diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d58649e65d..6f464634ad 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -2,7 +2,7 @@ name: Bug report about: Create a report to help us improve title: '' -type: bug +labels: bug assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 3ba13e0cec..5c2d2a4572 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1 +1,8 @@ blank_issues_enabled: false +contact_links: + - name: goose Discord discussion + url: https://discord.gg/goose-oss + about: Please ask and answer questions here. + - name: Report a security vulnerability + url: https://github.com/aaif-goose/goose/security/policy + about: Please report security vulnerabilities here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 2d5bb5e360..fbbcc255b8 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -2,14 +2,11 @@ name: Feature request about: Suggest an idea for this project title: '' -type: 'feature' +labels: 'enhancement' assignees: '' --- -**Before opening a feature request** -Please start a discussion in the [goose-eng Discord channel](https://discord.com/channels/1287729918100246654/1514412780504088677) before creating a feature request or beginning implementation. This helps align on direction before anyone spends time building. - **Please explain the motivation behind the feature request.** Does this feature solve a particular problem you have been experiencing? What opportunities or use cases would be unlocked with this feature? diff --git a/.github/ISSUE_TEMPLATE/submit-recipe.yml b/.github/ISSUE_TEMPLATE/submit-recipe.yml new file mode 100644 index 0000000000..4c632582d0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/submit-recipe.yml @@ -0,0 +1,99 @@ +name: ๐Ÿง‘โ€๐Ÿณ Submit a recipe to the goose cookbook +description: Share a reusable goose recipe with the community! +title: "[Recipe] " +labels: ["recipe submission"] +body: + - type: markdown + attributes: + value: | + Thanks for contributing to the goose cookbook! ๐Ÿณ + Recipes are reusable sessions created in goose desktop or CLI and shared with the community to help others vibe code faster. + + ๐Ÿ“Œ **How to Submit** + - Create your recipe using goose ("Make recipe from this session") + - Fill out the YAML below using the format provided + - Paste it into the field and submit the issue โ€” we'll review and add it to the cookbook! + + ๐Ÿช„ **What Happens After?** + - If accepted, we'll publish your recipe to the [goose recipes cookbook](https://goose-docs.ai/recipes) + - Your GitHub handle will be displayed and linked on the recipe card + - If the YAML has any issues, goose will comment with validation errors so you can fix and resubmit. + + ๐Ÿงช **Pro Tip:** You can test your recipe locally in your terminal with: + `goose recipe validate your-recipe.yaml` + + - type: textarea + id: recipe-yaml + attributes: + label: Paste Your Full Recipe YAML Below + description: Use the structure below and we'll auto-fill your GitHub handle for `author.contact` after submission. + placeholder: | + version: "1.0.0" + id: clean-up-feature-flag + title: Clean Up Feature Flag + description: Automatically clean up all references of a fully rolled out feature flag from a codebase and make the new behavior the default. + instructions: | + Your job is to systematically remove a fully rolled out feature flag and ensure the new behavior is now the default. Use code search tools like ripgrep to identify all references to the flag, clean up definition files, usage sites, tests, and configuration files. Then create a commit and push changes with clear commit messages documenting the flag removal. + prompt: | + Task: Remove a feature flag that has been fully rolled out, where the feature flag's functionality should become the default behavior. + + Context: + Feature flag key: {{ feature_flag_key }} + Project: {{ repo_dir }} + + Steps to follow: + 1. Check out a *new* branch from main or master named using the feature flag key. + 2. Find the feature flag constant/object that wraps the key. + 3. Search for all references to the constant/object using ripgrep or equivalent tools. + 4. Remove all conditional logic and make the new behavior default. + 5. Remove unused imports, mocks, config, and tests. + 6. Commit your changes and push the branch. + 7. Open a GitHub PR. + + Use commit messages like: + chore(flag-cleanup): remove flag from codebase + + parameters: + - key: feature_flag_key + input_type: string + requirement: required + description: Key of the feature flag + + - key: repo_dir + input_type: string + requirement: optional + default: ./ + description: Directory of the codebase + + extensions: + - type: stdio + name: developer + cmd: uvx + args: + - developer-mcp@latest + timeout: 300 + bundled: true + description: Access developer tools + + activities: + - Remove feature flag definitions + - Clean up feature flag usage sites + - Update affected tests + - Remove flag configurations + - Document flag removal + validations: + required: true + + - type: markdown + attributes: + value: | + ๐Ÿ›  **Recipe Field Tips** + - `version` must be "1.0.0" for now + - `id` should be lowercase, hyphenated, and unique (e.g. `my-awesome-recipe`) + - `title` is the display name of your recipe + - `description` should clearly explain what the recipe does + - `instructions` are specific steps goose should follow โ€” supports template variables like `{{ variable_name }}` + - `prompt` is the first thing goose sees when the recipe is launched + - `parameters` should include required or optional inputs โ€” optional ones must have `default` + - `extensions` must follow the full format with `type`, `cmd`, `args`, `timeout`, etc. + - `activities` describe the main actions the recipe performs diff --git a/.github/actions/generate-release-pr-body/pr_body_template.txt b/.github/actions/generate-release-pr-body/pr_body_template.txt index 48ffe753ab..e0c7e4d15d 100644 --- a/.github/actions/generate-release-pr-body/pr_body_template.txt +++ b/.github/actions/generate-release-pr-body/pr_body_template.txt @@ -2,7 +2,7 @@ ## Test before Release -1. Approve the workflows for this PR (scroll to bottom) to trigger CI and produce the Desktop bundle for testing. +1. Close and reopen this PR to trigger CI and produce the Desktop bundle for testing. Reason: workflows don't run on PRs opened by `GITHUB_TOKEN` ([docs](https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow)). 2. Make sure all check workflows pass. 3. Install the Desktop bundle from the download links (posted as a comment below when it is ready). 4. Complete the goose Release Manual Testing Checklist (posted as a comment below). diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5d902fbe55..51d3018cbb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -40,7 +40,7 @@ ## Project-Specific Context - This is a Rust project using cargo workspaces -- Core crates: `goose` (agent logic and ACP server), `goose-cli` (CLI), `goose-mcp` (MCP servers) +- Core crates: `goose` (agent logic), `goose-cli` (CLI), `goose-server` (backend), `goose-mcp` (MCP servers) - Error handling: Use `anyhow::Result`, not `unwrap()` in production code - Async runtime: tokio - MCP protocol implementations require extra scrutiny @@ -56,6 +56,7 @@ - `cargo fmt --check` - Code formatting (rustfmt) - `cargo test --jobs 2` - All tests - `cargo clippy --all-targets -- -D warnings` - Linting (clippy) +- `just check-openapi-schema` - OpenAPI schema validation **Desktop app checks:** - `pnpm install --frozen-lockfile` - Fresh dependency install (in `ui/desktop/`) diff --git a/.github/workflows/autoclose b/.github/workflows/autoclose new file mode 100644 index 0000000000..651fd3111c --- /dev/null +++ b/.github/workflows/autoclose @@ -0,0 +1,23 @@ +name: Close inactive issues +on: + schedule: + - cron: "30 1 * * *" + +jobs: + close-issues: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@v9 + with: + days-before-issue-stale: 60 + days-before-issue-close: 21 + stale-issue-label: "stale" + stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." + close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." + days-before-pr-stale: -1 + days-before-pr-close: -1 + any-of-labels: "bug,Bug" + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index f3b677c132..0e3f898864 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -86,12 +86,12 @@ jobs: - platform: windows architecture: x86_64 target-suffix: pc-windows-msvc - build-on: windows-2022 + build-on: windows-latest variant: cuda steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.ref }} @@ -299,7 +299,7 @@ jobs: echo "ARTIFACT_ZIP=target/${TARGET}/release/goose-${TARGET}${VARIANT_SUFFIX}.zip" >> $GITHUB_ENV - name: Upload CLI artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: goose-${{ matrix.architecture }}-${{ matrix.target-suffix }}${{ matrix.variant != 'standard' && matrix.variant != 'musl' && format('-{0}', matrix.variant) || '' }} path: | diff --git a/.github/workflows/build-notify.yml b/.github/workflows/build-notify.yml index 552aa275c5..8b2bf465eb 100644 --- a/.github/workflows/build-notify.yml +++ b/.github/workflows/build-notify.yml @@ -7,8 +7,6 @@ on: - Release - Canary types: [completed] - branches-ignore: - - "release/**" jobs: @@ -36,4 +34,4 @@ jobs: "color": 15158332 }] }' \ - "$DISCORD_WEBHOOK_URL" + "$DISCORD_WEBHOOK_URL" \ No newline at end of file diff --git a/.github/workflows/bundle-desktop-intel.yml b/.github/workflows/bundle-desktop-intel.yml index c2a26bf352..3923c6575b 100644 --- a/.github/workflows/bundle-desktop-intel.yml +++ b/.github/workflows/bundle-desktop-intel.yml @@ -49,7 +49,7 @@ jobs: run: df -h - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # Only pass ref if it's explicitly set, otherwise let checkout action use its default behavior ref: ${{ inputs.ref != '' && inputs.ref || '' }} @@ -73,11 +73,11 @@ jobs: key: intel-macos-deployment-target-12 - - name: Build desktop backend for Intel macOS (x86_64) + - name: Build goose-server for Intel macOS (x86_64) run: | source ./bin/activate-hermit rustup target add x86_64-apple-darwin - cargo build --release -p goose-cli --bin goose --target x86_64-apple-darwin + cargo build --release -p goose-server --target x86_64-apple-darwin @@ -95,16 +95,12 @@ jobs: # Check disk space after cleanup df -h - - name: Copy backend binary into Electron folder + - name: Copy binaries into Electron folder run: | - mkdir -p ui/desktop/src/bin - rm -f ui/desktop/src/bin/goose - cp target/x86_64-apple-darwin/release/goose ui/desktop/src/bin/goose - chmod +x ui/desktop/src/bin/goose - ls -la ui/desktop/src/bin/ + cp target/x86_64-apple-darwin/release/goosed ui/desktop/src/bin/goosed - name: Cache pnpm dependencies - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: | ui/desktop/node_modules @@ -156,10 +152,6 @@ jobs: fi working-directory: ui/desktop - - name: Verify macOS updater resources - run: node scripts/verify-mac-update-resources.js "out/Goose-darwin-x64/Goose.app" - working-directory: ui/desktop - - name: Clean up signing keychain if: always() run: | @@ -176,7 +168,7 @@ jobs: df -h - name: Upload Desktop artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Goose-darwin-x64 path: ui/desktop/out/Goose-darwin-x64/Goose_intel_mac.zip diff --git a/.github/workflows/bundle-desktop-linux.yml b/.github/workflows/bundle-desktop-linux.yml index ad3b84f194..d3fc279087 100644 --- a/.github/workflows/bundle-desktop-linux.yml +++ b/.github/workflows/bundle-desktop-linux.yml @@ -39,7 +39,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.ref || inputs.branch }} @@ -104,11 +104,6 @@ jobs: sudo apt-get install -y libasound2 fi - - name: Configure RPM packaging - run: | - echo '%_build_id_links none' > ~/.rpmmacros - test "$(rpm --eval '%{_build_id_links}')" = "none" - - name: Setup Flatpak Runtimes run: | flatpak remote-add --if-not-exists --user flathub https://dl.flathub.org/repo/flathub.flatpakrepo @@ -124,7 +119,7 @@ jobs: with: key: linux-${{ matrix.build-on }}-${{ matrix.variant }} - - name: Build desktop backend binary + - name: Build goosed binary env: RUST_LOG: debug RUST_BACKTRACE: 1 @@ -137,25 +132,19 @@ jobs: FEATURE_ARGS=(--features vulkan) fi - cargo build --release --target ${TARGET} -p goose-cli --bin goose "${FEATURE_ARGS[@]}" + cargo build --release --target ${TARGET} -p goose-server "${FEATURE_ARGS[@]}" - - name: Copy backend binary into Electron folder + - name: Copy binaries into Electron folder run: | - echo "Copying backend binary to ui/desktop/src/bin/" + echo "Copying binaries to ui/desktop/src/bin/" export TARGET="x86_64-unknown-linux-gnu" mkdir -p ui/desktop/src/bin - rm -f ui/desktop/src/bin/goose - cp target/$TARGET/release/goose ui/desktop/src/bin/ - chmod +x ui/desktop/src/bin/goose + cp target/$TARGET/release/goosed ui/desktop/src/bin/ + chmod +x ui/desktop/src/bin/goosed ls -la ui/desktop/src/bin/ - - name: Free Rust build artifacts before packaging - run: | - source ./bin/activate-hermit - cargo clean - - name: Cache pnpm dependencies - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: | ui/desktop/node_modules @@ -192,14 +181,6 @@ jobs: done fi - for package in out/make/rpm/x64/*.rpm; do - package_contents="$(rpm -qlp "$package")" - if grep -qE '^/usr/lib/\.build-id(/|$)' <<< "$package_contents"; then - echo "::error file=$package::RPM contains build-id links" - exit 1 - fi - done - echo "Build completed. Checking output..." ls -la out/ find out/ -name "*.deb" -o -name "*.rpm" -o -name "*.flatpak" | head -10 @@ -217,7 +198,7 @@ jobs: - name: Upload .deb package if: matrix.variant == 'standard' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Goose-linux-x64-deb path: ui/desktop/out/make/deb/x64/*.deb @@ -225,7 +206,7 @@ jobs: - name: Upload .deb package (Vulkan) if: matrix.variant == 'vulkan' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Goose-linux-x64-vulkan-deb path: ui/desktop/out/make/deb/x64/*-vulkan.deb @@ -233,7 +214,7 @@ jobs: - name: Upload .rpm package if: matrix.variant == 'standard' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Goose-linux-x64-rpm path: ui/desktop/out/make/rpm/x64/*.rpm @@ -241,7 +222,7 @@ jobs: - name: Upload .rpm package (Vulkan) if: matrix.variant == 'vulkan' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Goose-linux-x64-vulkan-rpm path: ui/desktop/out/make/rpm/x64/*-vulkan.rpm @@ -249,7 +230,7 @@ jobs: - name: Upload .flatpak package if: matrix.variant == 'standard' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Goose-linux-x64-flatpak path: ui/desktop/out/make/flatpak/x86_64/*.flatpak @@ -257,7 +238,7 @@ jobs: - name: Upload .flatpak package (Vulkan) if: matrix.variant == 'vulkan' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Goose-linux-x64-vulkan-flatpak path: ui/desktop/out/make/flatpak/x86_64/*-vulkan.flatpak diff --git a/.github/workflows/bundle-desktop-windows.yml b/.github/workflows/bundle-desktop-windows.yml index 6ceb29b82f..ae6be9b8a5 100644 --- a/.github/workflows/bundle-desktop-windows.yml +++ b/.github/workflows/bundle-desktop-windows.yml @@ -44,16 +44,16 @@ permissions: jobs: build-desktop-windows: name: Build Desktop (Windows) - runs-on: ${{ inputs.windows_variant == 'cuda' && 'windows-2022' || 'windows-latest' }} + runs-on: windows-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.ref != '' && inputs.ref || '' }} - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: 24.10.0 @@ -61,7 +61,7 @@ jobs: run: npm install -g pnpm@10.30.3 - name: Cache node_modules - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: | node_modules @@ -113,33 +113,28 @@ jobs: env: CUDA_COMPUTE_CAP: ${{ inputs.windows_variant == 'cuda' && '80' || '' }} run: | - $isCuda = "${{ inputs.windows_variant }}" -eq "cuda" - - Write-Output "Building Windows ACP backend" - if ($isCuda) { - cargo build --release --target x86_64-pc-windows-msvc -p goose-cli --bin goose --features cuda + Write-Output "Building Windows executable..." + if ("${{ inputs.windows_variant }}" -eq "cuda") { + cargo build --release --target x86_64-pc-windows-msvc -p goose-server --features cuda } else { - cargo build --release --target x86_64-pc-windows-msvc -p goose-cli --bin goose + cargo build --release --target x86_64-pc-windows-msvc -p goose-server } - $binaryPath = "./target/x86_64-pc-windows-msvc/release/goose.exe" # Verify build succeeded - if (-not (Test-Path $binaryPath)) { - Write-Error "Windows backend binary not found: $binaryPath" + if (-not (Test-Path "./target/x86_64-pc-windows-msvc/release/goosed.exe")) { + Write-Error "Windows binary not found." Get-ChildItem ./target/x86_64-pc-windows-msvc/release/ -ErrorAction SilentlyContinue exit 1 } - Write-Output "Windows backend binary found." - Get-Item $binaryPath + Write-Output "Windows binary found." + Get-Item ./target/x86_64-pc-windows-msvc/release/goosed.exe - name: Prepare Windows binary shell: bash run: | - BACKEND_BINARY="./target/x86_64-pc-windows-msvc/release/goose.exe" - - if [ ! -f "$BACKEND_BINARY" ]; then - echo "Windows backend binary not found: $BACKEND_BINARY" + if [ ! -f "./target/x86_64-pc-windows-msvc/release/goosed.exe" ]; then + echo "Windows binary not found." exit 1 fi @@ -147,14 +142,13 @@ jobs: rm -rf ./ui/desktop/src/bin mkdir -p ./ui/desktop/src/bin - echo "Copying Windows backend binary..." - cp -f "$BACKEND_BINARY" ./ui/desktop/src/bin/ + echo "Copying Windows binary..." + cp -f ./target/x86_64-pc-windows-msvc/release/goosed.exe ./ui/desktop/src/bin/ if [ -d "./ui/desktop/src/platform/windows/bin" ]; then echo "Copying Windows platform files..." for file in ./ui/desktop/src/platform/windows/bin/*.{exe,dll,cmd}; do - filename="$(basename "$file")" - if [ -f "$file" ] && [ "$filename" != "goose.exe" ]; then + if [ -f "$file" ] && [ "$(basename "$file")" != "goosed.exe" ]; then cp -f "$file" ./ui/desktop/src/bin/ fi done @@ -201,7 +195,7 @@ jobs: ls -la ./dist-windows/resources/bin/ - name: Upload unsigned distribution - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: windows-unsigned${{ inputs.windows_variant == 'cuda' && '-cuda' || '' }} path: ui/desktop/dist-windows/ @@ -235,14 +229,14 @@ jobs: certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }} files: | ${{ github.workspace }}/dist-windows/Goose.exe - ${{ github.workspace }}/dist-windows/resources/bin/goose.exe + ${{ github.workspace }}/dist-windows/resources/bin/goosed.exe - name: Verify signed executables shell: pwsh run: | $files = @( "dist-windows/Goose.exe", - "dist-windows/resources/bin/goose.exe" + "dist-windows/resources/bin/goosed.exe" ) foreach ($file in $files) { Write-Output "Verifying signature: $file" @@ -261,7 +255,7 @@ jobs: 7z a -tzip "${ZIP_NAME}.zip" dist-windows/ - name: Upload signed Windows build - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Goose-win32-x64${{ inputs.windows_variant == 'cuda' && '-cuda' || '' }} path: Goose-win32-x64${{ inputs.windows_variant == 'cuda' && '-cuda' || '' }}.zip @@ -290,7 +284,7 @@ jobs: 7z a -tzip "${ZIP_NAME}.zip" dist-windows/ - name: Upload Windows build - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Goose-win32-x64${{ inputs.windows_variant == 'cuda' && '-cuda' || '' }} path: Goose-win32-x64${{ inputs.windows_variant == 'cuda' && '-cuda' || '' }}.zip diff --git a/.github/workflows/bundle-desktop.yml b/.github/workflows/bundle-desktop.yml index f431a34bc7..0cb3d0b019 100644 --- a/.github/workflows/bundle-desktop.yml +++ b/.github/workflows/bundle-desktop.yml @@ -77,7 +77,7 @@ jobs: run: df -h - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # Only pass ref if it's explicitly set, otherwise let checkout action use its default behavior ref: ${{ inputs.ref != '' && inputs.ref || '' }} @@ -118,10 +118,8 @@ jobs: key: macos-deployment-target-12 # Build the project - - name: Build desktop backend - run: | - source ./bin/activate-hermit - cargo build --release -p goose-cli --bin goose + - name: Build goosed + run: source ./bin/activate-hermit && cargo build --release -p goose-server # Post-build cleanup to free space - name: Post-build cleanup @@ -136,16 +134,12 @@ jobs: # Check disk space after cleanup df -h - - name: Copy backend binary into Electron folder + - name: Copy binaries into Electron folder run: | - mkdir -p ui/desktop/src/bin - rm -f ui/desktop/src/bin/goose - cp target/release/goose ui/desktop/src/bin/goose - chmod +x ui/desktop/src/bin/goose - ls -la ui/desktop/src/bin/ + cp target/release/goosed ui/desktop/src/bin/goosed - name: Cache pnpm dependencies - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: | ui/desktop/node_modules @@ -190,10 +184,6 @@ jobs: fi working-directory: ui/desktop - - name: Verify macOS updater resources - run: node scripts/verify-mac-update-resources.js "out/Goose-darwin-arm64/Goose.app" - working-directory: ui/desktop - - name: Clean up signing keychain if: always() run: | @@ -211,7 +201,7 @@ jobs: - name: Upload Desktop artifact id: upload-app-bundle - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Goose-darwin-arm64 path: ui/desktop/out/Goose-darwin-arm64/Goose.zip diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index f93599880c..d8f8727bff 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -33,7 +33,7 @@ jobs: version: ${{ steps.set-version.outputs.version }} steps: # checkout code so we can read the Cargo.toml - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Generate a canary version id: set-version run: | @@ -60,14 +60,14 @@ jobs: runs-on: ubuntu-latest needs: [build-cli] steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: download_cli.sh path: download_cli.sh # ------------------------------------------------------------ - # 4) Bundle Desktop App (macOS only) + # 4) Bundle Desktop App (macOS only) - builds goosed and Electron app # ------------------------------------------------------------ bundle-desktop: needs: [prepare-version] @@ -80,7 +80,7 @@ jobs: signing: false # ------------------------------------------------------------ - # 5) Bundle Desktop App (macOS Intel) + # 5) Bundle Desktop App (macOS Intel) - builds goosed and Electron app # ------------------------------------------------------------ bundle-desktop-intel: needs: [prepare-version] @@ -93,7 +93,7 @@ jobs: signing: false # ------------------------------------------------------------ - # 6) Bundle Desktop App (Linux) + # 6) Bundle Desktop App (Linux) - builds goosed and Electron app # ------------------------------------------------------------ bundle-desktop-linux: needs: [prepare-version] @@ -102,7 +102,7 @@ jobs: version: ${{ needs.prepare-version.outputs.version }} # ------------------------------------------------------------ - # 6) Bundle Desktop App (Windows) + # 6) Bundle Desktop App (Windows) - builds goosed and Electron app # ------------------------------------------------------------ bundle-desktop-windows: needs: [prepare-version] @@ -138,7 +138,7 @@ jobs: merge-multiple: true - name: Attest build provenance - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-path: | goose-*.tar.bz2 diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index 0e917b04e8..ee7cecce5a 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -22,8 +22,8 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v6 # https://github.com/EmbarkStudios/cargo-deny-action v2.0.15 - - uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe + - uses: EmbarkStudios/cargo-deny-action@a531616d8ce3b9177443e48a1159bc945a099823 with: command: check advisories diff --git a/.github/workflows/cargo-machete.yml b/.github/workflows/cargo-machete.yml index 58e64f2e53..f9641958b8 100644 --- a/.github/workflows/cargo-machete.yml +++ b/.github/workflows/cargo-machete.yml @@ -20,5 +20,5 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: bnjbvr/cargo-machete@ac30a525c0a8d163a92d727b3ff079ee3f6ecb08 # v0.9.2 diff --git a/.github/workflows/check-release-pr.yaml b/.github/workflows/check-release-pr.yaml index f900317017..2601b18ad7 100644 --- a/.github/workflows/check-release-pr.yaml +++ b/.github/workflows/check-release-pr.yaml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest if: startsWith(github.head_ref, 'release/') steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.head_ref }} fetch-depth: 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1216340871..34201a59c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: code: ${{ steps.filter.outputs.code }} steps: - name: Checkout Code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check for file changes uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v3 @@ -39,9 +39,9 @@ jobs: if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' steps: - name: Checkout Code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 - name: Run cargo fmt run: cargo fmt --check @@ -53,9 +53,9 @@ jobs: if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' steps: - name: Checkout Code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 - name: Install Dependencies run: | @@ -82,7 +82,7 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - name: Checkout Code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Cache Cargo artifacts uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 @@ -106,7 +106,7 @@ jobs: needs: changes if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Read MSRV from Cargo.toml id: msrv @@ -119,7 +119,7 @@ jobs: echo "msrv=$msrv" >> "$GITHUB_OUTPUT" echo "MSRV: $msrv" - - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 + - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 with: toolchain: ${{ steps.msrv.outputs.msrv }} @@ -143,9 +143,9 @@ jobs: needs: changes if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 @@ -165,9 +165,9 @@ jobs: if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' steps: - name: Checkout Code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 - name: Install Dependencies run: | @@ -183,18 +183,17 @@ jobs: cd ui/desktop && pnpm install --frozen-lockfile cd ../sdk && pnpm install --frozen-lockfile + - name: Check OpenAPI Schema is Up-to-Date + run: | + source ./bin/activate-hermit + hermit uninstall rustup + just check-openapi-schema + - name: Check ACP Schema is Up-to-Date run: | source ./bin/activate-hermit just check-acp-schema - - name: Test ACP Client SDK - run: | - source ./bin/activate-hermit - cd ui/sdk - pnpm test - pnpm run typecheck:test - desktop-lint: name: Test and Lint Electron Desktop App runs-on: macos-latest @@ -202,7 +201,7 @@ jobs: if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' steps: - name: Checkout Code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # Temporarily disabled due to GitHub Actions bug on macOS runners # https://github.com/actions/runner-images/issues/13341 diff --git a/.github/workflows/close-release-pr-on-tag.yaml b/.github/workflows/close-release-pr-on-tag.yaml index 05e0f2d418..5eb8d16e5c 100644 --- a/.github/workflows/close-release-pr-on-tag.yaml +++ b/.github/workflows/close-release-pr-on-tag.yaml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Extract version from tag id: version diff --git a/.github/workflows/code-review.yml b/.github/workflows/code-review.yml index 57f642396e..014ec55efa 100644 --- a/.github/workflows/code-review.yml +++ b/.github/workflows/code-review.yml @@ -64,7 +64,7 @@ jobs: OIDC_TOKEN: ${{ steps.oidc.outputs.token }} - name: Upload review inputs - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: code-review-inputs path: /tmp/code-review/ @@ -84,7 +84,7 @@ jobs: steps: - name: Checkout base branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install tools run: | @@ -119,7 +119,7 @@ jobs: - name: Upload review output if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: code-review-output path: | diff --git a/.github/workflows/create-release-branch.yaml b/.github/workflows/create-release-branch.yaml index bca8171ebf..75b4c4a541 100644 --- a/.github/workflows/create-release-branch.yaml +++ b/.github/workflows/create-release-branch.yaml @@ -18,13 +18,13 @@ jobs: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'version-bump/') steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 0 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 + - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 - name: Extract version and base branch env: diff --git a/.github/workflows/create-version-bump-pr.yaml b/.github/workflows/create-version-bump-pr.yaml index 0e4d070ded..7707a138fb 100644 --- a/.github/workflows/create-version-bump-pr.yaml +++ b/.github/workflows/create-version-bump-pr.yaml @@ -25,77 +25,64 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: main - fetch-depth: 0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + fetch-depth: 0 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 + - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 - - name: install dependencies - run: | - sudo apt update -y - sudo apt install -y libdbus-1-dev gnome-keyring libxcb1-dev + - name: install dependencies + run: | + sudo apt update -y + sudo apt install -y libdbus-1-dev gnome-keyring libxcb1-dev - - name: Compute version and create bump branch - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} - OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - XAI_API_KEY: ${{ secrets.XAI_API_KEY }} - TETRATE_API_KEY: ${{ secrets.TETRATE_API_KEY }} - run: | - VERSION=$(just get-next-minor-version) - echo "version=$VERSION" >> $GITHUB_ENV - echo "Version: $VERSION (minor bump on main)" + - name: Compute version and create bump branch + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + TETRATE_API_KEY: ${{ secrets.TETRATE_API_KEY }} + run: | + VERSION=$(just get-next-minor-version) + echo "version=$VERSION" >> $GITHUB_ENV + echo "Version: $VERSION (minor bump on main)" - git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" + git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" - BRANCH_NAME="version-bump/$VERSION" - git switch -C "$BRANCH_NAME" + BRANCH_NAME="version-bump/$VERSION" + git switch -c "$BRANCH_NAME" - just prepare-release $VERSION + just prepare-release $VERSION - echo "branch_name=$BRANCH_NAME" >> $GITHUB_ENV + echo "branch_name=$BRANCH_NAME" >> $GITHUB_ENV - - name: Push bump branch - run: | - git push --force-with-lease origin "${{ env.branch_name }}" + - name: Push bump branch + run: | + git push origin "${{ env.branch_name }}" - - name: Create Pull Request if needed - run: | - EXISTING_PR_URL=$(gh pr list \ - --base main \ - --head "${{ env.branch_name }}" \ - --state open \ - --json url \ - --jq '.[0].url // empty') + - name: Create Pull Request + run: | + PR_BODY=$(cat <<'EOF' + Bumps version to **${{ env.version }}**. - if [ -n "$EXISTING_PR_URL" ]; then - echo "Existing PR found: $EXISTING_PR_URL" - echo "pr_url=$EXISTING_PR_URL" >> $GITHUB_ENV - exit 0 - fi + **Please follow these steps:** - PR_BODY=$(cat <<'EOF' - Bumps version to **${{ env.version }}**. - - **Please follow these steps:** - - 1. Approve workflows for this PR to trigger CI checks. - 2. Review and resolve any merge conflicts. - 3. Approve and merge this PR. - 4. The `release/${{ env.version }}` PR will be created automatically. - EOF - ) - PR_URL=$(gh pr create \ - -B main \ - -H "${{ env.branch_name }}" \ - --title "chore(release): bump version to ${{ env.version }} (minor)" \ - --body "$PR_BODY") - echo "pr_url=$PR_URL" >> $GITHUB_ENV - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + 1. Close and reopen this PR to trigger CI checks. Reason: workflows don't run on PRs opened by `GITHUB_TOKEN` ([docs](https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow)). + 2. Review and resolve any merge conflicts. + 3. Approve and merge this PR. + 4. The `release/${{ env.version }}` PR will be created automatically. + EOF + ) + PR_URL=$(gh pr create \ + -B main \ + -H "${{ env.branch_name }}" \ + --title "chore(release): bump version to ${{ env.version }} (minor)" \ + --body "$PR_BODY") + echo "pr_url=$PR_URL" >> $GITHUB_ENV + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/deploy-docs-and-extensions.yml b/.github/workflows/deploy-docs-and-extensions.yml index d5f4bf2952..b93b5e7f70 100644 --- a/.github/workflows/deploy-docs-and-extensions.yml +++ b/.github/workflows/deploy-docs-and-extensions.yml @@ -24,15 +24,15 @@ jobs: steps: - name: Checkout the branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: 20 - name: Cache Node.js modules (documentation) - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: ./documentation/node_modules key: ${{ runner.os }}-documentation-${{ hashFiles('./documentation/package-lock.json') }} @@ -58,10 +58,10 @@ jobs: uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Upload GitHub Pages artifact - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 with: path: documentation/build - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.github/workflows/docs-update-cli-ref.yml b/.github/workflows/docs-update-cli-ref.yml index 9ac5cfca7d..3df35a340e 100644 --- a/.github/workflows/docs-update-cli-ref.yml +++ b/.github/workflows/docs-update-cli-ref.yml @@ -43,7 +43,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # Fetch all history for version comparison fetch-tags: true # Fetch all tags so we can checkout version tags @@ -63,7 +63,7 @@ jobs: sudo apt-get install -y jq ripgrep - name: Set up Rust - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 with: toolchain: stable @@ -73,7 +73,7 @@ jobs: python-version: '3.11' - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' @@ -191,7 +191,7 @@ jobs: - name: Upload automation outputs if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: cli-docs-update-${{ steps.versions.outputs.old_version }}-to-${{ steps.versions.outputs.new_version }} path: | diff --git a/.github/workflows/docs-update-recipe-ref.yml b/.github/workflows/docs-update-recipe-ref.yml new file mode 100644 index 0000000000..a8c09cb0e8 --- /dev/null +++ b/.github/workflows/docs-update-recipe-ref.yml @@ -0,0 +1,292 @@ +# Automatically updates the Recipe Reference Guide when recipe struct fields, +# validation rules, or schema constraints change between releases. +# +# Triggers: Manual (for testing) or on release (production) +# Testing: Use dry_run mode to review outputs without creating PRs +# See: documentation/automation/recipe-schema-tracking/TESTING.md + +name: Update Recipe Documentation + +on: + workflow_dispatch: # Manual trigger for testing + inputs: + old_version: + description: 'Previous version (e.g., v1.14.0). Leave empty to auto-detect.' + required: false + type: string + new_version: + description: 'New version (e.g., v1.15.0). Leave empty to use HEAD.' + required: false + type: string + dry_run: + description: 'Dry run mode - generate files but do not create PR' + required: false + type: boolean + default: true + + release: + types: [published] + +permissions: + contents: write # Create branches and commit files + pull-requests: write # Create PRs + +jobs: + update-docs: + name: Update Recipe Documentation + runs-on: ubuntu-latest + + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 # Fetch all history for version comparison + fetch-tags: true # Fetch all tags so we can checkout version tags + + - name: Fetch upstream tags (for forks) + if: github.repository != 'aaif-goose/goose' + run: | + # Add upstream remote and fetch tags (only needed when testing in forks) + git remote add upstream https://github.com/aaif-goose/goose.git || git remote set-url upstream https://github.com/aaif-goose/goose.git + git fetch upstream --tags --force + echo "โœ… Fetched tags from upstream (fork mode)" + echo "Total tags available: $(git tag | wc -l)" + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y jq ripgrep + + - name: Set up Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: '20' + + - name: Install goose CLI + run: | + mkdir -p /home/runner/.local/bin + curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh \ + | CONFIGURE=false GOOSE_BIN_DIR=/home/runner/.local/bin bash + echo "/home/runner/.local/bin" >> $GITHUB_PATH + goose --version + + - name: Configure goose for CI + env: + GOOSE_PROVIDER: ${{ vars.GOOSE_PROVIDER || 'openai' }} + GOOSE_MODEL: ${{ vars.GOOSE_MODEL || 'gpt-4o' }} + run: | + mkdir -p ~/.config/goose + cat < ~/.config/goose/config.yaml + GOOSE_PROVIDER: $GOOSE_PROVIDER + GOOSE_MODEL: $GOOSE_MODEL + keyring: false + EOF + echo "โœ… Created goose config:" + cat ~/.config/goose/config.yaml + + - name: Determine versions to compare + id: versions + env: + GH_TOKEN: ${{ github.token }} + INPUT_OLD_VERSION: ${{ github.event.inputs.old_version }} + INPUT_NEW_VERSION: ${{ github.event.inputs.new_version }} + EVENT_NAME: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + get_previous_release() { + gh release list --limit 2 --json tagName --jq '.[].tagName' | sed -n '2p' + } + + if [ -n "$INPUT_OLD_VERSION" ]; then + OLD_VERSION="$INPUT_OLD_VERSION" + else + OLD_VERSION=$(get_previous_release) + fi + + if [ -n "$INPUT_NEW_VERSION" ]; then + NEW_VERSION="$INPUT_NEW_VERSION" + elif [ "$EVENT_NAME" = "release" ]; then + NEW_VERSION="$RELEASE_TAG" + else + NEW_VERSION="HEAD" # For testing unreleased changes + fi + + if [ -z "$OLD_VERSION" ] || [ -z "$NEW_VERSION" ]; then + echo "Error: Could not determine versions to compare" + exit 1 + fi + + echo "old_version=$OLD_VERSION" >> $GITHUB_OUTPUT + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "OLD_VERSION=$OLD_VERSION" >> $GITHUB_ENV + echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV + + echo "โœ… Comparing $OLD_VERSION โ†’ $NEW_VERSION" + + - name: Extract and compare schemas + id: extract + timeout-minutes: 15 + working-directory: documentation/automation/recipe-schema-tracking + env: + GOOSE_REPO: ${{ github.workspace }} + run: | + set -o pipefail # Ensure pipeline failures are caught + + mkdir -p output + ./scripts/run-pipeline.sh "$OLD_VERSION" "$NEW_VERSION" 2>&1 | tee output/pipeline.log + + HAS_CHANGES=$(jq -r '.has_changes' output/validation-changes.json) + echo "has_changes=$HAS_CHANGES" >> $GITHUB_OUTPUT + + if [ "$HAS_CHANGES" = "false" ]; then + echo "โœ… No changes detected" + else + echo "โœ… Changes detected" + fi + + - name: Update recipe-reference.md (AI synthesis) + if: steps.extract.outputs.has_changes == 'true' + timeout-minutes: 10 + working-directory: documentation/automation/recipe-schema-tracking/output + env: + RECIPE_REF_PATH: ${{ github.workspace }}/documentation/docs/guides/recipes/recipe-reference.md + run: | + echo "๐Ÿ” Environment diagnostics:" + echo " GOOSE_PROVIDER: $GOOSE_PROVIDER" + echo " GOOSE_MODEL: $GOOSE_MODEL" + echo " OPENAI_API_KEY: ${OPENAI_API_KEY:0:8}..." # Show first 8 chars only + echo " RECIPE_REF_PATH: $RECIPE_REF_PATH" + echo " HOME: $HOME" + echo " PATH: $PATH" + echo "" + echo "๐Ÿ“ Goose config file:" + cat ~/.config/goose/config.yaml || echo "Config file not found!" + echo "" + echo "๐Ÿ“ Current directory:" + pwd + ls -la + echo "" + echo "๐Ÿค– Step 1: Running validation changes synthesis..." + goose run --recipe ../recipes/synthesize-validation-changes.yaml + + echo "" + echo "๐Ÿค– Step 2: Applying changes to recipe-reference.md..." + goose run --recipe ../recipes/update-recipe-reference.yaml + + - name: Upload automation outputs + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: recipe-docs-update-${{ steps.versions.outputs.old_version }}-to-${{ steps.versions.outputs.new_version }} + path: | + documentation/automation/recipe-schema-tracking/output/*.json + documentation/automation/recipe-schema-tracking/output/*.md + documentation/automation/recipe-schema-tracking/output/*.log + retention-days: 30 + + - name: Create Pull Request + if: | + steps.extract.outputs.has_changes == 'true' && + (github.event.inputs.dry_run != 'true' || github.event_name == 'release') + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + branch: docs/auto-recipe-reference-${{ steps.versions.outputs.new_version }} + delete-branch: true + + commit-message: | + docs: Update recipe reference for ${{ steps.versions.outputs.new_version }} + + Automated update based on schema changes between ${{ steps.versions.outputs.old_version }} and ${{ steps.versions.outputs.new_version }}. + + title: "docs: Update Recipe Reference Guide for ${{ steps.versions.outputs.new_version }}" + body: | + ## Summary + + This PR updates the Recipe Reference Guide based on schema and validation changes detected between **${{ steps.versions.outputs.old_version }}** and **${{ steps.versions.outputs.new_version }}**. + + ### Type of Change + - [x] Documentation + + ### AI Assistance + - [x] This PR was created or reviewed with AI assistance + + #### ๐Ÿค– Automation Details + + - **Workflow**: `docs-update-recipe-ref.yml` + - **Triggered by**: ${{ github.event_name }} + - **Previous version**: ${{ steps.versions.outputs.old_version }} + - **New version**: ${{ steps.versions.outputs.new_version }} + + #### ๐Ÿ“‹ Changes Detected + + Review the workflow artifacts for detailed change analysis: + - `validation-changes.json` - Structured diff of changes + - `validation-changes.md` - Human-readable change documentation + - `update-summary.md` - Summary of documentation updates applied + + Download artifacts from the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). + + ### โœ… Review Checklist + + - [ ] Verify all schema changes are accurately documented + - [ ] Check that examples are updated correctly + - [ ] Ensure validation rules are clearly explained + - [ ] Confirm no unintended changes were made + - [ ] Do changes require additional updates in this or other recipe topics? + + ### ๐Ÿ”— Related + + - Release: ${{ github.event.release.html_url || 'N/A' }} + + --- + + *This PR was automatically generated by the Recipe Documentation Automation workflow.* + + labels: | + documentation + automated + recipe-reference + + - name: Workflow summary + if: always() + env: + OLD_VERSION: ${{ steps.versions.outputs.old_version }} + NEW_VERSION: ${{ steps.versions.outputs.new_version }} + HAS_CHANGES: ${{ steps.extract.outputs.has_changes }} + DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} + run: | + echo "## ๐Ÿ“Š Recipe Documentation Update Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Version Comparison**: $OLD_VERSION โ†’ $NEW_VERSION" >> $GITHUB_STEP_SUMMARY + echo "**Changes Detected**: $HAS_CHANGES" >> $GITHUB_STEP_SUMMARY + echo "**Dry Run Mode**: $DRY_RUN" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "$HAS_CHANGES" = "true" ]; then + echo "### โœ… Documentation Updated" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "The Recipe Reference Guide has been updated to reflect changes in $NEW_VERSION." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "$DRY_RUN" = "true" ]; then + echo "**Note**: Running in dry-run mode - no PR was created. Review the artifacts to see the generated changes." >> $GITHUB_STEP_SUMMARY + else + echo "A pull request has been created with the documentation updates." >> $GITHUB_STEP_SUMMARY + fi + else + echo "### โ„น๏ธ No Changes Needed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "No recipe schema or validation changes were detected between $OLD_VERSION and $NEW_VERSION." >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "### ๐Ÿ“ฆ Artifacts" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Download the workflow artifacts to review:" >> $GITHUB_STEP_SUMMARY + echo "- Extracted schemas and validation structures" >> $GITHUB_STEP_SUMMARY + echo "- Change detection results" >> $GITHUB_STEP_SUMMARY + echo "- Human-readable change documentation" >> $GITHUB_STEP_SUMMARY + echo "- Documentation update summary" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/goose-issue-solver.yml b/.github/workflows/goose-issue-solver.yml index 8ef17abb30..af5caea73b 100644 --- a/.github/workflows/goose-issue-solver.yml +++ b/.github/workflows/goose-issue-solver.yml @@ -30,7 +30,7 @@ env: - Deletion is a feature: ask "can I delete code instead of adding?" - Complete changes: update ALL usages when changing a type. No exceptions. - When changing message types, update ALL provider format functions. - - When changing ACP request/response types, run `just generate-acp-types`. + - When changing server routes, run `just generate-openapi`. - Your context degrades. The TODO is your memory. Update it after each step. Types: @@ -150,7 +150,7 @@ jobs: -d '{"content":"eyes"}' - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: main fetch-depth: 1 diff --git a/.github/workflows/goose-pr-reviewer.yml b/.github/workflows/goose-pr-reviewer.yml index 0d2a272d0c..4e0e9df455 100644 --- a/.github/workflows/goose-pr-reviewer.yml +++ b/.github/workflows/goose-pr-reviewer.yml @@ -241,7 +241,7 @@ jobs: -d '{"content":"eyes"}' - name: Checkout PR - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: refs/pull/${{ github.event.issue.number }}/head fetch-depth: 1 diff --git a/.github/workflows/goose-release-notes.yml b/.github/workflows/goose-release-notes.yml index 9a4ffd2c7e..bb0d552af8 100644 --- a/.github/workflows/goose-release-notes.yml +++ b/.github/workflows/goose-release-notes.yml @@ -15,8 +15,6 @@ on: - Release types: - completed - branches-ignore: - - "release/**" # Allow manual trigger for testing workflow_dispatch: @@ -78,7 +76,7 @@ jobs: generate-release-notes: name: Generate Release Notes runs-on: ubuntu-latest - # For workflow_run: release branch preflight builds are ignored by the trigger. + # For workflow_run: only run if Release succeeded and tag is not 'stable' # For workflow_dispatch: only run if tag is not 'stable' if: | (github.event_name == 'workflow_dispatch' && inputs.tag != 'stable') || @@ -115,7 +113,7 @@ jobs: fi - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 fetch-tags: true diff --git a/.github/workflows/minor-release.yaml b/.github/workflows/minor-release.yaml index e4e32d63bb..539dc33543 100644 --- a/.github/workflows/minor-release.yaml +++ b/.github/workflows/minor-release.yaml @@ -6,61 +6,12 @@ permissions: on: schedule: - - cron: "0 0 * * 2" + - cron: '0 0 * * 2' workflow_dispatch: - push: - branches: - - main jobs: - check-version-bump-pr: - if: github.repository == 'aaif-goose/goose' && github.event_name == 'push' - runs-on: ubuntu-latest - outputs: - should_run: ${{ steps.check.outputs.should_run }} - steps: - - name: Check for conflicted version bump PR - id: check - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - for attempt in 1 2 3 4 5 6; do - CONFLICTING_PR_URLS=$(gh pr list \ - --repo "$GITHUB_REPOSITORY" \ - --base main \ - --state open \ - --search 'head:version-bump/' \ - --json mergeStateStatus,url \ - --jq '.[] | select(.mergeStateStatus == "DIRTY") | .url') - - UNKNOWN_COUNT=$(gh pr list \ - --repo "$GITHUB_REPOSITORY" \ - --base main \ - --state open \ - --search 'head:version-bump/' \ - --json mergeStateStatus \ - --jq '[.[] | select(.mergeStateStatus == "UNKNOWN")] | length') - - if [ -n "$CONFLICTING_PR_URLS" ] || [ "$UNKNOWN_COUNT" = "0" ]; then - break - fi - - echo "Waiting for GitHub to compute PR mergeability (attempt $attempt)..." - sleep 10 - done - - if [ -n "$CONFLICTING_PR_URLS" ]; then - echo "Found conflicted version bump PR(s):" - echo "$CONFLICTING_PR_URLS" - echo "should_run=true" >> "$GITHUB_OUTPUT" - else - echo "No conflicted version bump PR found." - echo "should_run=false" >> "$GITHUB_OUTPUT" - fi - release: - needs: check-version-bump-pr - if: always() && github.repository == 'aaif-goose/goose' && (github.event_name != 'push' || needs.check-version-bump-pr.outputs.should_run == 'true') + if: github.repository == 'aaif-goose/goose' uses: ./.github/workflows/create-version-bump-pr.yaml secrets: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/.github/workflows/patch-release.yaml b/.github/workflows/patch-release.yaml index b9936551ca..8b0b375cb2 100644 --- a/.github/workflows/patch-release.yaml +++ b/.github/workflows/patch-release.yaml @@ -19,13 +19,13 @@ jobs: TARGET_BRANCH: ${{ inputs.target_branch }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.target_branch }} fetch-depth: 0 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 + - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 - name: install dependencies run: | diff --git a/.github/workflows/pr-comment-build-cli.yml b/.github/workflows/pr-comment-build-cli.yml index 70aaa24cc8..da280f16a8 100644 --- a/.github/workflows/pr-comment-build-cli.yml +++ b/.github/workflows/pr-comment-build-cli.yml @@ -92,7 +92,7 @@ jobs: - name: Checkout code if: steps.security_check.outputs.authorized == 'true' - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Get PR head SHA with gh id: set_head_sha diff --git a/.github/workflows/pr-comment-bundle-intel.yml b/.github/workflows/pr-comment-bundle-intel.yml index 6b8fba33c3..ed88f6571a 100644 --- a/.github/workflows/pr-comment-bundle-intel.yml +++ b/.github/workflows/pr-comment-bundle-intel.yml @@ -45,7 +45,7 @@ jobs: allowed_contexts: pull_request - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Get PR head SHA with gh id: set_head_sha diff --git a/.github/workflows/pr-comment-bundle-windows.yml b/.github/workflows/pr-comment-bundle-windows.yml index b6a7840059..69ac315c3a 100644 --- a/.github/workflows/pr-comment-bundle-windows.yml +++ b/.github/workflows/pr-comment-bundle-windows.yml @@ -48,7 +48,7 @@ jobs: allowed_contexts: pull_request - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Get PR head SHA with gh id: set_head_sha diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml index ae83ad2afe..814658875f 100644 --- a/.github/workflows/pr-smoke-test.yml +++ b/.github/workflows/pr-smoke-test.yml @@ -30,7 +30,7 @@ jobs: code: ${{ steps.filter.outputs.code }} steps: - name: Checkout Code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.inputs.branch || github.ref }} fetch-depth: 0 @@ -51,11 +51,11 @@ jobs: if: needs.changes.outputs.code == 'true' || github.event_name == 'workflow_dispatch' steps: - name: Checkout Code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.inputs.branch || github.ref }} - - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 - name: Install Dependencies run: | @@ -67,22 +67,29 @@ jobs: - name: Build Binary for Smoke Tests run: | - cargo build --bin goose + cargo build --bin goose --bin goosed - name: Upload goose binary - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: goose-binary path: target/debug/goose retention-days: 1 + - name: Upload goosed binary + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: goosed-binary + path: target/debug/goosed + retention-days: 1 + smoke-tests: name: Smoke Tests runs-on: ubuntu-latest needs: build-binary steps: - name: Checkout Code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.inputs.branch || github.ref }} @@ -96,7 +103,7 @@ jobs: run: chmod +x target/debug/goose - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v4 with: node-version: '22' @@ -135,7 +142,7 @@ jobs: python-version: '3.12' - name: Install uv - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 - name: Run MCP Tests env: @@ -170,7 +177,7 @@ jobs: needs: build-binary steps: - name: Checkout Code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.inputs.branch || github.ref }} @@ -213,7 +220,7 @@ jobs: needs: build-binary steps: - name: Checkout Code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.inputs.branch || github.ref }} @@ -232,7 +239,7 @@ jobs: python-version: '3.12' - name: Install uv - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 - name: Run Compaction Tests env: @@ -246,3 +253,39 @@ jobs: mkdir -p $HOME/.local/share/goose/sessions mkdir -p $HOME/.config/goose bash scripts/test_compaction.sh + + goosed-integration-tests: + name: goose server HTTP integration tests + runs-on: ubuntu-latest + needs: build-binary + steps: + - name: Checkout Code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.inputs.branch || github.ref }} + + - name: Download Binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: goosed-binary + path: target/debug + + - name: Make Binary Executable + run: chmod +x target/debug/goosed + + - name: Install Node.js Dependencies + run: source ../../bin/activate-hermit && pnpm install --frozen-lockfile + working-directory: ui/desktop + + - name: Run Integration Tests + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GOOSED_BINARY: ../../target/debug/goosed + 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:goosed + working-directory: ui/desktop diff --git a/.github/workflows/pr-website-preview.yml b/.github/workflows/pr-website-preview.yml index 56f7bd2aae..b7db3e4d65 100644 --- a/.github/workflows/pr-website-preview.yml +++ b/.github/workflows/pr-website-preview.yml @@ -20,16 +20,12 @@ jobs: permissions: contents: read pull-requests: write - env: - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.PAGES_PR_PREVIEW_CF_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.PAGES_PR_PREVIEW_CF_API_TOKEN }} - CLOUDFLARE_PAGES_PROJECT_NAME: ${{ secrets.PAGES_PR_PREVIEW_CF_PAGES_PROJECT_NAME }} steps: - name: Checkout the branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node.js for docs build - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: 20 cache: npm @@ -51,16 +47,17 @@ jobs: run: ./scripts/verify-build.sh - name: Setup Node.js for Wrangler - if: env.CLOUDFLARE_API_TOKEN != '' - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: 22 - name: Deploy preview to Cloudflare Pages - if: env.CLOUDFLARE_API_TOKEN != '' id: cloudflare-pages working-directory: ./documentation env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.PAGES_PR_PREVIEW_CF_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.PAGES_PR_PREVIEW_CF_API_TOKEN }} + CLOUDFLARE_PAGES_PROJECT_NAME: ${{ secrets.PAGES_PR_PREVIEW_CF_PAGES_PROJECT_NAME }} PR_NUMBER: ${{ github.event.number }} run: | set -euo pipefail @@ -79,7 +76,6 @@ jobs: echo "preview-url=$preview_url" >> "$GITHUB_OUTPUT" - name: Comment preview URL - if: env.CLOUDFLARE_API_TOKEN != '' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PREVIEW_URL: ${{ steps.cloudflare-pages.outputs.preview-url }} diff --git a/.github/workflows/publish-ask-ai-bot.yml b/.github/workflows/publish-ask-ai-bot.yml index 5bd90040c6..c25a4228f5 100644 --- a/.github/workflows/publish-ask-ai-bot.yml +++ b/.github/workflows/publish-ask-ai-bot.yml @@ -19,13 +19,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to GitHub Container Registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -33,7 +33,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: ghcr.io/${{ github.repository_owner }}/ask-ai-bot tags: | @@ -42,7 +42,7 @@ jobs: type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # pin@v7.3.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # pin@v7.2.0 with: context: . file: services/ask-ai-bot/Dockerfile diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index c15177fa4a..0c08ed6eef 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -23,13 +23,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to GitHub Container Registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -37,7 +37,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: ghcr.io/${{ github.repository_owner }}/goose tags: | @@ -54,7 +54,7 @@ jobs: - name: Build and push Docker image id: docker-push - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # pin@v7.3.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # pin@v7.2.0 with: context: . push: true @@ -65,7 +65,7 @@ jobs: platforms: linux/amd64,linux/arm64 - name: Attest Docker image - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: ghcr.io/${{ github.repository_owner }}/goose subject-digest: ${{ steps.docker-push.outputs.digest }} diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 1653b1dc30..3fdac7136a 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -22,17 +22,17 @@ jobs: needs: [build-cli] steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '24.10.0' registry-url: 'https://registry.npmjs.org' always-auth: true - name: Setup pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6.0.5 with: version: 10.30.3 @@ -100,7 +100,10 @@ jobs: cd ui/sdk pnpm run build:ts - - name: Compatibility check (SDK client โ†” goose binary) + cd ../text + pnpm run build + + - name: Compatibility check (TUI client โ†” goose binary) env: GOOSE_BINARY: ${{ github.workspace }}/ui/goose-binary/goose-binary-linux-x64/bin/goose run: | @@ -108,7 +111,7 @@ jobs: pnpm run check:compat - name: Upload built packages - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: npm-packages path: ui/ @@ -132,7 +135,7 @@ jobs: echo "" echo "### npm Packages" cd ui - for pkg in sdk goose-binary/*/; do + for pkg in sdk text goose-binary/*/; do if [ -f "$pkg/package.json" ]; then name=$(jq -r '.name' "$pkg/package.json") version=$(jq -r '.version' "$pkg/package.json") @@ -157,14 +160,14 @@ jobs: run: find ui/goose-binary -name 'goose' -type f -exec chmod +x {} + - name: Setup Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '24.10.0' registry-url: 'https://registry.npmjs.org' always-auth: true - name: Setup pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6.0.5 with: version: 10.30.3 @@ -174,7 +177,7 @@ jobs: # Publish each package individually so one failure doesn't abort the rest. # Skips packages whose version is already published (409/403). failed=0 - for pkg in sdk goose-binary/*/; do + for pkg in sdk goose-binary/*/ text; do if [ -f "$pkg/package.json" ]; then name=$(jq -r '.name' "$pkg/package.json") version=$(jq -r '.version' "$pkg/package.json") @@ -204,7 +207,7 @@ jobs: { echo "## ๐Ÿš€ Published Packages" echo "" - for pkg in sdk goose-binary/*/; do + for pkg in sdk text goose-binary/*/; do if [ -f "$pkg/package.json" ]; then name=$(jq -r '.name' "$pkg/package.json") version=$(jq -r '.version' "$pkg/package.json") diff --git a/.github/workflows/python-sdk-wheels.yml b/.github/workflows/python-sdk-wheels.yml deleted file mode 100644 index d01d4008c0..0000000000 --- a/.github/workflows/python-sdk-wheels.yml +++ /dev/null @@ -1,118 +0,0 @@ -name: Python SDK Wheels - -on: - workflow_dispatch: - inputs: - publish: - description: "Publish wheels to PyPI after building" - required: true - default: false - type: boolean - -permissions: - contents: read - id-token: write - -jobs: - build-wheels: - name: Build wheel (${{ matrix.name }}) - runs-on: ${{ matrix.os }} - container: ${{ matrix.container || null }} - strategy: - fail-fast: false - matrix: - include: - - name: macos-arm64 - os: macos-14 - - name: macos-x86_64 - os: macos-13 - - name: linux-x86_64 - os: ubuntu-latest - container: quay.io/pypa/manylinux_2_28_x86_64@sha256:441c35fdc6ee809ff9260894f8468ab4fea8c15dc880f8700a3f81b7922c1cda - - name: windows-x86_64 - os: windows-latest - - steps: - - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Set up Rust - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 - with: - python-version: "3.12" - - - name: Install uv - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - with: - enable-cache: true - - - name: Install Linux tools - if: runner.os == 'Linux' - shell: bash - run: | - python3 -m pip install --upgrade pip - python3 -m pip install uv - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin - - - name: Install just - if: runner.os != 'Linux' - uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 - - - name: Cache Cargo artifacts - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - - - name: Build wheel - shell: bash - run: just --justfile crates/goose-sdk/justfile python-wheel - - - name: Repair Linux wheel - if: runner.os == 'Linux' - shell: bash - run: | - python3 -m pip install auditwheel - mkdir -p crates/goose-sdk/python/wheelhouse - auditwheel repair --plat manylinux_2_28_x86_64 -w crates/goose-sdk/python/wheelhouse crates/goose-sdk/python/dist/*.whl - rm crates/goose-sdk/python/dist/*.whl - mv crates/goose-sdk/python/wheelhouse/*.whl crates/goose-sdk/python/dist/ - - - name: Smoke test wheel - shell: bash - run: | - python -m venv .venv-wheel-test - if [ "${{ runner.os }}" = "Windows" ]; then - python_bin=".venv-wheel-test/Scripts/python.exe" - else - python_bin=".venv-wheel-test/bin/python" - fi - UV_NO_CONFIG=1 uv pip install --default-index https://pypi.org/simple --python "$python_bin" crates/goose-sdk/python/dist/*.whl - "$python_bin" -c 'import goose; print(goose.__name__)' - - - name: Upload wheel artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: goose-sdk-wheel-${{ matrix.name }} - path: crates/goose-sdk/python/dist/*.whl - if-no-files-found: error - - publish: - name: Publish wheels to PyPI - needs: build-wheels - runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' && inputs.publish - environment: pypi - steps: - - name: Download wheel artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - pattern: goose-sdk-wheel-* - path: dist - merge-multiple: true - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 - with: - packages-dir: dist diff --git a/.github/workflows/quarantine.yml b/.github/workflows/quarantine.yml index 9f99702d14..940d17b903 100644 --- a/.github/workflows/quarantine.yml +++ b/.github/workflows/quarantine.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check PR Author run: | # Get PR author diff --git a/.github/workflows/rebuild-skills-marketplace.yml b/.github/workflows/rebuild-skills-marketplace.yml index 5aee55c5c0..db0ad4c27c 100644 --- a/.github/workflows/rebuild-skills-marketplace.yml +++ b/.github/workflows/rebuild-skills-marketplace.yml @@ -45,15 +45,15 @@ jobs: fi - name: Checkout the branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: 20 - name: Cache Node.js modules (documentation) - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: ./documentation/node_modules key: ${{ runner.os }}-documentation-${{ hashFiles('./documentation/package-lock.json') }} @@ -78,10 +78,10 @@ jobs: uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Upload GitHub Pages artifact - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 with: path: documentation/build - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.github/workflows/recipe-security-scanner.yml b/.github/workflows/recipe-security-scanner.yml index edcb4dfb7e..ab5ccecca9 100644 --- a/.github/workflows/recipe-security-scanner.yml +++ b/.github/workflows/recipe-security-scanner.yml @@ -30,14 +30,14 @@ jobs: # We checkout the base branch (trusted) for building the scanner image, # and only fetch recipe files from the PR for scanning. - name: Checkout base branch (trusted code) - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.base.sha }} fetch-depth: 0 path: trusted - name: Fetch PR recipe files only - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 @@ -119,7 +119,7 @@ jobs: - name: Set up Docker Buildx if: steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true' - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Prune Docker caches if: steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true' @@ -243,7 +243,7 @@ jobs: - name: Upload scan artifacts if: always() && steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: security-scan path: ${{ runner.temp }}/security-scan/** diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e4c140bd3..ab4f88116e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,8 @@ -# This workflow builds release artifacts for release branches and publishes tagged releases. +# This workflow is main release, needs to be manually tagged & pushed. on: push: paths-ignore: - "documentation/**" - branches: - - "release/*" tags: - "v1.*" @@ -17,11 +15,6 @@ permissions: pull-requests: write # Required for npm publish workflow attestations: write # Required for SLSA build provenance attestations -env: - # Set this repository Actions variable to "true" in GitHub Settings > Secrets and variables - # > Actions > Variables after a release containing desktop app-update.yml has shipped. - ENABLE_MAC_NATIVE_AUTO_UPDATE: ${{ vars.ENABLE_MAC_NATIVE_AUTO_UPDATE || 'false' }} - concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -41,8 +34,8 @@ jobs: runs-on: ubuntu-latest needs: [build-cli] steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: download_cli.sh path: download_cli.sh @@ -56,8 +49,8 @@ jobs: id-token: write contents: read with: - signing: ${{ startsWith(github.ref, 'refs/tags/') }} - environment: ${{ startsWith(github.ref, 'refs/tags/') && 'signing' || '' }} + signing: true + environment: signing secrets: inherit # ------------------------------------------------------------ @@ -69,8 +62,8 @@ jobs: id-token: write contents: read with: - signing: ${{ startsWith(github.ref, 'refs/tags/') }} - environment: ${{ startsWith(github.ref, 'refs/tags/') && 'signing' || '' }} + signing: true + environment: signing secrets: inherit # ------------------------------------------------------------ @@ -89,7 +82,7 @@ jobs: contents: read actions: read with: - signing: ${{ startsWith(github.ref, 'refs/tags/') }} + signing: true secrets: inherit bundle-desktop-windows-cuda: @@ -99,7 +92,7 @@ jobs: contents: read actions: read with: - signing: ${{ startsWith(github.ref, 'refs/tags/') }} + signing: true windows_variant: cuda secrets: inherit @@ -108,7 +101,6 @@ jobs: # ------------------------------------ release: name: Release - if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest needs: [build-cli, install-script, bundle-desktop, bundle-desktop-intel, bundle-desktop-linux, bundle-desktop-windows, bundle-desktop-windows-cuda] permissions: @@ -116,25 +108,13 @@ jobs: id-token: write # Required for Sigstore OIDC signing attestations: write # Required for SLSA build provenance attestations steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Download all artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: merge-multiple: true - - name: Generate macOS update manifest - if: ${{ env.ENABLE_MAC_NATIVE_AUTO_UPDATE == 'true' }} - run: node ui/desktop/scripts/generate-mac-update-manifest.js --version "${GITHUB_REF_NAME}" --directory . - - - name: Attest macOS update manifest - if: ${{ env.ENABLE_MAC_NATIVE_AUTO_UPDATE == 'true' }} - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 - with: - subject-path: latest-mac.yml - - name: Attest build provenance - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-path: | goose-*.tar.bz2 @@ -183,11 +163,3 @@ jobs: allowUpdates: true omitBody: true omitPrereleaseDuringUpdate: true - - - name: Upload macOS update manifest - if: ${{ env.ENABLE_MAC_NATIVE_AUTO_UPDATE == 'true' }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release upload "${GITHUB_REF_NAME}" latest-mac.yml --clobber - gh release upload stable latest-mac.yml --clobber diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 7e2c50683f..56eb8cec8a 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -34,12 +34,12 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 with: results_file: results.sarif results_format: sarif @@ -64,7 +64,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: SARIF file path: results.sarif diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 35b79315df..dbe008a54e 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -29,7 +29,7 @@ jobs: steps: # Use the official stale action from GitHub - name: 'Close Stale PRs' - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 with: # Authentication token with required permissions repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-finder.yml b/.github/workflows/test-finder.yml index c00476d77d..0116e76d9b 100644 --- a/.github/workflows/test-finder.yml +++ b/.github/workflows/test-finder.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 diff --git a/.github/workflows/update-hacktoberfest-leaderboard.yml b/.github/workflows/update-hacktoberfest-leaderboard.yml index 8c9989a79c..f452b8891e 100644 --- a/.github/workflows/update-hacktoberfest-leaderboard.yml +++ b/.github/workflows/update-hacktoberfest-leaderboard.yml @@ -16,7 +16,7 @@ jobs: issues: write steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Update Leaderboard uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/.github/workflows/update-health-dashboard.yml b/.github/workflows/update-health-dashboard.yml index 36f06dbe10..5dfd73d350 100644 --- a/.github/workflows/update-health-dashboard.yml +++ b/.github/workflows/update-health-dashboard.yml @@ -223,7 +223,7 @@ jobs: console.log(`Successfully updated discussion #${discussionNumber}`); - name: 'Upload metrics artifact' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: health-metrics path: health-metrics.json diff --git a/.github/workflows/update-release-pr.yaml b/.github/workflows/update-release-pr.yaml index 0c5e54a877..987243f704 100644 --- a/.github/workflows/update-release-pr.yaml +++ b/.github/workflows/update-release-pr.yaml @@ -17,12 +17,12 @@ jobs: runs-on: ubuntu-latest if: startsWith(github.event.pull_request.head.ref, 'release/') && github.event.pull_request.user.login == 'github-actions[bot]' steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 + - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 - name: Extract version from branch name env: diff --git a/.gitignore b/.gitignore index fb5d128862..b82cf5c753 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ tokenizer_files/ .DS_Store .idea .vscode -.zed/ *.log tmp/ diff --git a/AGENTS.md b/AGENTS.md index 0f01fcde55..286ecce432 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ cargo build ```bash cargo build # debug cargo build --release # release -just release-binary # release binary +just release-binary # release + openapi ``` ### Test @@ -33,21 +33,28 @@ cargo clippy --all-targets -- -D warnings ### UI ```bash +just generate-openapi # after server changes just run-ui # start desktop -cd ui/desktop && pnpm run typecheck cd ui/desktop && pnpm test # test UI ``` +### Git +```bash +git commit -s # required for DCO sign-off +``` + ## Structure ``` crates/ โ”œโ”€โ”€ goose # core logic โ”œโ”€โ”€ goose-acp-macros # ACP proc macros โ”œโ”€โ”€ goose-cli # CLI entry +โ”œโ”€โ”€ goose-server # backend (binary: goosed) โ”œโ”€โ”€ goose-mcp # MCP extensions โ”œโ”€โ”€ goose-test # test utilities โ””โ”€โ”€ goose-test-support # test helpers +evals/open-model-gym/ # benchmarking / evals ui/desktop/ # Electron app ``` @@ -63,6 +70,7 @@ ui/desktop/ # Electron app # 1. cargo build # 2. cargo test -p # 3. cargo clippy --all-targets -- -D warnings +# 4. [if server] just generate-openapi ``` ## Rules @@ -72,7 +80,7 @@ ui/desktop/ # Electron app - Error: Use anyhow::Result - Provider: Implement Provider trait see providers/base.rs - MCP: Extensions in crates/goose-mcp/ -- UI Desktop: Use ACP SDK types or local `src/types/*` types. Do not import generated OpenAPI types/client code from `ui/desktop/src/api` +- Server: Changes need just generate-openapi ## Code Quality @@ -104,7 +112,7 @@ remaining space for dynamic text. ## Never -- Never: Recreate `ui/desktop/src/api` or add `@hey-api/openapi-ts` to `ui/desktop` +- Never: Edit ui/desktop/openapi.json manually - Cargo.toml: For human-authored dependency changes, use `cargo add` instead of manually editing dependency entries unless there is a specific reason not to. - Cargo.toml: Automated dependency bump PRs are exempt; when manual edits are necessary, keep `Cargo.lock` consistent. - Never: Skip cargo fmt @@ -113,5 +121,6 @@ remaining space for dynamic text. ## Entry Points - CLI: crates/goose-cli/src/main.rs +- Server: crates/goose-server/src/main.rs - UI: ui/desktop/src/main.ts - Agent: crates/goose/src/agents/agent.rs diff --git a/BUILDING_LINUX.md b/BUILDING_LINUX.md index 0b242a359f..4e1b9c4114 100644 --- a/BUILDING_LINUX.md +++ b/BUILDING_LINUX.md @@ -59,7 +59,13 @@ cd goose Build Goose CLI: ```bash -cargo build --release -p goose-cli --bin goose +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 @@ -74,9 +80,9 @@ cargo test -p cd ui/desktop pnpm install -# Copy the goose binary to the expected location +# Copy the server binary to the expected location mkdir -p src/bin -cp ../../target/release/goose src/bin/ +cp ../../target/release/goosed src/bin/ ``` ### 4. Build the Application @@ -137,10 +143,10 @@ cd /path/to/goose/ui/desktop/out/goose-linux-x64 ./goose 2>&1 | grep -v "GLib-GObject" | grep -v "browser_main_loop" ``` -#### Goose Binary Not Found -If you see "Goose binary not found", ensure you've: -1. Built the Rust binary: `cargo build --release -p goose-cli --bin goose` -2. Copied it to the right location: `cp ../../target/release/goose src/bin/` +#### Server Binary Not Found +If you see "Could not find goosed binary", ensure you've: +1. Built the Rust backend: `cargo build --release -p goose-server` +2. Copied it to the right location: `cp ../../target/release/goosed src/bin/` 3. Rebuilt the application: `pnpm run make` ### Distribution-Specific Notes @@ -171,7 +177,7 @@ Building as Snap packages is not currently supported but may be added in the fut For active development: -1. **Backend changes**: Rebuild with `cargo build --release -p goose-cli --bin goose` and copy the binary +1. **Backend changes**: Rebuild with `cargo build --release -p goose-server` and copy the binary 2. **Frontend changes**: Use `pnpm run start` for hot reload during development 3. **Full rebuild**: Run the complete build process above diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee36c03dc3..e916759515 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,17 +29,16 @@ It just means the change was too large for a first contribution. Start with some ### Issues -If you spot a bug, please open an issue. This shows the community and the maintainers the direction of your -thinking. +If you spot a bug or have a concrete proposal for a feature, please open an issue. This shows the community and +the maintainers the direction of your thinking. For bugs, describe how to reproduce the problem as clearly as possible. If the issue involves an interaction with an LLM, include a diagnostics report if possible. ### Discussions -Before opening a feature request or beginning implementation, please start with a discussion in the -[goose-eng Discord channel](https://discord.com/channels/1287729918100246654/1514412780504088677). Discussions -are a good place to explore design questions, alternatives, and whether something fits the goals of the project. +If you have an idea but are not yet sure how it should work, open a discussion instead. Discussions are a good +place to explore design questions, alternatives, and whether something fits the goals of the project. If a change is large or touches multiple parts of the codebase, please start with a discussion before opening a PR. This helps us align on direction before you spend time implementing something. @@ -174,38 +173,41 @@ The app opens a window and displays first-time setup. After completing setup, go Make GUI changes in `ui/desktop`. -#### Troubleshooting: blank screen on `just run-ui` +### Regenerating the OpenAPI schema -If the app opens to a blank window (logs show `Cannot read properties of null (reading 'useRef')`), your `node_modules` is out of date and is loading two copies of React. Delete it and reinstall: +The file `ui/desktop/openapi.json` is automatically generated during the build. +It is written by the `generate_schema` binary in `crates/goose-server`. +To update the spec without starting the UI, run: ``` -rm -rf ui/desktop/node_modules -cd ui && pnpm install +just generate-openapi ``` -See #8757. +This command regenerates `ui/desktop/openapi.json` and then runs the UI's +`generate-api` script to rebuild the TypeScript client from that spec. + +API changes should be made in the Rust source under `crates/goose-server/src/`. ### Debugging -To debug the external ACP backend, run it from an IDE. The configuration will depend on the IDE. The command to run is: +To debug the Goose server, run it from an IDE. The configuration will depend on the IDE. The command to run is: ``` export GOOSE_SERVER__SECRET_KEY=test -cargo run --package goose-cli --bin goose -- serve --platform desktop --host 127.0.0.1 --port 3000 +cargo run --package goose-server --bin goosed -- agent # or: `just run-server` ``` -The `debug-ui` recipe connects to `http://127.0.0.1:3000` by default. If the -backend uses another port, set `GOOSE_PORT` when starting the UI, or set -`GOOSE_EXTERNAL_BACKEND_URL` to the backend's HTTP base URL. +The server listens on port `3000` by default; this can be changed by setting the +`GOOSE_PORT` environment variable. -Once the backend is running, start a UI and connect it to the backend by running: +Once the server is running, start a UI and connect it to the server by running: ``` just debug-ui ``` -The UI connects to the backend started in the IDE, allowing breakpoints -and stepping through the backend code while interacting with the UI. +The UI connects to the server started in the IDE, allowing breakpoints +and stepping through the server code while interacting with the UI. ## Creating a fork @@ -348,6 +350,16 @@ This project follows the [Conventional Commits](https://www.conventionalcommits. [hermit]: https://cashapp.github.io/hermit/ [just]: https://github.com/casey/just?tab=readme-ov-file#installation +## Developer Certificate of Origin + +This project requires a [Developer Certificate of Origin](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin) sign-offs on all commits. This is a statement indicating that you are allowed to make the contribution and that the project has the right to distribute it under its license. When you are ready to commit, use the `--signoff` or `-s` flag to attach the sign-off to your commit. + +``` +git commit --signoff ... +# OR +git commit -s ... +``` + ## Other Ways to Contribute There are numerous ways to be an open source contributor and contribute to goose. We're here to help you on your way! Here are some suggestions to get started. If you have any questions or need help, feel free to reach out to us on [Discord](https://discord.gg/goose-oss). diff --git a/CUSTOM_DISTROS.md b/CUSTOM_DISTROS.md index 74ace5fc5b..ad59da656e 100644 --- a/CUSTOM_DISTROS.md +++ b/CUSTOM_DISTROS.md @@ -26,8 +26,8 @@ goose's architecture is designed for extensibility. Organizations can create "re โ”‚ โ”‚ โ”‚ โ–ผ โ–ผ โ–ผ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ goose serve (ACP) โ”‚ -โ”‚ ACP HTTP/WebSocket server for custom clients โ”‚ +โ”‚ goose-server (goosed) โ”‚ +โ”‚ REST API for all goose functionality โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ–ผ @@ -49,7 +49,7 @@ goose's architecture is designed for extensibility. Organizations can create "re | Bundle custom MCP extensions | `config.yaml` extensions section, `ui/desktop/src/built-in-extensions.json`, `ui/desktop/src/components/settings/extensions/bundled-extensions.json` | Medium | | Modify system prompts | `crates/goose/src/prompts/` | Low | | Customize desktop branding | `ui/desktop/` (icons, names, colors) | Medium | -| Build a new UI (web, mobile) | Integrate with `goose serve` over ACP | High | +| Build a new UI (web, mobile) | Integrate with `goose-server` REST API | High | | Create guided workflows | Recipes (YAML-based task definitions) | Low | | Build complex multi-step workflows | Recipes with sub-recipes and subagents | Medium | @@ -304,32 +304,40 @@ export GOOSE_BUNDLE_NAME="InsightStream-goose" **Goal**: Create an entirely new frontend while leveraging goose's backend. -goose provides two ACP transport options for building custom clients: +goose provides two integration options for building custom UIs: -### Option 1: ACP HTTP/WebSocket (`goose serve`) +### Option 1: REST API (goose-server) -Use `goose serve` for process-separated integrations such as web apps, desktop shells, and other clients: +Use goose-server for HTTP-based integrations (web apps, simple clients): ```bash # Start the server -GOOSE_SERVER__SECRET_KEY='a-long-random-secret' goose serve +./target/release/goosed -# ACP endpoint available at http://localhost:3284/acp +# API available at http://localhost:3000 ``` -HTTP clients authenticate with the `X-Secret-Key` header. Browser WebSocket clients use the same secret as a `?token=` query parameter because the browser WebSocket API cannot set custom headers: +**Reference the OpenAPI spec** at `ui/desktop/openapi.json` for available endpoints: +- Session management +- Message streaming +- Extension control +- Configuration -```text -ws://localhost:3284/acp?token=a-long-random-secret +**Key endpoints** for a minimal integration: + +``` +POST /sessions # Create a new session +POST /sessions/{id}/messages # Send a message (streaming response) +GET /sessions/{id} # Get session state +GET /extensions # List available extensions +POST /extensions/{name}/enable # Enable an extension ``` -For browser clients served from a non-loopback origin, pass the exact UI origin with `--allowed-origin`. When you pass `--allowed-origin`, it replaces the default loopback origin allowlist, so include every origin the client needs. +**Handle streaming responses** - goose uses Server-Sent Events (SSE) for real-time responses. -For the ACP protocol and client flow, see [Agent Client Protocol clients](documentation/docs/guides/acp-clients.md). +### Option 2: Agent Client Protocol (ACP) -### Option 2: Agent Client Protocol (ACP) over stdio - -For richer local integrations (IDEs and embedded agents), run goose as an ACP agent over stdio. +For richer integrations (IDEs, desktop apps, embedded agents), use the **Agent Client Protocol (ACP)**โ€”a standardized JSON-RPC protocol for AI agent communication over stdio or other transports. ACP provides: - **Bidirectional communication**: Agents can request permissions, stream updates, and receive cancellations @@ -402,6 +410,11 @@ For the full ACP specification, see the [Agent Client Protocol documentation](ht ### Technical Details +**REST API (goose-server)**: +- Server implementation: `crates/goose-server/src/routes/` +- OpenAPI generation: `just generate-openapi` +- API client example: `ui/desktop/src/api/` (generated TypeScript client) + **ACP**: - ACP server implementation: `crates/goose/src/acp/server.rs` - CLI integration: `crates/goose-cli/src/cli.rs` (Command::Acp) @@ -642,10 +655,10 @@ prompt: | - find_patterns: Find similar features to model after ``` -Recipes that define `sub_recipes` get the Summon extension automatically. The AI can invoke sub-recipes through Summon's `delegate` tool: +The AI can then invoke these sub-recipes using the `subagent` tool: ``` -delegate(source: "find_files", parameters: {"search_term": "authentication"}) +subagent(subrecipe: "find_files", parameters: {"search_term": "authentication"}) ``` ### Subagents: Dynamic Task Delegation @@ -665,27 +678,28 @@ prompt: | To complete this task: 1. Spawn a subagent to analyze the frontend code: - delegate(instructions: "Analyze all React components in src/components/ and list their props and state management patterns") + subagent(instructions: "Analyze all React components in src/components/ + and list their props and state management patterns") 2. Spawn another subagent for the backend: - delegate(instructions: "Document all API endpoints in src/api/ including their request/response schemas") + subagent(instructions: "Document all API endpoints in src/api/ + including their request/response schemas") 3. Synthesize findings from both subagents into a unified report. ``` #### Parallel Subagent Execution -Use `async: true` to run delegates in parallel, then collect each result with `load(source: "")`: +Multiple subagent calls in the same message execute in parallel: ```yaml prompt: | - Run these analyses in parallel: + Run these analyses in parallel by making all subagent calls at once: - delegate(instructions: "Count lines of code by language", async: true) - delegate(instructions: "Find all TODO comments", async: true) - delegate(instructions: "List external dependencies", async: true) + subagent(instructions: "Count lines of code by language") + subagent(instructions: "Find all TODO comments") + subagent(instructions: "List external dependencies") - Use load(source: "") for each returned task id. Then combine the results into a codebase health report. ``` @@ -697,18 +711,22 @@ Customize model, provider, or behavior per subagent: prompt: | Use a faster model for simple tasks: - delegate( + subagent( instructions: "List all files modified in the last week", - model: "gpt-4o-mini", - max_turns: 3 + settings: { + model: "gpt-4o-mini", + max_turns: 3 + } ) Use the full model for complex analysis: - delegate( + subagent( instructions: "Review this code for security vulnerabilities", - model: "claude-sonnet-4-20250514", - temperature: 0.1 + settings: { + model: "claude-sonnet-4-20250514", + temperature: 0.1 + } ) ``` @@ -720,7 +738,7 @@ Limit which extensions a subagent can access: prompt: | Create a sandboxed subagent with only file reading capabilities: - delegate( + subagent( instructions: "Analyze the README files in this project", extensions: ["developer"] # Only developer extension, no network access ) @@ -801,7 +819,7 @@ prompt: | 2. **Parallelize independent tasks** - Multiple subagent calls in one message run concurrently 3. **Use `sequential_when_repeated: true`** - For tasks that shouldn't run in parallel (e.g., database migrations) 4. **Scope extensions appropriately** - Give subagents only the tools they need -5. **Use background delegation for independent work** - Pass `async: true` to `delegate`, then collect results with `load(source: "")` +5. **Use summary mode (default)** - Subagents return concise summaries; use `summary: false` only when you need full conversation history 6. **Handle failures gracefully** - Design workflows to continue even if one subagent fails ### Technical Details diff --git a/Cargo.lock b/Cargo.lock index befb0adfcc..a962076cdd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,65 +33,50 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cipher", "cpufeatures 0.2.17", ] [[package]] name = "agent-client-protocol" -version = "1.0.1" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16302d16c7531355db16593d99c38c8297db0c4653aa7dd80c3556bb17f4cd8c" +checksum = "2af62fb84df2af0f933d8f5fd78b843fa5eb0ec5a48fa1b528c41951d0bbe36c" dependencies = [ "agent-client-protocol-derive", "agent-client-protocol-schema", - "async-process", - "blocking", + "anyhow", "futures", "futures-concurrency", - "rustc-hash 2.1.3", + "jsonrpcmsg", + "rmcp", + "rustc-hash 2.1.2", "schemars 1.2.1", "serde", "serde_json", - "shell-words", + "thiserror 2.0.18", + "tokio", + "tokio-util", "tracing", "uuid", - "windows-sys 0.61.2", ] [[package]] name = "agent-client-protocol-derive" -version = "1.2.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5ca63f112bd2459bcaf9eda0683b9ba95fc3b5e5fdd9036ca941c6a09345b1" +checksum = "cabdc9d845d08ec7ed2d0c9de1ae4a1b198301407d55855261572761be90ec9f" dependencies = [ "quote", - "syn 2.0.118", -] - -[[package]] -name = "agent-client-protocol-http" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31ff55efe7a6bb92a5d0226f6055c2f277b7591060d5095c7764f445289ebda3" -dependencies = [ - "agent-client-protocol", - "async-stream", - "axum", - "futures", - "serde_json", - "tokio", - "tower-http 0.7.0", - "tracing", - "uuid", + "syn 2.0.117", ] [[package]] name = "agent-client-protocol-schema" -version = "1.1.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac542aba230234b1591ace7286a47c0514fe3efc3037d43296bde31ba7ee5728" +checksum = "49bae57dad1c28a362fbdcf7bab0583316a02b45a70792109fced55780a3b63c" dependencies = [ "anyhow", "derive_more", @@ -109,7 +94,7 @@ version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "getrandom 0.3.4", "once_cell", "serde", @@ -134,9 +119,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.4" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" dependencies = [ "alloc-no-stdlib", ] @@ -217,28 +202,28 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" - -[[package]] -name = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "ar_archive_writer" -version = "0.5.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" dependencies = [ "object", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arboard" version = "3.6.1" @@ -258,9 +243,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.2" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ "rustversion", ] @@ -273,9 +258,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.8" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "ascii" @@ -283,59 +268,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" -[[package]] -name = "askama" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc" -dependencies = [ - "askama_macros", - "itoa", - "percent-encoding", - "serde", - "serde_json", -] - -[[package]] -name = "askama_derive" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738" -dependencies = [ - "askama_parser", - "basic-toml", - "glob", - "memchr", - "proc-macro2", - "quote", - "rustc-hash 2.1.3", - "serde", - "serde_derive", - "syn 2.0.118", -] - -[[package]] -name = "askama_macros" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a" -dependencies = [ - "askama_derive", -] - -[[package]] -name = "askama_parser" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" -dependencies = [ - "rustc-hash 2.1.3", - "serde", - "serde_derive", - "unicode-ident", - "winnow 1.0.3", -] - [[package]] name = "asn1-rs" version = "0.7.2" @@ -360,7 +292,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", "synstructure", ] @@ -372,7 +304,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -393,19 +325,7 @@ checksum = "2eb025ef00a6da925cf40870b9c8d008526b6004ece399cb0974209720f0b194" dependencies = [ "quote", "swc_macros_common", - "syn 2.0.118", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", + "syn 2.0.117", ] [[package]] @@ -420,77 +340,12 @@ dependencies = [ "tokio", ] -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if 1.0.4", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix 1.1.4", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - [[package]] name = "async-once-cell" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if 1.0.4", - "event-listener", - "futures-lite", - "rustix 1.1.4", -] - -[[package]] -name = "async-signal" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if 1.0.4", - "futures-core", - "futures-io", - "rustix 1.1.4", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - [[package]] name = "async-stream" version = "0.3.6" @@ -510,15 +365,9 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - [[package]] name = "async-trait" version = "0.1.89" @@ -527,7 +376,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -590,9 +439,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-config" -version = "1.8.18" +version = "1.8.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e33f815b73a3899c03b380d543532e5865f230dce9678d108dc10732a8682275" +checksum = "517aa062d8bd9015ee23d6daa5e1c1372328412fdae4e6c4c1be9b69c6ad37a2" dependencies = [ "aws-credential-types", "aws-runtime", @@ -600,17 +449,17 @@ dependencies = [ "aws-sdk-ssooidc", "aws-sdk-sts", "aws-smithy-async", - "aws-smithy-http 0.63.6", + "aws-smithy-http", "aws-smithy-json", "aws-smithy-runtime", "aws-smithy-runtime-api", - "aws-smithy-schema 0.1.0", + "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", "fastrand", "hex", - "http 1.4.2", + "http 1.4.1", "sha1", "time", "tokio", @@ -633,9 +482,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.17.1" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -644,28 +493,27 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.42.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", "dunce", "fs_extra", - "pkg-config", ] [[package]] name = "aws-runtime" -version = "1.7.5" +version = "1.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c9b9de216a988dd54b754a82a7660cfe14cee4f6782ae4524470972fa0ccb39" +checksum = "77ed8e8c52d2dc2390ad9f15647fe663f71e9780b4262c190fbb823a32721566" dependencies = [ "aws-credential-types", "aws-sigv4", "aws-smithy-async", "aws-smithy-eventstream", - "aws-smithy-http 0.63.6", + "aws-smithy-http", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -673,7 +521,7 @@ dependencies = [ "bytes", "bytes-utils", "fastrand", - "http 1.4.2", + "http 1.4.1", "http-body 1.0.1", "percent-encoding", "pin-project-lite", @@ -683,19 +531,18 @@ dependencies = [ [[package]] name = "aws-sdk-bedrockruntime" -version = "1.135.0" +version = "1.131.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e74b780f2f36912bae71b4f4f8ed9a0a88832b4681a1add3caf5ca25dbc8ab2d" +checksum = "e494025b4c578bfefd025aada69c51ab1db6b7589f61cb78ae681f3115269209" dependencies = [ - "arc-swap", "aws-credential-types", "aws-runtime", "aws-sigv4", "aws-smithy-async", "aws-smithy-eventstream", - "aws-smithy-http 0.63.6", + "aws-smithy-http", "aws-smithy-json", - "aws-smithy-observability 0.2.6", + "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -703,7 +550,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.2", + "http 1.4.1", "http-body-util", "regex-lite", "tracing", @@ -711,18 +558,17 @@ dependencies = [ [[package]] name = "aws-sdk-sagemakerruntime" -version = "1.105.0" +version = "1.102.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3b9e91619a2ffd88b195f71a0054f0a8d247ba4550306628796ba976d4611c" +checksum = "fb969c5c411b21a4bca5b52e9f5492af20e927fd4c410f748a3996462eb295c5" dependencies = [ - "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", "aws-smithy-eventstream", - "aws-smithy-http 0.63.6", + "aws-smithy-http", "aws-smithy-json", - "aws-smithy-observability 0.2.6", + "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -730,24 +576,23 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.2", + "http 1.4.1", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-sso" -version = "1.102.0" +version = "1.99.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c82b3ac19f1431854f7ace3a7531674633e286bfdde21976893bfee36fd493b" +checksum = "9f4055e6099b2ec264abdc0d9bbfffce306c1601809275c861594779a0b04b45" dependencies = [ - "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http 0.63.6", + "aws-smithy-http", "aws-smithy-json", - "aws-smithy-observability 0.2.6", + "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -755,24 +600,23 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.2", + "http 1.4.1", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-ssooidc" -version = "1.104.0" +version = "1.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "321000d2b4c5519ee573f73167f612efd7329322d9b26969ad1979f0427f1913" +checksum = "02f009ba0284c5d696425fd7b4dcc5b189f5726f4041b7a5794daecb3a68d598" dependencies = [ - "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http 0.63.6", + "aws-smithy-http", "aws-smithy-json", - "aws-smithy-observability 0.2.6", + "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -780,24 +624,23 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.2", + "http 1.4.1", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-sts" -version = "1.107.0" +version = "1.104.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0d328ba962af23ecfa3c9f23b98d3d35e325fa218d7f13d17a6bf522f8a560" +checksum = "6aa6622798e19e6a76b690562085dd4771c736cd48343464a53ab4ae2f2c9f84" dependencies = [ - "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http 0.63.6", + "aws-smithy-http", "aws-smithy-json", - "aws-smithy-observability 0.2.6", + "aws-smithy-observability", "aws-smithy-query", "aws-smithy-runtime", "aws-smithy-runtime-api", @@ -806,20 +649,20 @@ dependencies = [ "aws-types", "fastrand", "http 0.2.12", - "http 1.4.2", + "http 1.4.1", "regex-lite", "tracing", ] [[package]] name = "aws-sigv4" -version = "1.4.5" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae38512beae0ffee7010fc24e7a8a123c53efdfef42a61e80fda4882418dc71" +checksum = "b7083fb918b38474ac65ffbf8a69fc8792d36879f4ac5f1667b43aec61efe9a5" dependencies = [ "aws-credential-types", "aws-smithy-eventstream", - "aws-smithy-http 0.63.6", + "aws-smithy-http", "aws-smithy-runtime-api", "aws-smithy-types", "bytes", @@ -827,7 +670,7 @@ dependencies = [ "hex", "hmac 0.13.0", "http 0.2.12", - "http 1.4.2", + "http 1.4.1", "percent-encoding", "sha2 0.11.0", "time", @@ -836,9 +679,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.3.0" +version = "1.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" dependencies = [ "futures-util", "pin-project-lite", @@ -847,9 +690,9 @@ dependencies = [ [[package]] name = "aws-smithy-eventstream" -version = "0.60.21" +version = "0.60.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78d8391e65fcea47c586a22e1a41f173b38615b112b2c6b7a44e80cec3e6b706" +checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548" dependencies = [ "aws-smithy-types", "bytes", @@ -869,28 +712,7 @@ dependencies = [ "bytes-utils", "futures-core", "futures-util", - "http 1.4.2", - "http-body 1.0.1", - "http-body-util", - "percent-encoding", - "pin-project-lite", - "pin-utils", - "tracing", -] - -[[package]] -name = "aws-smithy-http" -version = "0.64.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" -dependencies = [ - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "bytes-utils", - "futures-core", - "futures-util", - "http 1.4.2", + "http 1.4.1", "http-body 1.0.1", "http-body-util", "percent-encoding", @@ -901,12 +723,12 @@ dependencies = [ [[package]] name = "aws-smithy-json" -version = "0.62.7" +version = "0.62.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa" +checksum = "517089205f18ab4adc5a3e02888cb139bbbbb2e168eac9f396216925d1fbeaf5" dependencies = [ "aws-smithy-runtime-api", - "aws-smithy-schema 0.1.0", + "aws-smithy-schema", "aws-smithy-types", ] @@ -919,15 +741,6 @@ dependencies = [ "aws-smithy-runtime-api", ] -[[package]] -name = "aws-smithy-observability" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" -dependencies = [ - "aws-smithy-runtime-api", -] - [[package]] name = "aws-smithy-query" version = "0.60.15" @@ -940,20 +753,20 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.12.0" +version = "1.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea94a9ff8464016338c851e24b472d7131c388c88898a502e781815b2ee6045" +checksum = "b8e6f5caf6fea86f8c2206541ab5857cfcda9013426cdbe8fa0098b9e2d32182" dependencies = [ "aws-smithy-async", - "aws-smithy-http 0.64.0", - "aws-smithy-observability 0.3.0", + "aws-smithy-http", + "aws-smithy-observability", "aws-smithy-runtime-api", - "aws-smithy-schema 0.2.0", + "aws-smithy-schema", "aws-smithy-types", "bytes", "fastrand", "http 0.2.12", - "http 1.4.2", + "http 1.4.1", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -965,16 +778,16 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.13.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ed1ebe6e0a95ea84570225f5a8208dec4b8f77e61a9b0d6f51773fcb4612f0" +checksum = "dc117c179ecf39a62a0a3f49f600e9ac26a7ad7dd172177999f83933af776c32" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api-macros", "aws-smithy-types", "bytes", "http 0.2.12", - "http 1.4.2", + "http 1.4.1", "pin-project-lite", "tokio", "tracing", @@ -983,13 +796,13 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api-macros" -version = "1.1.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1000,32 +813,21 @@ checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", - "http 1.4.2", -] - -[[package]] -name = "aws-smithy-schema" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" -dependencies = [ - "aws-smithy-runtime-api", - "aws-smithy-types", - "http 1.4.2", + "http 1.4.1", ] [[package]] name = "aws-smithy-types" -version = "1.6.1" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8" +checksum = "056b66dbce2f81cc0c1e2b05bb402eb58f8a3530479d650efadd5bbae9a4050b" dependencies = [ "base64-simd", "bytes", "bytes-utils", "futures-core", "http 0.2.12", - "http 1.4.2", + "http 1.4.1", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -1058,7 +860,7 @@ dependencies = [ "aws-credential-types", "aws-smithy-async", "aws-smithy-runtime-api", - "aws-smithy-schema 0.1.0", + "aws-smithy-schema", "aws-smithy-types", "rustc_version", "tracing", @@ -1076,7 +878,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.2", + "http 1.4.1", "http-body 1.0.1", "http-body-util", "hyper", @@ -1098,7 +900,6 @@ dependencies = [ "tower", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -1109,7 +910,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.2", + "http 1.4.1", "http-body 1.0.1", "http-body-util", "mime", @@ -1117,7 +918,6 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -1128,7 +928,7 @@ checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1141,7 +941,7 @@ dependencies = [ "bytes", "either", "fs-err", - "http 1.4.2", + "http 1.4.1", "http-body 1.0.1", "hyper", "hyper-util", @@ -1169,7 +969,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", - "cfg-if 1.0.4", + "cfg-if", "libc", "miniz_oxide", "object", @@ -1223,15 +1023,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "basic-toml" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" -dependencies = [ - "serde", -] - [[package]] name = "bat" version = "0.26.1" @@ -1263,7 +1054,7 @@ dependencies = [ "serde_derive", "serde_with", "serde_yaml", - "syn 2.0.118", + "syn 2.0.117", "syntect", "terminal-colorsaurus", "thiserror 2.0.18", @@ -1303,7 +1094,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -1312,9 +1103,9 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash 2.1.3", - "shlex 1.3.0", - "syn 2.0.118", + "rustc-hash 2.1.2", + "shlex", + "syn 2.0.117", ] [[package]] @@ -1343,7 +1134,7 @@ dependencies = [ "biome_json_parser", "biome_json_syntax", "biome_rowan", - "bitflags 2.13.0", + "bitflags 2.11.1", "indexmap 1.9.3", "serde", "serde_json", @@ -1363,7 +1154,7 @@ dependencies = [ "biome_rowan", "biome_text_edit", "biome_text_size", - "bitflags 2.13.0", + "bitflags 2.11.1", "bpaf", "serde", "termcolor", @@ -1402,7 +1193,7 @@ dependencies = [ "biome_deserialize", "biome_diagnostics", "biome_rowan", - "cfg-if 1.0.4", + "cfg-if", "countme", "drop_bomb", "indexmap 1.9.3", @@ -1437,7 +1228,7 @@ dependencies = [ "biome_json_syntax", "biome_rowan", "biome_text_size", - "cfg-if 1.0.4", + "cfg-if", "smallvec", "tracing", "unicode-width 0.1.14", @@ -1456,8 +1247,8 @@ dependencies = [ "biome_js_unicode_table", "biome_parser", "biome_rowan", - "bitflags 2.13.0", - "cfg-if 1.0.4", + "bitflags 2.11.1", + "cfg-if", "drop_bomb", "indexmap 1.9.3", "rustc-hash 1.1.0", @@ -1541,7 +1332,7 @@ dependencies = [ "biome_console", "biome_diagnostics", "biome_rowan", - "bitflags 2.13.0", + "bitflags 2.11.1", "drop_bomb", ] @@ -1621,41 +1412,20 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" -[[package]] -name = "bitcoin-consensus-encoding" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2d6094e2a1ba3c93b5a596fe5a10d1a10c3c6e06785cde89f693a044c01aa40" -dependencies = [ - "bitcoin-internals", -] - -[[package]] -name = "bitcoin-internals" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a30a22d1f112dde8e16be7b45c63645dc165cef254f835b3e1e9553e485cfa64" -dependencies = [ - "hex-conservative 0.3.2", -] - [[package]] name = "bitcoin-io" -version = "0.1.101" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" -dependencies = [ - "bitcoin-consensus-encoding", -] +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" [[package]] name = "bitcoin_hashes" -version = "0.14.101" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ "bitcoin-io", - "hex-conservative 0.2.2", + "hex-conservative", "serde", ] @@ -1667,18 +1437,18 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ "serde_core", ] [[package]] name = "bitvec" -version = "1.1.1" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" dependencies = [ "funty", "radium", @@ -1695,7 +1465,7 @@ dependencies = [ "arrayref", "arrayvec", "cc", - "cfg-if 1.0.4", + "cfg-if", "constant_time_eq", "cpufeatures 0.3.0", ] @@ -1717,9 +1487,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ "hybrid-array", ] @@ -1742,44 +1512,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - -[[package]] -name = "bon" -version = "3.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" -dependencies = [ - "bon-macros", - "rustversion", -] - -[[package]] -name = "bon-macros" -version = "3.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" -dependencies = [ - "darling 0.23.0", - "ident_case", - "prettyplease", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.118", -] - [[package]] name = "borrow-or-share" version = "0.2.4" @@ -1793,7 +1525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17d4f95e880cfd28c4ca5a006cf7f6af52b4bcb7b5866f573b2faa126fb7affb" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1813,14 +1545,14 @@ checksum = "2f7e98cee839b19076cb3ce1afdb62bb182e04ff5f71f70188827002fae91094" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] name = "brotli" -version = "8.0.4" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -1829,9 +1561,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.3" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -1848,13 +1580,13 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.3" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", "regex-automata", - "serde_core", + "serde", ] [[package]] @@ -1889,7 +1621,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1906,15 +1638,15 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.12.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bytes-str" -version = "0.2.8" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577d2bf5650f8554d5a372af5ac93535110a0fc75b3e702bb853369febf227c2" +checksum = "7c60b5ce37e0b883c37eb89f79a1e26fbe9c1081945d024eee93e8d91a7e18b3" dependencies = [ "bytes", "serde", @@ -1963,28 +1695,27 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.4" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" dependencies = [ "serde_core", ] [[package]] name = "candle-core" -version = "0.11.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ecb245093b0f791b89d3420c3df9c6d49c60ab63ba54db896bf8a3baf486706" +checksum = "6bd9895436c1ba5dc1037a19935d084b838db066ff4e15ef7dded020b7c12a4a" dependencies = [ "byteorder", "candle-kernels", "candle-metal-kernels", "candle-ug", - "cudarc 0.19.8", + "cudarc 0.19.7", "float8", "gemm 0.19.0", "half", - "libc", "libm", "memmap2", "num-traits", @@ -1994,30 +1725,28 @@ dependencies = [ "rand 0.9.4", "rand_distr", "rayon", - "safetensors 0.8.0", + "safetensors 0.7.0", "thiserror 2.0.18", "tokenizers 0.22.2", - "yoke 0.8.3", - "zerocopy", - "zip 8.6.0", + "yoke 0.8.2", + "zip 7.2.0", ] [[package]] name = "candle-kernels" -version = "0.11.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67450168a281bbb195a14cc85cf164c7a12023a54a03409a3324f476346e0789" +checksum = "742e2ac226b777134436e9e692f44e77c278b8a7abb1554dc10e44dc911b349f" dependencies = [ "cudaforge", ] [[package]] name = "candle-metal-kernels" -version = "0.11.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "242e83c6acf639bb273c929d73c67a882bb4dd08a140f121096e19ba2f213d3e" +checksum = "4b6b5a4cae6b4e1ab0efcee4dc05272d11b374a3d1ba121b3a961e36be54ab60" dependencies = [ - "block2", "half", "objc2", "objc2-foundation", @@ -2029,9 +1758,9 @@ dependencies = [ [[package]] name = "candle-nn" -version = "0.11.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaa10b6ccc365b33210ce404fbf45e60d3e0bdac1004463cf1052e6ee1c1739a" +checksum = "a9317a09d6530b758990ed7f625ac69ff43653bc9ee28b0464644ad1169ada87" dependencies = [ "candle-core", "candle-metal-kernels", @@ -2040,21 +1769,21 @@ dependencies = [ "num-traits", "objc2-metal", "rayon", - "safetensors 0.8.0", + "safetensors 0.7.0", "serde", "thiserror 2.0.18", ] [[package]] name = "candle-transformers" -version = "0.11.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bcbbf7ff00ff6fe2af22b93600195917fe90e90ff48424a140d1a926c44b1c1" +checksum = "f59d08c89e9f4af9c464e2f3a8e16199e7cc601e6f34538c2cfbb42b623b1783" dependencies = [ "byteorder", "candle-core", "candle-nn", - "fancy-regex 0.18.0", + "fancy-regex 0.17.0", "num-traits", "rand 0.9.4", "rayon", @@ -2066,9 +1795,9 @@ dependencies = [ [[package]] name = "candle-ug" -version = "0.11.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "257411c33abf7d898a31ac20d80813dfe96ff09e23a73039e22ab293aea9b871" +checksum = "ca0fc3167cbc99c8ec1be618cb620aa21dca95038f118c3579a79370e3dc5f77" dependencies = [ "ug", "ug-cuda", @@ -2094,31 +1823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b4a6cae9efc04cc6cbb8faf338d2c497c165c83e74509cf4dbedea948bbf6e5" dependencies = [ "quote", - "syn 2.0.118", -] - -[[package]] -name = "cargo-platform" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "cargo_metadata" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror 2.0.18", + "syn 2.0.117", ] [[package]] @@ -2141,14 +1846,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.66" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex 2.0.1", + "shlex", ] [[package]] @@ -2171,12 +1876,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "cfg-if" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" - [[package]] name = "cfg-if" version = "1.0.4" @@ -2195,18 +1894,18 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cipher", "cpufeatures 0.2.17", ] [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -2226,9 +1925,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.45" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -2294,9 +1993,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.7" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" dependencies = [ "clap", ] @@ -2320,7 +2019,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -2331,9 +2030,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clap_mangen" -version = "0.3.0" +version = "0.2.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d82842b45bf9f6a3be090dd860095ac30728042c08e0d6261ca7259b5d850f07" +checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" dependencies = [ "clap", "roff", @@ -2341,9 +2040,9 @@ dependencies = [ [[package]] name = "cliclack" -version = "0.5.5" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fd2a475e9dff35ddebd459b0523aab732572581f7fe53133edd20b3330a0ce6" +checksum = "4529f45438fc25ca048b242d5c48e2d3ce9a521e2a5a9123d9737d8520b030dd" dependencies = [ "console", "indicatif", @@ -2368,7 +2067,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d9334f725b46fb9bed8580b9b47a932587e044fadb344ed7fa98774b067ac1a" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "windows 0.56.0", ] @@ -2383,9 +2082,9 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.4" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" [[package]] name = "cmpv2" @@ -2423,15 +2122,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "colored" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "combine" version = "4.6.7" @@ -2459,7 +2149,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" dependencies = [ "castaway", - "cfg-if 1.0.4", + "cfg-if", "itoa", "ryu", "static_assertions", @@ -2467,12 +2157,12 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.9.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" dependencies = [ "castaway", - "cfg-if 1.0.4", + "cfg-if", "itoa", "rustversion", "ryu", @@ -2510,10 +2200,22 @@ dependencies = [ ] [[package]] -name = "console" -version = "0.16.4" +name = "config" +version = "0.15.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +checksum = "f316c6237b2d38be61949ecd15268a4c6ca32570079394a2444d9ce2c72a72d8" +dependencies = [ + "pathdiff", + "serde_core", + "toml 1.1.2+spec-1.1.0", + "winnow 1.0.3", +] + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", @@ -2533,21 +2235,6 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" -[[package]] -name = "const-str" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" - -[[package]] -name = "const_panic" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" -dependencies = [ - "typewit", -] - [[package]] name = "constant_time_eq" version = "0.4.2" @@ -2653,15 +2340,6 @@ dependencies = [ "libm", ] -[[package]] -name = "countio" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9702aee5d1d744c01d82f6915644f950f898e014903385464c773b96fefdecb" -dependencies = [ - "futures-io", -] - [[package]] name = "countme" version = "3.0.1" @@ -2707,7 +2385,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", ] [[package]] @@ -2735,18 +2413,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.16" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.7" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -2754,27 +2432,27 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.13" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" @@ -2814,27 +2492,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "csv" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" -dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde_core", -] - -[[package]] -name = "csv-core" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" -dependencies = [ - "memchr", -] - [[package]] name = "ctor" version = "0.2.9" @@ -2842,25 +2499,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.117", ] -[[package]] -name = "ctor" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" -dependencies = [ - "ctor-proc-macro", - "dtor 0.1.1", -] - -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" - [[package]] name = "ctutils" version = "0.4.2" @@ -2900,9 +2541,9 @@ dependencies = [ [[package]] name = "cudarc" -version = "0.19.8" +version = "0.19.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42310153e06cf4cd532901f7096beb27504d681736a29ee90728ae4e2d93b2a8" +checksum = "1cea5f10a99e025c1b44ae2354c2d8326b25ddbd0baf76bde8e55cfd4018a2cc" dependencies = [ "float8", "half", @@ -2915,7 +2556,7 @@ version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", @@ -2933,15 +2574,9 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] -[[package]] -name = "daachorse" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f55d7153ba3b507595872a3874803f07a8a81d1e888abed8e5db7da0597d6e2" - [[package]] name = "darling" version = "0.20.11" @@ -2952,16 +2587,6 @@ dependencies = [ "darling_macro 0.20.11", ] -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - [[package]] name = "darling" version = "0.23.0" @@ -2983,21 +2608,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", -] - -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -3010,7 +2621,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -3021,18 +2632,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.118", -] - -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core 0.21.3", - "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -3043,7 +2643,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -3061,7 +2661,21 @@ version = "5.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", "hashbrown 0.14.5", "lock_api", "once_cell", @@ -3082,9 +2696,9 @@ checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" [[package]] name = "dbus" -version = "0.9.12" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" dependencies = [ "libc", "libdbus-sys", @@ -3130,37 +2744,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "defmt" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" -dependencies = [ - "bitflags 1.3.2", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" -dependencies = [ - "defmt-parser", - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "defmt-parser" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" -dependencies = [ - "thiserror 2.0.18", -] - [[package]] name = "deno_ast" version = "0.52.0" @@ -3219,7 +2802,7 @@ dependencies = [ "deno_error", "deno_media_type", "deno_path_util", - "http 1.4.2", + "http 1.4.1", "indexmap 2.14.0", "log", "once_cell", @@ -3326,7 +2909,7 @@ checksum = "1c28ede88783f14cd8aae46ca89f230c226b40e4a81ab06fa52ed72af84beb2f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -3393,7 +2976,7 @@ dependencies = [ "stringcase", "strum 0.27.2", "strum_macros 0.27.2", - "syn 2.0.118", + "syn 2.0.117", "syn-match", "thiserror 2.0.18", ] @@ -3546,7 +3129,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -3555,9 +3138,21 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ + "powerfmt", "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -3576,7 +3171,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -3586,7 +3181,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -3608,7 +3203,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.118", + "syn 2.0.117", "unicode-xid", ] @@ -3636,7 +3231,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.1", + "block-buffer 0.12.0", "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", @@ -3651,7 +3246,7 @@ dependencies = [ "diplomat_core", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -3671,7 +3266,16 @@ dependencies = [ "serde", "smallvec", "strck", - "syn 2.0.118", + "syn 2.0.117", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", ] [[package]] @@ -3680,7 +3284,19 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -3691,7 +3307,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -3701,19 +3317,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "objc2", ] [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -3771,7 +3387,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33175ddb7a6d418589cab2966bd14a710b3b1139459d3d5ca9edf783c4833f4c" dependencies = [ "num-bigint", - "rustc-hash 2.1.3", + "rustc-hash 2.1.2", "swc_atoms", "swc_common", "swc_ecma_ast", @@ -3788,28 +3404,13 @@ checksum = "9bda8e21c04aca2ae33ffc2fd8c23134f3cac46db123ba97bd9d3f3b8a4a85e1" [[package]] name = "dtor" -version = "0.1.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d738e43aa64edab57c983d56de890d65fea7dc05605490c74451ce721dfd84b" +checksum = "e2137ce22f50d4c43ce098daf41c904cc700de1ce8bc2daf53ed4e702180a464" dependencies = [ "linktime-proc-macro", ] -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -3945,7 +3546,7 @@ version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", ] [[package]] @@ -3963,7 +3564,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -3983,7 +3584,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -4025,9 +3626,6 @@ name = "esaxx-rs" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" -dependencies = [ - "cc", -] [[package]] name = "etcetera" @@ -4035,7 +3633,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "home", "windows-sys 0.48.0", ] @@ -4046,7 +3644,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "windows-sys 0.61.2", ] @@ -4061,16 +3659,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - [[package]] name = "exr" version = "1.74.0" @@ -4114,17 +3702,6 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "fancy-regex" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - [[package]] name = "fastrand" version = "2.4.1" @@ -4168,7 +3745,7 @@ version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "libc", ] @@ -4224,9 +3801,9 @@ dependencies = [ [[package]] name = "fluent-uri" -version = "0.4.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" dependencies = [ "borrow-or-share", "ref-cast", @@ -4289,7 +3866,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -4339,14 +3916,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5ff35a391aef949120a0340d690269b3d9f63460a6106e99bd07b961f345ea9" dependencies = [ "swc_macros_common", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] name = "fs-err" -version = "3.3.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" dependencies = [ "autocfg", "tokio", @@ -4477,7 +4054,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -4509,15 +4086,6 @@ dependencies = [ "slab", ] -[[package]] -name = "gearhash" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8cf82cf76cd16485e56295a1377c775ce708c9f1a0be6b029076d60a245d213" -dependencies = [ - "cfg-if 0.1.10", -] - [[package]] name = "gemm" version = "0.18.2" @@ -4653,7 +4221,7 @@ dependencies = [ "num-traits", "once_cell", "paste", - "pulp 0.22.3", + "pulp 0.22.2", "raw-cpuid", "rayon", "seq-macro", @@ -4783,10 +4351,10 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -4796,7 +4364,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "js-sys", "libc", "r-efi 5.3.0", @@ -4806,16 +4374,16 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ - "cfg-if 1.0.4", - "js-sys", + "cfg-if", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasm-bindgen", + "wasip2", + "wasip3", ] [[package]] @@ -4844,26 +4412,6 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" -[[package]] -name = "git-version" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad568aa3db0fcbc81f2f116137f263d7304f512a1209b35b85150d3ef88ad19" -dependencies = [ - "git-version-macro", -] - -[[package]] -name = "git-version-macro" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "glob" version = "0.3.3" @@ -4895,35 +4443,22 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "goblin" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" -dependencies = [ - "log", - "plain", - "scroll", -] - [[package]] name = "goose" -version = "1.42.0" +version = "1.37.0" dependencies = [ "agent-client-protocol", - "agent-client-protocol-http", "agent-client-protocol-schema", + "ahash", "anyhow", "arboard", "async-stream", "async-trait", "aws-config", - "aws-lc-rs", "aws-sdk-bedrockruntime", "aws-sdk-sagemakerruntime", "aws-smithy-types", "axum", - "axum-server", "base64 0.22.1", "blake3", "byteorder", @@ -4933,27 +4468,26 @@ dependencies = [ "candle-transformers", "chrono", "clap", - "ctor 0.2.9", - "dirs", + "ctor", + "dashmap 6.2.1", + "dirs 5.0.1", "dotenvy", - "dtor 1.0.5", + "dtor", + "encoding_rs", "env-lock", "etcetera 0.11.0", "fs-err", "fs2", "futures", - "gethostname", "goose-acp-macros", - "goose-download-manager", "goose-mcp", - "goose-providers", - "goose-sdk-types", + "goose-sdk", "goose-test-support", - "http 1.4.2", + "http 1.4.1", + "http-body-util", "icu_calendar", "icu_locale", "ignore", - "image 0.24.9", "include_dir", "indexmap 2.14.0", "indoc", @@ -4962,7 +4496,9 @@ dependencies = [ "jsonwebtoken", "keyring", "libc", - "lru 0.18.0", + "llama-cpp-2", + "llama-cpp-sys-2", + "lru", "minijinja", "mockall", "nanoid", @@ -4970,29 +4506,29 @@ dependencies = [ "nostr-sdk", "oauth2", "once_cell", - "openssl", "opentelemetry 0.32.0", "opentelemetry-appender-tracing", "opentelemetry-otlp 0.32.0", "opentelemetry-stdout", - "opentelemetry_sdk 0.32.1", + "opentelemetry_sdk 0.32.0", "pastey", "pctx_code_mode", "pem", + "pkcs1", + "pkcs8", "process-wrap", "pulldown-cmark", - "rand 0.10.2", + "rand 0.8.6", "rayon", - "rcgen", "regex", "reqwest 0.13.4", "rmcp", "rubato", "rustls", "schemars 1.2.1", + "sec1", "serde", "serde_json", - "serde_path_to_error", "serde_urlencoded", "serde_yaml", "serial_test", @@ -5001,21 +4537,19 @@ dependencies = [ "shellexpand", "smithy-transport-reqwest", "sqlx", - "strum 0.28.0", - "subtle", + "strum 0.27.2", "symphonia", "sys-info", "tempfile", "test-case", - "thiserror 2.0.18", + "thiserror 1.0.69", "tiktoken-rs", - "tokenizers 0.23.1", + "tokenizers 0.21.4", "tokio", "tokio-cron-scheduler", "tokio-stream", "tokio-util", - "tower", - "tower-http 0.7.0", + "tower-http", "tracing", "tracing-appender", "tracing-futures", @@ -5038,28 +4572,28 @@ dependencies = [ "uuid", "v_htmlescape", "webbrowser", - "which 8.0.4", + "which 8.0.2", "winapi", "wiremock", + "zip 8.6.0", ] [[package]] name = "goose-acp-macros" -version = "1.42.0" +version = "1.37.0" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] name = "goose-cli" -version = "1.42.0" +version = "1.37.0" dependencies = [ "anstream", "anyhow", "async-trait", "axum", - "axum-server", "base64 0.22.1", "bat", "bzip2", @@ -5077,10 +4611,9 @@ dependencies = [ "futures", "goose", "goose-mcp", - "goose-providers", "indicatif", "open", - "rand 0.10.2", + "rand 0.8.6", "regex", "reqwest 0.13.4", "rmcp", @@ -5089,9 +4622,9 @@ dependencies = [ "serde_json", "serde_yaml", "sha2 0.11.0", - "shlex 2.0.1", + "shlex", "sigstore-verify", - "strum 0.28.0", + "strum 0.27.2", "tar", "tempfile", "test-case", @@ -5106,58 +4639,9 @@ dependencies = [ "zip 8.6.0", ] -[[package]] -name = "goose-download-manager" -version = "0.1.0-alpha.0" -dependencies = [ - "anyhow", - "once_cell", - "reqwest 0.13.4", - "serde", - "tokio", - "tracing", - "utoipa 4.2.3", -] - -[[package]] -name = "goose-local-inference" -version = "0.1.0-alpha.0" -dependencies = [ - "anyhow", - "async-stream", - "async-trait", - "base64 0.22.1", - "chrono", - "encoding_rs", - "etcetera 0.11.0", - "fs2", - "futures", - "goose-download-manager", - "goose-provider-types", - "goose-sdk-types", - "hf-hub", - "include_dir", - "llama-cpp-2", - "llama-cpp-sys-2", - "minijinja", - "regex", - "reqwest 0.13.4", - "rmcp", - "safemlx", - "safemlx-lm", - "safemlx-lm-utils", - "serde", - "serde_json", - "tempfile", - "tokio", - "tracing", - "utoipa 4.2.3", - "uuid", -] - [[package]] name = "goose-mcp" -version = "1.42.0" +version = "1.37.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -5185,100 +4669,69 @@ dependencies = [ "url", ] -[[package]] -name = "goose-provider-types" -version = "0.1.0-alpha.0" -dependencies = [ - "anyhow", - "async-stream", - "async-trait", - "base64 0.22.1", - "chrono", - "env-lock", - "futures", - "once_cell", - "rand 0.10.2", - "regex", - "reqwest 0.13.4", - "rmcp", - "serde", - "serde_json", - "strum 0.28.0", - "tempfile", - "test-case", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tracing", - "unicode-normalization", - "utoipa 4.2.3", - "uuid", -] - -[[package]] -name = "goose-providers" -version = "0.1.0-alpha.0" -dependencies = [ - "anyhow", - "async-stream", - "async-trait", - "chrono", - "env-lock", - "futures", - "goose-local-inference", - "goose-provider-types", - "include_dir", - "pem", - "pkcs1", - "pkcs8", - "reqwest 0.13.4", - "rmcp", - "sec1", - "serde", - "serde_json", - "tempfile", - "test-case", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", - "url", - "urlencoding", - "utoipa 4.2.3", - "wiremock", -] - [[package]] name = "goose-sdk" -version = "0.1.0-alpha.0" -dependencies = [ - "agent-client-protocol", - "agent-client-protocol-schema", - "anyhow", - "futures", - "goose-providers", - "goose-sdk-types", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "uniffi", -] - -[[package]] -name = "goose-sdk-types" -version = "0.1.0-alpha.0" +version = "1.37.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", "schemars 1.2.1", "serde", "serde_json", + "tokio", + "tokio-util", +] + +[[package]] +name = "goose-server" +version = "1.37.0" +dependencies = [ + "anyhow", + "aws-lc-rs", + "axum", + "axum-server", + "base64 0.22.1", + "bytes", + "chrono", + "clap", + "config", + "fs2", + "futures", + "goose", + "goose-mcp", + "hex", + "http 1.4.1", + "openssl", + "pem", + "rand 0.8.6", + "rcgen", + "reqwest 0.13.4", + "rmcp", + "rustls", + "serde", + "serde_json", + "serde_path_to_error", + "serde_yaml", + "socket2", + "subtle", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tokio-tungstenite 0.29.0", + "tokio-util", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "utoipa 4.2.3", + "uuid", + "winreg", ] [[package]] name = "goose-test" -version = "1.42.0" +version = "1.37.0" dependencies = [ "clap", "serde_json", @@ -5286,7 +4739,7 @@ dependencies = [ [[package]] name = "goose-test-support" -version = "1.42.0" +version = "1.37.0" dependencies = [ "axum", "env-lock", @@ -5318,16 +4771,16 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.2", + "http 1.4.1", "indexmap 2.14.0", "slab", "tokio", @@ -5342,7 +4795,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "bytemuck", - "cfg-if 1.0.4", + "cfg-if", "crunchy", "num-traits", "rand 0.9.4", @@ -5352,9 +4805,9 @@ dependencies = [ [[package]] name = "handlebars" -version = "6.4.2" +version = "6.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26569a2763497b7bd3fbd19374b774ea6038c5293678771259cd534d49740ff" +checksum = "d43ccdfe15a81ab0a8af639e90254227c9a46afd9c5f5b6ec7efaa345c4b0f00" dependencies = [ "derive_builder", "log", @@ -5421,12 +4874,6 @@ dependencies = [ "hashbrown 0.15.5", ] -[[package]] -name = "heapify" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e" - [[package]] name = "heck" version = "0.5.0" @@ -5454,63 +4901,6 @@ dependencies = [ "arrayvec", ] -[[package]] -name = "hex-conservative" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "hf-hub" -version = "1.0.0-rc.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f89305dc8fe34e165eaf0eb12b6e294e12381d9df9a431bcc52a5809bab4319" -dependencies = [ - "base64 0.22.1", - "bon", - "bytes", - "futures", - "globset", - "hf-xet", - "hyper", - "pathdiff", - "reqwest 0.13.4", - "serde", - "serde_json", - "sha2 0.11.0", - "thiserror 2.0.18", - "tokio", - "tokio-retry", - "tokio-util", - "tracing", - "url", -] - -[[package]] -name = "hf-xet" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "430b33fa84f92796d4d263070b6c0d3ca219df7b9a0e1853ee431029b1612bcd" -dependencies = [ - "async-trait", - "bytes", - "http 1.4.2", - "more-asserts", - "serde", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", - "uuid", - "xet-client", - "xet-core-structures", - "xet-data", - "xet-runtime", -] - [[package]] name = "hipstr" version = "0.6.0" @@ -5560,14 +4950,14 @@ dependencies = [ [[package]] name = "hstr" -version = "3.0.6" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83bb87e4b300d73412f6dcc7022ee7741452b51b155c2b06e5994d0770c2dbe2" +checksum = "c1b94e40256e78ddd4e30490aa931bec17e65e9413a6ad11f64ec67815da9323" dependencies = [ "hashbrown 0.14.5", "new_debug_unreachable", "once_cell", - "rustc-hash 2.1.3", + "rustc-hash 2.1.2", "serde", "triomphe", ] @@ -5600,9 +4990,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -5626,7 +5016,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.2", + "http 1.4.1", ] [[package]] @@ -5637,7 +5027,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.2", + "http 1.4.1", "http-body 1.0.1", "pin-project-lite", ] @@ -5654,33 +5044,27 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "humantime" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" - [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.10.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "h2", - "http 1.4.2", + "http 1.4.1", "http-body 1.0.1", "httparse", "httpdate", @@ -5697,14 +5081,14 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.2", + "http 1.4.1", "hyper", "hyper-util", "rustls", "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.8", + "webpki-roots 1.0.7", ] [[package]] @@ -5746,7 +5130,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.2", + "http 1.4.1", "http-body 1.0.1", "hyper", "ipnet", @@ -5815,7 +5199,7 @@ checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", - "yoke 0.8.3", + "yoke 0.8.2", "zerofrom", "zerovec", ] @@ -5906,12 +5290,18 @@ dependencies = [ "serde", "stable_deref_trait", "writeable", - "yoke 0.8.3", + "yoke 0.8.2", "zerofrom", "zerotrie", "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -5947,9 +5337,9 @@ checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" [[package]] name = "ignore" -version = "0.4.27" +version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe112b004901c62c2faa11f4f75e9864e0cc5af8da71c9115d184a3aa888749f" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" dependencies = [ "crossbeam-deque", "globset", @@ -6063,9 +5453,9 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.18.6" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" dependencies = [ "console", "portable-atomic", @@ -6095,9 +5485,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.48.0" +version = "1.47.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" dependencies = [ "once_cell", "similar", @@ -6110,7 +5500,7 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "js-sys", "wasm-bindgen", "web-sys", @@ -6140,7 +5530,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -6191,11 +5581,10 @@ checksum = "2ceaf4c6c48465bead8cb6a0b7c4ee0c86ecbb31239032b9c66ab9a08d2f3ee1" [[package]] name = "jiff" -version = "0.2.31" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" +checksum = "392c70591e8749fe235ddaf513e6f58b26bce3dcc16524cecc8936f75afa161e" dependencies = [ - "defmt", "jiff-static", "jiff-tzdb-platform", "log", @@ -6207,20 +5596,20 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.31" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2" +checksum = "47b605b0c050d845fc355bb11eb3f9a8deddc218ea60c76e61aa1f2adfb2c96a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] name = "jiff-tzdb" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6142247df1a93c2b3587402a19710be3e6e942f1581a1702e76408f2c21d6590" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" [[package]] name = "jiff-tzdb-platform" @@ -6237,7 +5626,7 @@ version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "combine", "jni-macros", "jni-sys", @@ -6258,7 +5647,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -6277,16 +5666,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] name = "jobserver" -version = "0.1.35" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.3.4", "libc", ] @@ -6301,12 +5690,13 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "futures-util", + "once_cell", "wasm-bindgen", ] @@ -6320,39 +5710,39 @@ dependencies = [ ] [[package]] -name = "jsonschema" -version = "0.46.10" +name = "jsonrpcmsg" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0a699d3e77675e6aa4bfffe3b907c8b5f7ed3241f9965bffb25475ad4b08d05" +checksum = "6d833a15225c779251e13929203518c2ff26e2fe0f322d584b213f4f4dad37bd" dependencies = [ - "ahash", - "bytecount", - "data-encoding", - "email_address", - "fancy-regex 0.18.0", - "fraction", - "getrandom 0.3.4", - "idna", - "itoa", - "jsonschema-regex", - "num-cmp", - "num-traits", - "percent-encoding", - "referencing", - "regex", "serde", "serde_json", - "unicode-general-category", - "uuid-simd", ] [[package]] -name = "jsonschema-regex" -version = "0.46.10" +name = "jsonschema" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbd1086b01b9349fd4ef9a07433965af64c8ce8159abe633a189e4ff817bd13" +checksum = "f1b46a0365a611fbf1d2143104dcf910aada96fafd295bab16c60b802bf6fa1d" dependencies = [ + "ahash", + "base64 0.22.1", + "bytecount", + "email_address", + "fancy-regex 0.14.0", + "fraction", + "idna", + "itoa", + "num-cmp", + "num-traits", + "once_cell", + "percent-encoding", + "referencing", + "regex", "regex-syntax", + "serde", + "serde_json", + "uuid-simd", ] [[package]] @@ -6397,23 +5787,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "konst" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f660d5f887e3562f9ab6f4a14988795b694099d66b4f5dedc02d197ba9becb1d" -dependencies = [ - "const_panic", - "konst_proc_macros", - "typewit", -] - -[[package]] -name = "konst_proc_macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" - [[package]] name = "lazy-regex" version = "3.6.0" @@ -6434,7 +5807,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -6446,6 +5819,12 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "lebe" version = "0.5.3" @@ -6480,7 +5859,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "windows-link", ] @@ -6490,7 +5869,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "windows-link", ] @@ -6502,14 +5881,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "libc", "plain", - "redox_syscall 0.9.0", + "redox_syscall 0.7.5", ] [[package]] @@ -6525,9 +5904,9 @@ dependencies = [ [[package]] name = "linktime-proc-macro" -version = "0.2.0" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" +checksum = "a44cd706ff0d503ee32b2071166510ca27e281228de10cd3aa8d35ff94560f81" [[package]] name = "linux-keyutils" @@ -6535,7 +5914,7 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "libc", ] @@ -6602,29 +5981,30 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "lopdf" -version = "0.42.0" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25aab26d99567469098e64a02f42679f8965c6401263eefa31d8f2dcc37a221c" +checksum = "73fdcbab5b237a03984f83b1394dc534e0b1960675c7f3ec4d04dccc9032b56d" dependencies = [ "aes", - "bitflags 2.13.0", + "bitflags 2.11.1", "cbc", "ecb", "encoding_rs", "flate2", - "getrandom 0.4.3", + "getrandom 0.4.2", "indexmap 2.14.0", "itoa", "log", "md-5", "nom 8.0.0", - "rand 0.10.2", + "nom_locate", + "rand 0.10.1", "rangemap", "sha2 0.10.9", "stringprep", @@ -6639,36 +6019,12 @@ version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" -[[package]] -name = "lru" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" - [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lz4_flex" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" -dependencies = [ - "twox-hash", -] - -[[package]] -name = "mach-sys" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48460c2e82a3a0de197152fdf8d2c2d5e43adc501501553e439bf2156e6f87c7" -dependencies = [ - "fastrand", -] - [[package]] name = "macro_rules_attribute" version = "0.2.2" @@ -6715,21 +6071,21 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "digest 0.10.7", ] [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" -version = "0.9.11" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" dependencies = [ "libc", "stable_deref_trait", @@ -6756,7 +6112,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "block", "core-graphics-types", "foreign-types 0.5.0", @@ -6765,12 +6121,6 @@ dependencies = [ "paste", ] -[[package]] -name = "micromap" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" - [[package]] name = "mime" version = "0.3.17" @@ -6789,24 +6139,14 @@ dependencies = [ [[package]] name = "minijinja" -version = "2.21.0" +version = "2.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39" +checksum = "2929e494b2280e1e18959bb2e121da03347ae896896fdfaceaab43c88a02803f" dependencies = [ "memo-map", "serde", ] -[[package]] -name = "minijinja-contrib" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85342f6fac0be8ccd5bd00d9066be538f34f393f577b75d81b17c8398a6b43bb" -dependencies = [ - "minijinja", - "serde", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -6825,22 +6165,22 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] [[package]] name = "mockall" -version = "0.15.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a6ceddfe3ce334925e96bf420fdb2dcee5bed6c632a168ece622676dadeaf8a" +checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "downcast", "fragile", "mockall_derive", @@ -6850,14 +6190,14 @@ dependencies = [ [[package]] name = "mockall_derive" -version = "0.15.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cfe16fbe8a314aeec0b861ac24e60b1e123e97634bab045475b9d6a18416fd8" +checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -6885,15 +6225,9 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] -[[package]] -name = "more-asserts" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fafa6961cabd9c63bcd77a45d7e3b7f3b552b70417831fb0f56db717e72407e" - [[package]] name = "moxcms" version = "0.8.1" @@ -6963,8 +6297,8 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.0", - "cfg-if 1.0.4", + "bitflags 2.11.1", + "cfg-if", "cfg_aliases", "libc", ] @@ -6979,7 +6313,7 @@ dependencies = [ "async-trait", "boxed_error", "capacity_builder", - "dashmap", + "dashmap 5.5.3", "deno_error", "deno_maybe_sync", "deno_media_type", @@ -7020,10 +6354,21 @@ dependencies = [ ] [[package]] -name = "nostr" -version = "0.44.4" +name = "nom_locate" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98cf5d15d70d1f8f4059e5f79923ac15891eb691d2843d01191e0585fb064d70" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom 8.0.0", +] + +[[package]] +name = "nostr" +version = "0.44.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d8f0fe13526800300a36bf3b7c5f752e62e32ab81c74a8e5caa2865708625a" dependencies = [ "base64 0.22.1", "bech32", @@ -7049,7 +6394,7 @@ version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" dependencies = [ - "lru 0.16.4", + "lru", "nostr", "tokio", ] @@ -7073,7 +6418,7 @@ dependencies = [ "async-wsocket", "atomic-destructor", "hex", - "lru 0.16.4", + "lru", "negentropy", "nostr", "nostr-database", @@ -7096,15 +6441,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "ntapi" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" -dependencies = [ - "winapi", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -7130,9 +6466,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.8" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", @@ -7186,7 +6522,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -7200,19 +6536,20 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.46" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" dependencies = [ + "autocfg", "num-integer", "num-traits", ] [[package]] name = "num-modular" -version = "0.6.4" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc41a1374056e9672221567958a66c16be12d0e2c1b408761e14d901c237d5e0" +checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" [[package]] name = "num-order" @@ -7254,28 +6591,6 @@ dependencies = [ "libc", ] -[[package]] -name = "num_enum" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "oauth2" version = "5.0.0" @@ -7285,7 +6600,7 @@ dependencies = [ "base64 0.22.1", "chrono", "getrandom 0.2.17", - "http 1.4.2", + "http 1.4.1", "rand 0.8.6", "reqwest 0.12.28", "serde", @@ -7320,7 +6635,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "objc2", "objc2-core-graphics", "objc2-foundation", @@ -7332,7 +6647,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "dispatch2", "objc2", ] @@ -7343,7 +6658,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "dispatch2", "objc2", "objc2-core-foundation", @@ -7362,30 +6677,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "block2", "libc", "objc2", "objc2-core-foundation", ] -[[package]] -name = "objc2-io-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" -dependencies = [ - "libc", - "objc2-core-foundation", -] - [[package]] name = "objc2-io-surface" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "objc2", "objc2-core-foundation", ] @@ -7396,7 +6701,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "block2", "dispatch2", "objc2", @@ -7404,15 +6709,6 @@ dependencies = [ "objc2-foundation", ] -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "objc2-core-foundation", -] - [[package]] name = "object" version = "0.37.3" @@ -7443,19 +6739,13 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "oneshot" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" - [[package]] name = "onig" version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "libc", "once_cell", "onig_sys", @@ -7479,22 +6769,23 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.3.6" +version = "5.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" dependencies = [ "is-wsl", "libc", + "pathdiff", ] [[package]] name = "openssl" -version = "0.10.81" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.13.0", - "cfg-if 1.0.4", + "bitflags 2.11.1", + "cfg-if", "foreign-types 0.3.2", "libc", "openssl-macros", @@ -7509,7 +6800,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -7520,18 +6811,18 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" -version = "300.6.1+3.6.3" +version = "300.6.0+3.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.117" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -7588,7 +6879,7 @@ checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" dependencies = [ "async-trait", "bytes", - "http 1.4.2", + "http 1.4.1", "opentelemetry 0.31.0", "reqwest 0.12.28", ] @@ -7601,7 +6892,7 @@ checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" dependencies = [ "async-trait", "bytes", - "http 1.4.2", + "http 1.4.1", "opentelemetry 0.32.0", "reqwest 0.13.4", ] @@ -7612,7 +6903,7 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f" dependencies = [ - "http 1.4.2", + "http 1.4.1", "opentelemetry 0.31.0", "opentelemetry-http 0.31.0", "opentelemetry-proto 0.31.0", @@ -7631,11 +6922,11 @@ version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" dependencies = [ - "http 1.4.2", + "http 1.4.1", "opentelemetry 0.32.0", "opentelemetry-http 0.32.0", "opentelemetry-proto 0.32.0", - "opentelemetry_sdk 0.32.1", + "opentelemetry_sdk 0.32.0", "prost", "reqwest 0.13.4", "thiserror 2.0.18", @@ -7661,7 +6952,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" dependencies = [ "opentelemetry 0.32.0", - "opentelemetry_sdk 0.32.1", + "opentelemetry_sdk 0.32.0", "prost", ] @@ -7673,7 +6964,7 @@ checksum = "a1b1c6a247d79091f0062a5f4bd058589525cf987a8d4c169440d9c1be72f0ad" dependencies = [ "chrono", "opentelemetry 0.32.0", - "opentelemetry_sdk 0.32.1", + "opentelemetry_sdk 0.32.0", ] [[package]] @@ -7693,9 +6984,9 @@ dependencies = [ [[package]] name = "opentelemetry_sdk" -version = "0.32.1" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" +checksum = "368afaed344110f40b179bb8fbe54bc52d98f9bd2b281799ef32487c2650c956" dependencies = [ "futures-channel", "futures-executor", @@ -7714,15 +7005,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "os_str_bytes" -version = "6.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" -dependencies = [ - "memchr", -] - [[package]] name = "outref" version = "0.5.2" @@ -7784,7 +7066,7 @@ version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "libc", "redox_syscall 0.5.18", "smallvec", @@ -7916,7 +7198,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "camino", - "http 1.4.2", + "http 1.4.1", "indexmap 2.14.0", "keyring", "opentelemetry-otlp 0.31.1", @@ -7925,7 +7207,7 @@ dependencies = [ "rmcp", "serde", "serde_json", - "shlex 1.3.0", + "shlex", "thiserror 2.0.18", "tokio", "tonic", @@ -8025,9 +7307,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.7" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ "memchr", "ucd-trie", @@ -8035,9 +7317,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.7" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ "pest", "pest_generator", @@ -8045,24 +7327,25 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.7" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] name = "pest_meta" -version = "2.8.7" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", + "sha2 0.10.9", ] [[package]] @@ -8104,7 +7387,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -8142,7 +7425,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -8157,17 +7440,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - [[package]] name = "pkcs1" version = "0.7.5" @@ -8203,13 +7475,13 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.10.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml 0.41.0", + "quick-xml 0.39.4", "serde", "time", ] @@ -8233,27 +7505,13 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "crc32fast", "fdeflate", "flate2", "miniz_oxide", ] -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if 1.0.4", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.1.4", - "windows-sys 0.61.2", -] - [[package]] name = "poly1305" version = "0.8.0" @@ -8349,7 +7607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -8361,15 +7619,6 @@ dependencies = [ "elliptic-curve", ] -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - [[package]] name = "proc-macro-error" version = "1.0.4" @@ -8419,9 +7668,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.4" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", "prost-derive", @@ -8429,15 +7678,15 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.14.4" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -8472,7 +7721,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "memchr", "unicase", ] @@ -8484,7 +7733,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96b86df24f0a7ddd5e4b95c94fc9ed8a98f1ca94d3b01bdce2824097e7835907" dependencies = [ "bytemuck", - "cfg-if 1.0.4", + "cfg-if", "libm", "num-complex", "reborrow", @@ -8493,12 +7742,12 @@ dependencies = [ [[package]] name = "pulp" -version = "0.22.3" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +checksum = "2e205bb30d5b916c55e584c22201771bcf2bad9aabd5d4127f38387140c38632" dependencies = [ "bytemuck", - "cfg-if 1.0.4", + "cfg-if", "libm", "num-complex", "paste", @@ -8510,15 +7759,15 @@ dependencies = [ [[package]] name = "pulp-wasm-simd-flag" -version = "0.1.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" +checksum = "40e24eee682d89fb193496edf918a7f407d30175b2e785fe057e4392dfd182e0" [[package]] name = "pxfm" -version = "0.1.30" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" [[package]] name = "quick-error" @@ -8548,25 +7797,25 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.41.0" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "memchr", ] [[package]] name = "quinn" -version = "0.11.11" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.3", + "rustc-hash 2.1.2", "rustls", "socket2", "thiserror 2.0.18", @@ -8577,18 +7826,17 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.4.3", + "getrandom 0.3.4", "lru-slab", - "rand 0.10.2", - "rand_pcg", + "rand 0.9.4", "ring", - "rustc-hash 2.1.3", + "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "slab", @@ -8600,23 +7848,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.15" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -8672,12 +7920,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "chacha20 0.10.1", - "getrandom 0.4.3", + "chacha20 0.10.0", + "getrandom 0.4.2", "rand_core 0.10.1", ] @@ -8735,15 +7983,6 @@ dependencies = [ "rand 0.9.4", ] -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - [[package]] name = "rangemap" version = "1.7.1" @@ -8756,7 +7995,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", ] [[package]] @@ -8798,7 +8037,6 @@ checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" dependencies = [ "aws-lc-rs", "pem", - "ring", "rustls-pki-types", "time", "x509-parser", @@ -8811,31 +8049,33 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" -[[package]] -name = "redb" -version = "3.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba239c1c1693315d3cc0e601db3b3965543afbf48c41730fdca2f069f510f4a" -dependencies = [ - "libc", -] - [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", ] [[package]] name = "redox_syscall" -version = "0.9.0" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", ] [[package]] @@ -8866,21 +8106,18 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] name = "referencing" -version = "0.46.10" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbf332a2f81899f6836f22c03da73dae8a664c32e3016b84692c23cddadc95d" +checksum = "c8eff4fa778b5c2a57e85c5f2fe3a709c52f0e60d23146e2151cbef5893f420e" dependencies = [ "ahash", "fluent-uri", - "getrandom 0.3.4", - "hashbrown 0.16.1", - "itoa", - "micromap", + "once_cell", "parking_lot", "percent-encoding", "serde_json", @@ -8888,9 +8125,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.4" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -8917,9 +8154,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.11" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" @@ -8932,7 +8169,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http 1.4.2", + "http 1.4.1", "http-body 1.0.1", "http-body-util", "hyper", @@ -8955,13 +8192,13 @@ dependencies = [ "tokio-native-tls", "tokio-rustls", "tower", - "tower-http 0.6.11", + "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.8", + "webpki-roots 1.0.7", ] [[package]] @@ -8979,7 +8216,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http 1.4.2", + "http 1.4.1", "http-body 1.0.1", "http-body-util", "hyper", @@ -9006,7 +8243,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", - "tower-http 0.6.11", + "tower-http", "tower-service", "url", "wasm-bindgen", @@ -9015,20 +8252,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "reqwest-middleware" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" -dependencies = [ - "anyhow", - "async-trait", - "http 1.4.2", - "reqwest 0.13.4", - "thiserror 2.0.18", - "tower-service", -] - [[package]] name = "resb" version = "0.1.2" @@ -9065,7 +8288,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if 1.0.4", + "cfg-if", "getrandom 0.2.17", "libc", "untrusted 0.9.0", @@ -9074,16 +8297,16 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.8.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" +checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" dependencies = [ "async-trait", "base64 0.22.1", "bytes", "chrono", "futures", - "http 1.4.2", + "http 1.4.1", "http-body 1.0.1", "http-body-util", "hyper", @@ -9092,7 +8315,7 @@ dependencies = [ "pastey", "pin-project-lite", "process-wrap", - "rand 0.10.2", + "rand 0.10.1", "reqwest 0.13.4", "rmcp-macros", "schemars 1.2.1", @@ -9111,15 +8334,15 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.8.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5" +checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" dependencies = [ "darling 0.23.0", "proc-macro2", "quote", "serde_json", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -9172,9 +8395,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.3" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -9200,7 +8423,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -9213,7 +8436,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -9222,9 +8445,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", @@ -9237,9 +8460,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -9249,9 +8472,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -9298,18 +8521,18 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.23" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rustyline" -version = "18.0.1" +version = "18.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53f6a737db68eb1a8ccff86b584b2fc13eca6a7bb6f78ebc7c529547e3ab9684" +checksum = "4a990b25f351b25139ddc7f21ee3f6f56f86d6846b74ac8fad3a719a287cd4a0" dependencies = [ - "bitflags 2.13.0", - "cfg-if 1.0.4", + "bitflags 2.11.1", + "cfg-if", "clipboard-win", "home", "libc", @@ -9335,105 +8558,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" -[[package]] -name = "safe-transmute" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944826ff8fa8093089aba3acb4ef44b9446a99a16f3bf4e74af3f77d340ab7d" - -[[package]] -name = "safemlx" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f501c4f9748dfe9ccc905e7e13ae88873d96a00735268c69258e158da07a777f" -dependencies = [ - "bytemuck", - "dyn-clone", - "half", - "itertools 0.14.0", - "libc", - "mach-sys", - "num-complex", - "num-traits", - "num_enum", - "parking_lot", - "paste", - "safemlx-internal-macros", - "safemlx-macros", - "safemlx-sys", - "safetensors 0.6.2", - "smallvec", - "strum 0.27.2", - "thiserror 2.0.18", -] - -[[package]] -name = "safemlx-internal-macros" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9708b1312176963a4ea6412859e6655e56a83fe6ab936142715aa0e9fd662fe5" -dependencies = [ - "darling 0.21.3", - "itertools 0.14.0", - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "safemlx-lm" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78f450654386009003b745adfdae7af2c7f44ae749d75621a2a0b07607945e9f" -dependencies = [ - "anyhow", - "clap", - "idna_adapter", - "minijinja", - "safemlx", - "safemlx-lm-utils", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokenizers 0.22.2", -] - -[[package]] -name = "safemlx-lm-utils" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "583d39124dae0b4a93ac7528d16d543f5e717c21365e6b034a8a74d8e1b4eb3a" -dependencies = [ - "minijinja", - "minijinja-contrib", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokenizers 0.22.2", -] - -[[package]] -name = "safemlx-macros" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d549c5fdbe69b904ef84c5cf352add203de1093b3c636e8133d94c24cfc33ce0" -dependencies = [ - "darling 0.21.3", - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "safemlx-sys" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "552285b007dab687890f5c49382e52e69a3bb1359bb2c8f2c2743743509af071" -dependencies = [ - "cc", - "cmake", -] - [[package]] name = "safetensors" version = "0.4.5" @@ -9446,25 +8570,13 @@ dependencies = [ [[package]] name = "safetensors" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "172dd94c5a87b5c79f945c863da53b2ebc7ccef4eca24ac63cca66a41aab2178" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "safetensors" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79b079b829cb27a1c3c374341345ed2e8b2c0c839034522cee576c140bd7f846" +checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5" dependencies = [ "hashbrown 0.16.1", - "libc", "serde", "serde_json", - "tempfile", ] [[package]] @@ -9485,6 +8597,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "schannel" version = "0.1.29" @@ -9542,7 +8663,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -9554,7 +8675,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -9569,26 +8690,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "scroll" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" -dependencies = [ - "scroll_derive", -] - -[[package]] -name = "scroll_derive" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "scrypt" version = "0.11.0" @@ -9601,6 +8702,12 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "sec1" version = "0.7.3" @@ -9641,7 +8748,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -9654,7 +8761,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -9676,10 +8783,6 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -dependencies = [ - "serde", - "serde_core", -] [[package]] name = "seq-macro" @@ -9724,7 +8827,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -9735,7 +8838,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -9783,17 +8886,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "serde_spanned" version = "1.1.1" @@ -9831,9 +8923,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ "base64 0.22.1", "bs58", @@ -9851,14 +8943,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -9876,24 +8968,25 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.5.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" dependencies = [ "once_cell", "parking_lot", + "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" -version = "3.5.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -9902,7 +8995,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -9913,10 +9006,9 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.2.17", "digest 0.10.7", - "sha2-asm", ] [[package]] @@ -9925,20 +9017,11 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.3.0", "digest 0.11.3", ] -[[package]] -name = "sha2-asm" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b845214d6175804686b2bd482bcffe96651bb2d1200742b712003504a2dac1ab" -dependencies = [ - "cc", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -9960,9 +9043,7 @@ version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" dependencies = [ - "bstr", - "dirs", - "os_str_bytes", + "dirs 6.0.0", ] [[package]] @@ -9971,12 +9052,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -9999,15 +9074,16 @@ dependencies = [ [[package]] name = "sigstore-bundle" -version = "0.10.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18b51b097b7e9d898efad51e1031c855ce4008b4866d0c38ac8b0fac5745a84c" +checksum = "9a86d28a25d9e6107e02ca60fc60dfeb7cdc2b98251950d6fcd055f2afa31acb" dependencies = [ "base64 0.22.1", "hex", "serde", "serde_json", "sigstore-crypto", + "sigstore-merkle", "sigstore-rekor", "sigstore-tsa", "sigstore-types", @@ -10016,9 +9092,9 @@ dependencies = [ [[package]] name = "sigstore-crypto" -version = "0.10.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1210ea5dd0dd07b1466449a6c5c79000f8ca10ac30b4e8f64031c3bed4c5c662" +checksum = "9cdc66f1480e176533425cc880bc5f8a8e0d88ae1fe2701e98de4fb68984b71c" dependencies = [ "aws-lc-rs", "base64 0.22.1", @@ -10038,9 +9114,9 @@ dependencies = [ [[package]] name = "sigstore-merkle" -version = "0.10.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae98f8a270885497dbe84e6dae10fa29066df0388f62980923728e38d844716" +checksum = "a4dbea5d8d5f293dfc2ed6abd06dc26a5ef017af126e53e3e36a6b2f7b7dac8c" dependencies = [ "base64 0.22.1", "hex", @@ -10051,9 +9127,9 @@ dependencies = [ [[package]] name = "sigstore-rekor" -version = "0.10.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e1c153f4f89f7570d0f625f660cadcb1b2fba72583f378d2d2b308c752ac45f" +checksum = "fbe0bf948de89bed9b3b5c9d62940264a4da60db3518006b952b107d0e71926f" dependencies = [ "base64 0.22.1", "hex", @@ -10069,9 +9145,9 @@ dependencies = [ [[package]] name = "sigstore-trust-root" -version = "0.10.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ded6afc37295a10dba6569e033dd15f85f04f00980bfcf2c64009bc8ed279985" +checksum = "54fdf65b1207f96d4b1e5b44028fe3e078d40a50f6316715f6ad97ae21b0bfeb" dependencies = [ "base64 0.22.1", "hex", @@ -10080,7 +9156,6 @@ dependencies = [ "serde", "serde_json", "sigstore-crypto", - "sigstore-tuf", "sigstore-types", "thiserror 2.0.18", "x509-cert", @@ -10088,9 +9163,9 @@ dependencies = [ [[package]] name = "sigstore-tsa" -version = "0.10.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88505b71600a791e52bed3777ed9cfeefd4b03dee6575d38c5542ae80e2bbdc" +checksum = "860eb6a645e0a22fa316031b06e9e87f9310c21915743bfa83901472dd3e7146" dependencies = [ "aws-lc-rs", "base64 0.22.1", @@ -10112,33 +9187,11 @@ dependencies = [ "x509-tsp", ] -[[package]] -name = "sigstore-tuf" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc7cced094bd306ef561312cfffa3ea05ac7eea4a62cfa81680c07a10addbc0" -dependencies = [ - "globset", - "hex", - "jiff", - "reqwest 0.13.4", - "serde", - "serde_json", - "sha2 0.10.9", - "sigstore-crypto", - "sigstore-types", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tracing", - "url", -] - [[package]] name = "sigstore-types" -version = "0.10.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f034e86c3b8d91b32608c83f4a18939eed32c6ca08d17d2f52b88be365aac6" +checksum = "7fb0334b69834c1f29757e57ade7e9bdbc42f5383cc22d003256d4d2df47af13" dependencies = [ "base64 0.22.1", "hex", @@ -10150,9 +9203,9 @@ dependencies = [ [[package]] name = "sigstore-verify" -version = "0.10.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4720f300d093ecac6dcd75d4498fd1b2d9133600e679837bb9d944a4229eda7" +checksum = "c23f74b41da93e92dc1de793d5450b662fce5c8cea887daa46002a793d78bca9" dependencies = [ "base64 0.22.1", "cms", @@ -10242,9 +9295,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" dependencies = [ "serde", ] @@ -10262,9 +9315,9 @@ dependencies = [ [[package]] name = "smawk" -version = "0.3.3" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" [[package]] name = "smithy-transport-reqwest" @@ -10274,16 +9327,16 @@ checksum = "566dc85be03a09c384f77a122188d9af000e1f1bd23551b346a9b555838da7e1" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", - "http 1.4.2", + "http 1.4.1", "parking_lot", "reqwest 0.13.4", ] [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", "windows-sys 0.61.2", @@ -10300,7 +9353,7 @@ dependencies = [ "data-encoding", "debugid", "if_chain", - "rustc-hash 2.1.3", + "rustc-hash 2.1.2", "serde", "serde_json", "unicode-id-start", @@ -10405,7 +9458,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -10428,7 +9481,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.118", + "syn 2.0.117", "tokio", "url", ] @@ -10441,7 +9494,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.13.0", + "bitflags 2.11.1", "byteorder", "bytes", "chrono", @@ -10473,7 +9526,7 @@ dependencies = [ "stringprep", "thiserror 2.0.18", "tracing", - "whoami 1.6.1", + "whoami", ] [[package]] @@ -10484,7 +9537,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.13.0", + "bitflags 2.11.1", "byteorder", "chrono", "crc", @@ -10511,7 +9564,7 @@ dependencies = [ "stringprep", "thiserror 2.0.18", "tracing", - "whoami 1.6.1", + "whoami", ] [[package]] @@ -10541,9 +9594,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.4" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39f24a9b78c40b90817bbcd1821c74ddfd74916aadd29403d001532a9195532d" +checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" dependencies = [ "bytes", "futures-util", @@ -10565,7 +9618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" dependencies = [ "cc", - "cfg-if 1.0.4", + "cfg-if", "libc", "psm", "windows-sys 0.61.2", @@ -10577,16 +9630,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "statrs" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" -dependencies = [ - "approx", - "num-traits", -] - [[package]] name = "std_prelude" version = "0.2.12" @@ -10616,7 +9659,7 @@ checksum = "ae36a4951ca7bd1cfd991c241584a9824a70f6aff1e7d4f693fb3f2465e4030e" dependencies = [ "quote", "swc_macros_common", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -10669,7 +9712,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -10681,7 +9724,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -10699,7 +9742,7 @@ dependencies = [ "allocator-api2", "bumpalo", "hashbrown 0.14.5", - "rustc-hash 2.1.3", + "rustc-hash 2.1.2", ] [[package]] @@ -10728,7 +9771,7 @@ dependencies = [ "new_debug_unreachable", "num-bigint", "once_cell", - "rustc-hash 2.1.3", + "rustc-hash 2.1.2", "serde", "siphasher 0.3.11", "swc_atoms", @@ -10763,7 +9806,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -10772,12 +9815,12 @@ version = "18.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a573a0c72850dec8d4d8085f152d5778af35a2520c3093b242d2d1d50776da7c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "is-macro", "num-bigint", "once_cell", "phf 0.11.3", - "rustc-hash 2.1.3", + "rustc-hash 2.1.2", "serde", "string_enum", "swc_atoms", @@ -10798,7 +9841,7 @@ dependencies = [ "num-bigint", "once_cell", "regex", - "rustc-hash 2.1.3", + "rustc-hash 2.1.2", "ryu-js", "serde", "swc_allocator", @@ -10818,7 +9861,7 @@ checksum = "e276dc62c0a2625a560397827989c82a93fd545fcf6f7faec0935a82cc4ddbb8" dependencies = [ "proc-macro2", "swc_macros_common", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -10827,10 +9870,10 @@ version = "26.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e82f7747e052c6ff6e111fa4adeb14e33b46ee6e94fe5ef717601f651db48fc" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "either", "num-bigint", - "rustc-hash 2.1.3", + "rustc-hash 2.1.2", "seq-macro", "serde", "smallvec", @@ -10851,7 +9894,7 @@ checksum = "fbcababb48f0d46587a0a854b2c577eb3a56fa99687de558338021e93cd2c8f5" dependencies = [ "anyhow", "pathdiff", - "rustc-hash 2.1.3", + "rustc-hash 2.1.2", "serde", "swc_atoms", "swc_common", @@ -10864,11 +9907,11 @@ version = "27.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f1a51af1a92cd4904c073b293e491bbc0918400a45d58227b34c961dd6f52d7" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "either", "num-bigint", "phf 0.11.3", - "rustc-hash 2.1.3", + "rustc-hash 2.1.2", "seq-macro", "serde", "smartstring", @@ -10890,7 +9933,7 @@ dependencies = [ "once_cell", "par-core", "phf 0.11.3", - "rustc-hash 2.1.3", + "rustc-hash 2.1.2", "serde", "swc_atoms", "swc_common", @@ -10923,7 +9966,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -10933,7 +9976,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2d7748d4112c87ce1885260035e4a43cebfe7661a40174b7d77a0a04760a257" dependencies = [ "either", - "rustc-hash 2.1.3", + "rustc-hash 2.1.2", "serde", "swc_atoms", "swc_common", @@ -10954,7 +9997,7 @@ dependencies = [ "bytes-str", "indexmap 2.14.0", "once_cell", - "rustc-hash 2.1.3", + "rustc-hash 2.1.2", "serde", "sha1", "string_enum", @@ -10975,7 +10018,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4408800fdeb541fabf3659db622189a0aeb386f57b6103f9294ff19dfde4f7b0" dependencies = [ "bytes-str", - "rustc-hash 2.1.3", + "rustc-hash 2.1.2", "serde", "swc_atoms", "swc_common", @@ -10996,7 +10039,7 @@ dependencies = [ "num_cpus", "once_cell", "par-core", - "rustc-hash 2.1.3", + "rustc-hash 2.1.2", "ryu-js", "swc_atoms", "swc_common", @@ -11028,7 +10071,7 @@ checksum = "c16ce73424a6316e95e09065ba6a207eba7765496fed113702278b7711d4b632" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -11039,7 +10082,7 @@ checksum = "aae1efbaa74943dc5ad2a2fb16cbd78b77d7e4d63188f3c5b4df2b4dcd2faaae" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -11054,7 +10097,7 @@ dependencies = [ "data-encoding", "debugid", "if_chain", - "rustc-hash 2.1.3", + "rustc-hash 2.1.2", "serde", "serde_json", "unicode-id-start", @@ -11247,9 +10290,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -11264,7 +10307,7 @@ checksum = "54b8f0a9004d6aafa6a588602a1119e6cdaacec9921aa1605383e6e7d6258fd6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -11284,7 +10327,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -11332,7 +10375,7 @@ checksum = "181f22127402abcf8ee5c83ccd5b408933fec36a6095cf82cda545634692657e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -11341,7 +10384,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "byteorder", "enum-as-inner", "libc", @@ -11349,27 +10392,13 @@ dependencies = [ "walkdir", ] -[[package]] -name = "sysinfo" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" -dependencies = [ - "libc", - "memchr", - "ntapi", - "objc2-core-foundation", - "objc2-io-kit", - "windows 0.62.2", -] - [[package]] name = "system-configuration" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -11407,7 +10436,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -11461,7 +10490,7 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a46bb5364467da040298c573c8a95dbf9a512efc039630409a03126e3703e90" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "libc", "memchr", "mio", @@ -11476,7 +10505,7 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3f27d9a8a177e57545481faec87acb45c6e854ed1e5a3658ad186c106f38ed" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "libc", "windows-sys 0.61.2", ] @@ -11502,10 +10531,10 @@ version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -11516,7 +10545,7 @@ checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", "test-case-core", ] @@ -11540,6 +10569,12 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "thin-vec" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482" + [[package]] name = "thiserror" version = "1.0.69" @@ -11566,7 +10601,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -11577,7 +10612,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -11592,7 +10627,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", ] [[package]] @@ -11622,9 +10657,9 @@ dependencies = [ [[package]] name = "tiktoken-rs" -version = "0.12.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "027853bbf8c7763b77c5c595f1c271c7d536ced7d6f83452911b944621e57fc2" +checksum = "fac4a168cfc1d8ed65bf17a6ee0843ad9a68f863c63c0fb2fa7eab67838782ee" dependencies = [ "anyhow", "base64 0.22.1", @@ -11632,16 +10667,17 @@ dependencies = [ "fancy-regex 0.17.0", "lazy_static", "regex", - "rustc-hash 2.1.3", + "rustc-hash 1.1.0", ] [[package]] name = "time" -version = "0.3.53" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde_core", @@ -11651,15 +10687,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.9" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.31" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -11721,18 +10757,18 @@ checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] name = "tokenizers" -version = "0.22.2" +version = "0.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +checksum = "a620b996116a59e184c2fa2dfd8251ea34a36d0a514758c6f966386bd2e03476" dependencies = [ "ahash", "aho-corasick", - "compact_str 0.9.1", + "compact_str 0.9.0", "dary_heap", "derive_builder", "esaxx-rs", @@ -11759,13 +10795,13 @@ dependencies = [ [[package]] name = "tokenizers" -version = "0.23.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44e5bea67576e04b6ff8564c5d9e09c2ef0cf476502245f2f120e497769d3112" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" dependencies = [ "ahash", - "compact_str 0.9.1", - "daachorse", + "aho-corasick", + "compact_str 0.9.0", "dary_heap", "derive_builder", "esaxx-rs", @@ -11831,7 +10867,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -11855,17 +10891,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-retry" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a129d95275ebf4c493ec53bf0f8cd95f5ac161bc4f381700809a54f595d4470" -dependencies = [ - "pin-project-lite", - "rand 0.10.2", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -11878,9 +10903,9 @@ dependencies = [ [[package]] name = "tokio-socks" -version = "0.5.3" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" dependencies = [ "either", "futures-util", @@ -11923,7 +10948,13 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", + "native-tls", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", + "tokio-native-tls", + "tokio-rustls", "tungstenite 0.29.0", ] @@ -11963,12 +10994,10 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "indexmap 2.14.0", "serde_core", "serde_spanned", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "toml_writer", "winnow 1.0.3", ] @@ -11990,18 +11019,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.25.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.3", -] - [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -12028,7 +11045,7 @@ dependencies = [ "base64 0.22.1", "bytes", "h2", - "http 1.4.2", + "http 1.4.1", "http-body 1.0.1", "http-body-util", "hyper", @@ -12083,11 +11100,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags 2.13.0", + "bitflags 2.11.1", "bytes", "futures-core", "futures-util", - "http 1.4.2", + "http 1.4.1", "http-body 1.0.1", "http-body-util", "pin-project-lite", @@ -12099,21 +11116,6 @@ dependencies = [ "url", ] -[[package]] -name = "tower-http" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" -dependencies = [ - "bitflags 2.13.0", - "bytes", - "http 1.4.2", - "percent-encoding", - "pin-project-lite", - "tower-layer", - "tower-service", -] - [[package]] name = "tower-layer" version = "0.3.3" @@ -12159,7 +11161,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -12184,17 +11186,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - [[package]] name = "tracing-opentelemetry" version = "0.33.0" @@ -12233,20 +11224,18 @@ dependencies = [ "serde", "serde_json", "sharded-slab", - "smallvec", "thread_local", "time", "tracing", "tracing-core", - "tracing-log", "tracing-serde", ] [[package]] name = "tree-sitter" -version = "0.26.10" +version = "0.26.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c343ed63e3f5c64d1acdecb5d2c13d4e169cb5fde0052106ebaa6c6f27f9e55" +checksum = "4dab76d0b724ba557954125188cf0633a1ca43199ced82d95c7b9c32cc3de1f3" dependencies = [ "cc", "regex", @@ -12334,9 +11323,9 @@ dependencies = [ [[package]] name = "tree-sitter-swift" -version = "0.7.3" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c" +checksum = "f3b98fb6bc8e6a6a10023f401aa6a1858115e849dfaf7de57dd8b8ea0f257bd9" dependencies = [ "cc", "tree-sitter-language", @@ -12354,9 +11343,9 @@ dependencies = [ [[package]] name = "triomphe" -version = "0.1.16" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" dependencies = [ "serde", "stable_deref_trait", @@ -12382,7 +11371,7 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http 1.4.2", + "http 1.4.1", "httparse", "log", "rand 0.9.4", @@ -12401,10 +11390,13 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http 1.4.2", + "http 1.4.1", "httparse", "log", + "native-tls", "rand 0.9.4", + "rustls", + "rustls-pki-types", "sha1", "thiserror 2.0.18", ] @@ -12423,15 +11415,9 @@ checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" [[package]] name = "typenum" -version = "1.20.1" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "typewit" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "ucd-trie" @@ -12489,11 +11475,12 @@ dependencies = [ [[package]] name = "umya-spreadsheet" -version = "3.0.0" +version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf31e59909bf0fed6fc9df1d24bed0f135518eb2af05a330284f81ffcfb0513" +checksum = "408c7e039c96ec1d517a1111ade7fadab889f32c096dac691a1e3b8018c3e39a" dependencies = [ "aes", + "ahash", "base64 0.22.1", "byteorder", "cbc", @@ -12501,20 +11488,18 @@ dependencies = [ "chrono", "encoding_rs", "fancy-regex 0.14.0", + "getrandom 0.2.17", "hmac 0.12.1", "html_parser", "imagesize", - "jiff", + "lazy_static", "md-5", - "num-traits", - "paste", - "phf 0.11.3", "quick-xml 0.37.5", - "rand 0.8.6", - "rgb", + "regex", "sha2 0.10.9", + "thin-vec", "thousands", - "zip 8.6.0", + "zip 2.4.2", ] [[package]] @@ -12535,12 +11520,6 @@ version = "2.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" -[[package]] -name = "unicode-general-category" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" - [[package]] name = "unicode-id-start" version = "1.4.0" @@ -12585,9 +11564,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.3" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -12613,127 +11592,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" -[[package]] -name = "uniffi" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a782a48d72cfd7a2d65cfc7c691dbf5375c43104b3c195f7eccc716dcc3540c8" -dependencies = [ - "anyhow", - "camino", - "cargo_metadata", - "clap", - "uniffi_bindgen", - "uniffi_core", - "uniffi_macros", - "uniffi_pipeline", -] - -[[package]] -name = "uniffi_bindgen" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533b0312c73e3b54eb78a4b257ceae390962dd4767995778309a74644643f9ac" -dependencies = [ - "anyhow", - "askama", - "camino", - "cargo_metadata", - "fs-err", - "glob", - "goblin", - "heck", - "indexmap 2.14.0", - "once_cell", - "serde", - "tempfile", - "textwrap", - "toml 1.1.2+spec-1.1.0", - "uniffi_internal_macros", - "uniffi_meta", - "uniffi_pipeline", - "uniffi_udl", -] - -[[package]] -name = "uniffi_core" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e32e261c5b0dfaba6488f536e71957dddd6b1a498ac7eb791bee56b60a086be" -dependencies = [ - "anyhow", - "bytes", - "once_cell", - "static_assertions", -] - -[[package]] -name = "uniffi_internal_macros" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84ae78069a5e6772ef694fd5bdb628532c88d2c2f0e7142bf6a384636eadb1af" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "uniffi_macros" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330be6770532e86320df31f54c70bb0be67588594e8e77fa56e9083a3fed5d0d" -dependencies = [ - "camino", - "fs-err", - "once_cell", - "proc-macro2", - "quote", - "serde", - "syn 2.0.118", - "toml 1.1.2+spec-1.1.0", - "uniffi_meta", -] - -[[package]] -name = "uniffi_meta" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78de021f5547e56ab16c665a49d67d4fd3d31e77422f7739a2e9359d328cd9e7" -dependencies = [ - "anyhow", - "siphasher 1.0.3", - "uniffi_internal_macros", - "uniffi_pipeline", -] - -[[package]] -name = "uniffi_pipeline" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f8201bb1907ed8a42d80e11cbc25c8a033e7a31c3cff1d911f56eedb81d4948" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "tempfile", - "uniffi_internal_macros", -] - -[[package]] -name = "uniffi_udl" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e57996bc58009cc29bf04845d627ae313c2547b87171c1c349d6c51a1656c0" -dependencies = [ - "anyhow", - "textwrap", - "uniffi_meta", - "weedle2", -] - [[package]] name = "unit-prefix" version = "0.5.2" @@ -12838,7 +11696,8 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.118", + "regex", + "syn 2.0.117", ] [[package]] @@ -12849,16 +11708,16 @@ checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] name = "uuid" -version = "1.23.4" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] @@ -12870,6 +11729,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" dependencies = [ "outref", + "uuid", "vsimd", ] @@ -12887,7 +11747,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f84468f393984251db025e944544590a8fb429d5088b78102bd72f09232f0da" dependencies = [ "bindgen", - "bitflags 2.13.0", + "bitflags 2.11.1", "fslock", "gzip-header", "home", @@ -12962,21 +11822,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" +name = "wasip2" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wasip2", + "wit-bindgen 0.57.1", ] [[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -12985,22 +11845,13 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" -[[package]] -name = "wasite" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" -dependencies = [ - "wasi 0.14.7+wasi-0.2.4", -] - [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -13009,9 +11860,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -13019,9 +11870,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -13029,26 +11880,48 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -13073,10 +11946,22 @@ dependencies = [ ] [[package]] -name = "web-sys" -version = "0.3.103" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -13110,9 +11995,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] @@ -13123,27 +12008,18 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.8", + "webpki-roots 1.0.7", ] [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] -[[package]] -name = "weedle2" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" -dependencies = [ - "nom 7.1.3", -] - [[package]] name = "weezl" version = "0.1.12" @@ -13176,9 +12052,9 @@ dependencies = [ [[package]] name = "which" -version = "8.0.4" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48d7cd18d4acb58fb3cdfe9ea54e6cd96a4e7d4cc45c56338b236e82dad47248" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" dependencies = [ "libc", ] @@ -13190,20 +12066,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ "libredox", - "wasite 0.1.0", -] - -[[package]] -name = "whoami" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" -dependencies = [ - "libc", - "libredox", - "objc2-system-configuration", - "wasite 1.0.2", - "web-sys", + "wasite", ] [[package]] @@ -13312,7 +12175,7 @@ checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -13323,7 +12186,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -13334,7 +12197,7 @@ checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -13345,7 +12208,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -13657,6 +12520,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "winsafe" version = "0.0.19" @@ -13673,7 +12546,7 @@ dependencies = [ "base64 0.22.1", "deadpool", "futures", - "http 1.4.2", + "http 1.4.1", "http-body-util", "hyper", "hyper-util", @@ -13686,12 +12559,100 @@ dependencies = [ "url", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.3" @@ -13751,7 +12712,6 @@ dependencies = [ "lazy_static", "nom 7.1.3", "oid-registry", - "ring", "rusticata-macros", "thiserror 2.0.18", "time", @@ -13768,153 +12728,6 @@ dependencies = [ "der", ] -[[package]] -name = "xet-client" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1e496dcbe6a09017acdfaf48e1a646735e7ff5b2a49e2c7e081cca77a59bc8" -dependencies = [ - "anyhow", - "async-trait", - "base64 0.22.1", - "bytes", - "clap", - "crc32fast", - "futures", - "http 1.4.2", - "hyper", - "lazy_static", - "more-asserts", - "rand 0.10.2", - "redb", - "reqwest 0.13.4", - "reqwest-middleware", - "serde", - "serde_json", - "serde_repr", - "statrs", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tokio-retry", - "tracing", - "tracing-subscriber", - "url", - "urlencoding", - "web-time", - "xet-core-structures", - "xet-runtime", -] - -[[package]] -name = "xet-core-structures" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb838aa8eb67d730af301584cf003caad407487606058292a6750711b603fbee" -dependencies = [ - "async-trait", - "base64 0.22.1", - "blake3", - "bytemuck", - "bytes", - "clap", - "countio", - "csv", - "futures", - "futures-util", - "getrandom 0.4.3", - "heapify", - "itertools 0.14.0", - "lazy_static", - "lz4_flex", - "more-asserts", - "rand 0.10.2", - "regex", - "safe-transmute", - "serde", - "static_assertions", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", - "uuid", - "web-time", - "xet-runtime", -] - -[[package]] -name = "xet-data" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67fd409bef621411a9d9013798540bb8036cb2678f03ab39af89a5e88034ed8c" -dependencies = [ - "anyhow", - "async-trait", - "bytes", - "chrono", - "clap", - "gearhash", - "http 1.4.2", - "itertools 0.14.0", - "lazy_static", - "more-asserts", - "rand 0.10.2", - "serde", - "serde_json", - "sha2 0.10.9", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", - "url", - "uuid", - "walkdir", - "xet-client", - "xet-core-structures", - "xet-runtime", -] - -[[package]] -name = "xet-runtime" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d8f121c33866f7648b737abe70d0e2dd9c0af4ffdd7219207531d0283aa63d" -dependencies = [ - "anyhow", - "async-trait", - "bytes", - "chrono", - "colored", - "const-str", - "ctor 0.6.3", - "dirs", - "futures", - "git-version", - "humantime", - "konst", - "lazy_static", - "libc", - "more-asserts", - "oneshot", - "pin-project", - "rand 0.10.2", - "reqwest 0.13.4", - "serde", - "serde_json", - "shellexpand", - "sysinfo", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", - "tracing-appender", - "tracing-subscriber", - "whoami 2.1.2", - "winapi", -] - [[package]] name = "xmlparser" version = "0.13.6" @@ -13957,9 +12770,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive 0.8.2", @@ -13974,7 +12787,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", "synstructure", ] @@ -13986,28 +12799,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.53" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.53" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -14027,28 +12840,28 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.5.0" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -14058,7 +12871,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", - "yoke 0.8.3", + "yoke 0.8.2", "zerofrom", "zerovec", ] @@ -14070,7 +12883,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "serde", - "yoke 0.8.3", + "yoke 0.8.2", "zerofrom", "zerovec-derive", ] @@ -14083,7 +12896,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -14098,6 +12911,35 @@ dependencies = [ "flate2", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + +[[package]] +name = "zip" +version = "7.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" +dependencies = [ + "crc32fast", + "indexmap 2.14.0", + "memchr", + "typed-path", +] + [[package]] name = "zip" version = "8.6.0" @@ -14114,9 +12956,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.5" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5431d5661c32445236631278f27946e444ddafe4684cac70b185272d4f9c52d5" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index 3e25ab5025..a2434589a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,8 +8,8 @@ resolver = "2" [workspace.package] edition = "2021" -version = "1.42.0" -rust-version = "1.94.1" +version = "1.37.0" +rust-version = "1.91.1" authors = ["AAIF "] license = "Apache-2.0" repository = "https://github.com/aaif-goose/goose" @@ -21,9 +21,8 @@ string_slice = "warn" [workspace.dependencies] rmcp = { version = "1.4", default-features = false, features = ["schemars", "auth"] } -agent-client-protocol-schema = { version = "1.1", default-features = false, features = ["unstable"] } -agent-client-protocol = { version = "1.0", default-features = false } -agent-client-protocol-http = { version = "1.0", default-features = false, features = ["server", "unstable_cancel_request"] } +agent-client-protocol-schema = { version = "0.12", default-features = false, features = ["unstable"] } +agent-client-protocol = { version = "0.11", default-features = false } arboard = { version = "3", default-features = false } anyhow = { version = "1.0.102", default-features = false, features = ["std"] } async-stream = { version = "0.3.6", default-features = false } @@ -31,11 +30,11 @@ async-trait = { version = "0.1.89", default-features = false } axum = { version = "0.8", default-features = false, features = ["http1", "http2", "json", "tokio", "query"] } base64 = { version = "0.22.1", default-features = false, features = ["std"] } bytes = { version = "1.10.1", default-features = false, features = ["std"] } -candle-core = { version = "0.11", default-features = false } -candle-nn = { version = "0.11", default-features = false } +candle-core = { version = "0.10", default-features = false } +candle-nn = { version = "0.10", default-features = false } chrono = { version = "0.4.44", default-features = false, features = ["serde", "std"] } clap = { version = "4.1.14", default-features = false, features = ["derive", "std", "help", "suggestions", "usage", "color", "error-context"] } -dirs = { version = "6", default-features = false } +dirs = { version = "5", default-features = false } dotenvy = { version = "0.15.7", default-features = false } env-lock = { version = "1", default-features = false } etcetera = { version = "0.11", default-features = false } @@ -46,9 +45,9 @@ ignore = { version = "0.4.12", default-features = false } include_dir = { version = "0.7", default-features = false } indoc = { version = "2", default-features = false } keyring = { version = "3.6.3", default-features = false, features = ["vendored"] } -lru = { version = "0.18", default-features = false } +lru = { version = "0.16", default-features = false } once_cell = { version = "1.21.3", default-features = false, features = ["std"] } -rand = "0.10.1" +rand = { version = "0.8.5", default-features = false, features = ["std"] } regex = { version = "1.12.3", default-features = false, features = ["std"] } reqwest = { version = "0.13.2", default-features = false, features = ["multipart", "form"] } rustls = { version = "0.23.31", default-features = false, features = ["aws_lc_rs", "std"] } @@ -57,20 +56,20 @@ serde = { version = "1.0.228", default-features = false, features = ["derive", " serde_json = { version = "1.0.145", default-features = false, features = ["std"] } serde_yaml = { version = "0.9.32", default-features = false } shellexpand = { version = "3", default-features = false, features = ["base-0", "tilde"] } -strum = { version = "0.28.0", default-features = false, features = ["derive", "std"] } +strum = { version = "0.27.1", default-features = false, features = ["derive", "std"] } tempfile = { version = "3.10.1", default-features = false } -thiserror = { version = "2.0.18", default-features = false } +thiserror = { version = "1.0.49", default-features = false } tokio = { version = "1.48", default-features = false } tokio-stream = { version = "0.1.16", default-features = false } tokio-util = { version = "0.7.12", default-features = false } -tower-http = { version = "0.7.0", default-features = false } +tower-http = { version = "0.6.8", default-features = false } tracing = { version = "0.1.43", default-features = false, features = ["std"] } tracing-appender = { version = "0.2.1", default-features = false } tracing-futures = { version = "0.2.4", default-features = false, features = ["futures-03", "std", "std-future"] } tracing-subscriber = { version = "0.3.22", default-features = false, features = ["std"] } urlencoding = { version = "2.1", default-features = false } utoipa = { version = "4.2", default-features = false } -uuid = { version = "1.23", default-features = false, features = ["v4", "std"] } +uuid = { version = "1.18", default-features = false, features = ["v4", "std"] } webbrowser = { version = "1", default-features = false } which = { version = "8", default-features = false, features = ["real-sys"] } winapi = { version = "0.3.9", default-features = false, features = ["wincred", "std"] } @@ -114,12 +113,3 @@ icu_locale = { version = "=2.1.1", default-features = false } [patch.crates-io] v8 = { path = "vendor/v8" } cudaforge = { git = "https://github.com/jbg/cudaforge", rev = "e7c1967340e40673db98dc9e17da0f04834a456f" } - -# Dependencies don't need debug info. You rarely step-debug into third-party -# crates, but their DWARF dominates `target/debug` โ€” roughly 50-70% of each -# dependency rlib in this workspace is debug info. Dropping it for dependencies -# only shrinks the debug build substantially and speeds linking. The "*" spec -# matches all dependencies but NOT workspace members, so first-party crates keep -# full debug info and stay fully debuggable; release builds are unaffected. -[profile.dev.package."*"] -debug = false diff --git a/I18N.md b/I18N.md index c07792efc8..274ed4fd72 100644 --- a/I18N.md +++ b/I18N.md @@ -100,15 +100,11 @@ Compiled files go to `src/i18n/compiled/` (gitignored). The locale is resolved at startup in the following order: -1. **Desktop language setting** โ€” selected in Settings, unless set to System Default -2. **`GOOSE_LOCALE`** โ€” explicit override from env/app config when the desktop setting is - System Default -3. **`navigator.language`** โ€” the browser/OS locale -4. **`"en"`** โ€” fallback default +1. **`GOOSE_LOCALE`** โ€” explicit override (set on the `window` object or via env) +2. **`navigator.language`** โ€” the browser/OS locale +3. **`"en"`** โ€” fallback default -The resolved locale is used for both text translations and all Intl formatting (dates, numbers, -relative times). Changing the desktop language setting reloads the renderer so startup-only locale -helpers pick up the new value. +The resolved locale is used for both text translations and all Intl formatting (dates, numbers, relative times). ## Date and number formatting @@ -138,8 +134,7 @@ This ensures date/number formatting uses the same locale as the rest of the UI. 1. Copy `src/i18n/messages/en.json` to a new file, e.g., `src/i18n/messages/ja.json`. 2. Translate the `defaultMessage` values. Keep ICU syntax intact (e.g., `{count, plural, ...}`). 3. Add the locale code to `SUPPORTED_LOCALES` in `src/i18n/index.ts`. -4. Add the locale to the language selector in `src/components/settings/app/AppSettingsSection.tsx`. -5. Optionally run `pnpm i18n:compile` to pre-compile. +4. Optionally run `pnpm i18n:compile` to pre-compile. No other code changes are needed โ€” `loadMessages()` dynamically imports the correct catalog at runtime. diff --git a/Justfile b/Justfile index be971a398c..8e8f6e49f3 100644 --- a/Justfile +++ b/Justfile @@ -13,14 +13,18 @@ check-everything: cargo clippy --all-targets -- -D warnings @echo " โ†’ Checking UI code formatting..." cd ui/desktop && pnpm run lint:check + @echo " โ†’ Validating OpenAPI schema..." + ./scripts/check-openapi-schema.sh @echo "" @echo "โœ… All style checks passed!" # Default release command release-binary: @echo "Building release version..." - cargo build --release -p goose-cli --bin goose + cargo build --release @just copy-binary + @echo "Generating OpenAPI schema..." + cargo run -p goose-server --bin generate_schema # Build Windows executable on a Windows host [unix] @@ -30,7 +34,7 @@ release-windows: [windows] release-windows: - @powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'rustup target add x86_64-pc-windows-msvc; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; cargo build --release --target x86_64-pc-windows-msvc -p goose-cli --bin goose; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; Write-Host "Windows executable created at ./target/x86_64-pc-windows-msvc/release/goose.exe"' + @powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'rustup target add x86_64-pc-windows-msvc; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; cargo build --release --target x86_64-pc-windows-msvc -p goose-server; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; Write-Host "Windows executable created at ./target/x86_64-pc-windows-msvc/release/goosed.exe"' # Build for Intel Mac release-intel: @@ -39,10 +43,15 @@ release-intel: @just copy-binary-intel copy-binary BUILD_MODE="release": - @rm -f ./ui/desktop/src/bin/goosed + @if [ -f ./target/{{BUILD_MODE}}/goosed ]; then \ + echo "Copying goosed binary from target/{{BUILD_MODE}}..."; \ + cp -p ./target/{{BUILD_MODE}}/goosed ./ui/desktop/src/bin/; \ + else \ + echo "Binary not found in target/{{BUILD_MODE}}"; \ + exit 1; \ + fi @if [ -f ./target/{{BUILD_MODE}}/goose ]; then \ echo "Copying goose CLI binary from target/{{BUILD_MODE}}..."; \ - rm -f ./ui/desktop/src/bin/goose; \ cp -p ./target/{{BUILD_MODE}}/goose ./ui/desktop/src/bin/; \ else \ echo "goose CLI binary not found in target/{{BUILD_MODE}}"; \ @@ -51,10 +60,15 @@ copy-binary BUILD_MODE="release": # Copy binary command for Intel build copy-binary-intel: - @rm -f ./ui/desktop/src/bin/goosed + @if [ -f ./target/x86_64-apple-darwin/release/goosed ]; then \ + echo "Copying Intel goosed binary to ui/desktop/src/bin with permissions preserved..."; \ + cp -p ./target/x86_64-apple-darwin/release/goosed ./ui/desktop/src/bin/; \ + else \ + echo "Intel release binary not found."; \ + exit 1; \ + fi @if [ -f ./target/x86_64-apple-darwin/release/goose ]; then \ echo "Copying Intel goose CLI binary to ui/desktop/src/bin..."; \ - rm -f ./ui/desktop/src/bin/goose; \ cp -p ./target/x86_64-apple-darwin/release/goose ./ui/desktop/src/bin/; \ else \ echo "Intel goose CLI binary not found."; \ @@ -69,11 +83,10 @@ copy-binary-windows: [windows] copy-binary-windows: - @powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'if (Test-Path ./target/x86_64-pc-windows-msvc/release/goose.exe) { \ + @powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'if (Test-Path ./target/x86_64-pc-windows-msvc/release/goosed.exe) { \ Write-Host "Copying Windows binary to ui/desktop/src/bin..."; \ New-Item -ItemType Directory -Force "./ui/desktop/src/bin" | Out-Null; \ - Remove-Item -Path "./ui/desktop/src/bin/goosed.exe" -Force -ErrorAction SilentlyContinue; \ - Copy-Item -Path "./target/x86_64-pc-windows-msvc/release/goose.exe" -Destination "./ui/desktop/src/bin/" -Force; \ + Copy-Item -Path "./target/x86_64-pc-windows-msvc/release/goosed.exe" -Destination "./ui/desktop/src/bin/" -Force; \ } else { \ Write-Host "Windows binary not found." -ForegroundColor Red; \ exit 1; \ @@ -99,7 +112,7 @@ run-ui-only: cd ui/desktop && pnpm install && pnpm run start-gui debug-ui: - @echo "๐Ÿš€ Starting goose frontend in external ACP backend mode" + @echo "๐Ÿš€ Starting goose frontend in external backend mode" cd ui/desktop && \ export GOOSE_EXTERNAL_BACKEND=true && \ export GOOSE_SERVER__SECRET_KEY="${GOOSE_SERVER__SECRET_KEY:-test}" && \ @@ -144,8 +157,19 @@ run-docs: # Run server run-server: - @echo "Running external ACP backend..." - GOOSE_SERVER__SECRET_KEY="${GOOSE_SERVER__SECRET_KEY:-test}" cargo run -p goose-cli --bin goose -- serve --platform desktop --host 127.0.0.1 --port 3000 + @echo "Running server..." + cargo run -p goose-server --bin goosed agent + +# Check if OpenAPI schema is up-to-date +check-openapi-schema: generate-openapi + ./scripts/check-openapi-schema.sh + +# Generate OpenAPI specification without starting the UI +generate-openapi: + @echo "Generating OpenAPI schema..." + cargo run -p goose-server --bin generate_schema + @echo "Generating frontend API..." + cd ui/desktop && npx @hey-api/openapi-ts # Check if generated ACP schema and TypeScript types are up-to-date check-acp-schema: generate-acp-types @@ -290,6 +314,7 @@ bump-version version: @cd ui/desktop && npm pkg set "version={{ version }}" # update Cargo.lock after bumping versions in Cargo.toml @cargo update --workspace + @just set-openapi-version {{ version }} # rebuild canonical model registry and mapping report from models.dev build-canonical-models: @@ -304,10 +329,14 @@ prepare-release version: Cargo.lock \ ui/desktop/package.json \ ui/pnpm-lock.yaml \ - crates/goose-provider-types/src/canonical/data/canonical_models.json \ - crates/goose-provider-types/src/canonical/data/provider_metadata.json + ui/desktop/openapi.json \ + crates/goose/src/providers/canonical/data/canonical_models.json \ + crates/goose/src/providers/canonical/data/provider_metadata.json @git commit --message "chore(release): release version {{ version }}" +set-openapi-version version: + @jq '.info.version |= "{{ version }}"' ui/desktop/openapi.json > ui/desktop/openapi.json.tmp && mv ui/desktop/openapi.json.tmp ui/desktop/openapi.json + # extract version from Cargo.toml get-tag-version: @uvx --from=toml-cli toml get --toml-path=Cargo.toml "workspace.package.version" @@ -342,6 +371,7 @@ set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] ### profile = --release or "" for debug ### allparam = OR/AND/ANY/NONE --workspace --all-features --all-targets win-bld profile allparam: + cargo run {{profile}} -p goose-server --bin generate_schema cargo build {{profile}} {{allparam}} ### Build just debug @@ -370,7 +400,6 @@ win-app-deps: win-copy-win profile: copy target{{s}}{{profile}}{{s}}*.exe ui{{s}}desktop{{s}}src{{s}}bin copy target{{s}}{{profile}}{{s}}*.dll ui{{s}}desktop{{s}}src{{s}}bin - if exist ui{{s}}desktop{{s}}src{{s}}bin{{s}}goosed.exe del /f /q ui{{s}}desktop{{s}}src{{s}}bin{{s}}goosed.exe ### "Other" copy {release|debug} files to ui/desktop/src/bin ### s = os dependent file separator diff --git a/README.md b/README.md index f6f0522594..e6c4f05608 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +> **๐Ÿฆ† goose has moved!** This project has moved from `block/goose` to the [Agentic AI Foundation (AAIF)](https://aaif.io/) at the Linux Foundation. Some links and references are still being updated โ€” please bear with us during the transition. +
# goose @@ -14,12 +16,8 @@ _your native open source AI agent โ€” desktop app, CLI, and API โ€” for code, wo Packaging status

- -aaif-goose%2Fgoose | Trendshift -
- goose is a general-purpose AI agent that runs on your machine. Not just for code โ€” use it for research, writing, automation, data analysis, or anything you need to get done. A native desktop app for macOS, Linux, and Windows. A full CLI for terminal workflows. An API to embed it anywhere. Built in Rust for performance and portability. diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index cb87c076c6..d25d7c00bc 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -1,23 +1,134 @@ # goose Release Manual Testing Checklist -Download the release builds from this PR. Once a build is ready, the actions bot will post a comment on this PR -with instructions on how to download and sign. +## Version: {{VERSION}} + +### Identify the high risk changes in this Release -## Use the following script to create a risk assessment and testing plan: ``` ./workflow_recipes/release_risk_check/run.sh {{VERSION}} ``` -It will generate an analysis report in `/tmp/release_report_final.md` and perform testing is necessary for high risk pr changes. +It will generate an analysis report in `/tmp/release_report_final.md` and perform testing is necessary for high risk pr changes. -## Run the goose self-test recipe -goose run --recipe goose-self-test.yaml +## Regression Testing -## Have goose produce a test plan +Make a copy of this document for each version and check off as steps are verified. -Open the release candidate desktop app and have goose produce a test plan by pointing it at this PR. Use a prompt like +### Provider Testing -> Look at the notes in PR and the report at `/tmp/release_report_final.md` and investigate potential risks in this release. After familiarizing yourself with the scope of each change, produce a suggested test plan that I should follow before publishing the release. +- [ ] 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 -goose will produce a plan. Follow this plan to finish testing. +### Starting Conversations + +Test various ways to start a conversation: + +- [ ] Open home and start a new conversation with "Hello" + - [ ] Agent responds + - [ ] Token count is updated after agent finishes + - [ ] Go to history and see there is a new entry +- [ ] Go back to the main screen, start a new conversation from the hub and see that it opens a new conversation +- [ ] Open history and click the Hello conversation - verify it loads +- [ ] Add a new message to this conversation and see that it is added +- [ ] Change the working directory of an existing conversation + - [ ] Ask "what is your working directory?" + - [ ] Response should match the new directory +- [ ] Open a new window, click chat in left side for new chat +- [ ] Type "create a tamagotchi game" in the chat input to test developer extension + +### Recipes + +#### Create Recipe from Session + +- [ ] Start a simple chat conversation like "hi" +- [ ] Click "create a recipe from this session" in the bottom chat bar + - [ ] Recipe title, description and instructions should be filled in with details from the chat + - [ ] Add a few activities and params (params unused indicator should update if added to instructions/prompts or activities) + - [ ] Can launch create and run recipe - launches in a new window showing as a recipe agent chat with parameters filled in and interact with it + - [ ] Recipe should be saved in recipe library + +#### Use Existing Recipe + +- [ ] Pick trip planner from recipe hub (go/gooserecipes) + - [ ] See the warning whether to trust this recipe (only on fresh install) + - [ ] See the form pop up + - [ ] Fill in the form with "Africa" and "14 days" + - [ ] Check results are reasonable + - [ ] Ask how many days the trip is for - should say 14 + +#### Recipe Management + +- [ ] Go to recipe manager and enter a new recipe to generate a joke + - [ ] See that it works if you run it + - [ ] Edit the recipe by bottom bar and click "View/Edit Recipe" + - [ ] Make it generate a limerick instead + - [ ] Check that the updated recipe works + - [ ] Delete the recipe from the recipe manager + - [ ] Verify recipe is actually deleted + +#### Recipe from File + +- [ ] Create a file `~/.config/goose/recipes/test-recipe.yaml` with the following content: + +```yaml +recipe: + title: test recipe again + description: testing recipe again + instructions: The value of test_param is {{test_param}} + prompt: What is the value of test_param? + parameters: + - key: test_param + input_type: string + requirement: required + description: Enter value for test_param +``` + +- [ ] See that it shows up in the list of installed recipes +- [ ] Launch the recipe, see that it asks for test_param +- [ ] Enter a number, see that it pre-fills the prompt and tells you the value after you hit submit +- [ ] Go to hub and enter "what is the value of test_param" +- [ ] See a new chat that says it has no idea (recipe is no longer active) + +### Extensions + +#### Manual Extension Addition + +- [ ] Can manually add an extension using random quotes from project + - [ ] Add new custom stdio extension with the following command and save: + - [ ] `node /ABSOLUTE/PATH/TO/goose/ui/desktop/tests/e2e/basic-mcp.ts` (use your actual project path) + - [ ] Should add and can chat to ask for a random quote + +#### Playwright Extension + +- [ ] Install the playwright extension from the extensions hub + - [ ] Tell it to open a browser and search on Google for cats + - [ ] Verify that the browser opens and navigates + +#### Extension with Environment Variables + +- [ ] Install an extension from deeplink that needs env variables: + - [ ] Use: `goose://extension?cmd=npx&arg=-y&arg=%40upstash%2Fcontext7-mcp&id=context7&name=Context7&description=Use%20up-to-date%20code%20and%20docs&env=TEST_ACCESS_TOKEN` + - [ ] Extension page should load with env variables modal showing + - [ ] Allow form input and saving extension + +### Speech-to-Text (Local Model) + +- [ ] Go to Settings > Chat > Voice dictation provider and select the small model +- [ ] Run a quick test that speech-to-text is working (click the mic button, speak, verify transcription) +- [ ] Also try OpenAI using your OpenAI key + +### Settings + +- [ ] Settings page loads and all tabs load +- [ ] Can change dark mode setting + +### Follow-up Issues + +Link any GitHub issues filed during testing: + +--- + +**Tested by:** _____ +**Date:** _____ +**Notes:** _____ diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index e47f98bf55..7dadbd32c8 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -20,10 +20,9 @@ name = "generate_manpages" path = "src/bin/generate_manpages.rs" [dependencies] -clap_mangen = { version = "0.3", default-features = false } +clap_mangen = { version = "0.2", default-features = false } goose = { path = "../goose", default-features = false } goose-mcp = { path = "../goose-mcp", default-features = false } -goose-providers = { path = "../goose-providers", default-features = false } rmcp = { workspace = true } clap = { workspace = true } cliclack = { version = "0.5", default-features = false } @@ -44,7 +43,7 @@ rustyline = { version = "18", default-features = false, features = ["custom-bind tracing = { workspace = true } chrono = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json", "time"] } -shlex = { version = "2.0", default-features = false, features = ["std"] } +shlex = { version = "1.3", default-features = false, features = ["std"] } async-trait = { workspace = true } base64 = { workspace = true } regex = { workspace = true } @@ -62,9 +61,8 @@ urlencoding = { workspace = true } clap_complete = { version = "4", default-features = false } comfy-table = { version = "7", default-features = false } sha2 = { workspace = true } -sigstore-verify = { version = "0.10", default-features = false, optional = true } +sigstore-verify = { version = "0.8", default-features = false, optional = true } axum.workspace = true -axum-server = { version = "0.8", default-features = false, optional = true } clap_complete_nushell = { version = "4", default-features = false } [target.'cfg(target_os = "windows")'.dependencies] @@ -89,7 +87,6 @@ local-inference = ["goose/local-inference"] aws-providers = ["goose/aws-providers"] cuda = ["goose/cuda", "local-inference"] vulkan = ["goose/vulkan", "local-inference"] -mlx = ["goose/mlx", "local-inference"] telemetry = ["goose/telemetry"] nostr = ["goose/nostr"] otel = ["goose/otel"] @@ -100,22 +97,16 @@ portable-default = ["rustls-tls", "aws-providers", "telemetry", "otel", "tui"] # disables the update command disable-update = [] rustls-tls = [ - "dep:axum-server", "reqwest/rustls", - "axum-server/tls-rustls", "sigstore-verify?/rustls", "goose/rustls-tls", "goose-mcp/rustls-tls", - "goose-providers/rustls-tls", ] native-tls = [ - "dep:axum-server", "reqwest/native-tls", - "axum-server/tls-openssl", "sigstore-verify?/native-tls", "goose/native-tls", "goose-mcp/native-tls", - "goose-providers/native-tls", ] [dev-dependencies] diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 427dd80351..aac8b77c3b 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -43,30 +43,14 @@ use tracing::warn; const GOOSE_SERVER_SECRET_KEY_ENV: &str = "GOOSE_SERVER__SECRET_KEY"; fn generate_serve_secret_key() -> String { - use rand::distr::{Alphanumeric, SampleString}; + use rand::distributions::{Alphanumeric, DistString}; format!( "goose-acp-{}", - Alphanumeric.sample_string(&mut rand::rng(), 32) + Alphanumeric.sample_string(&mut rand::thread_rng(), 32) ) } -#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)] -enum ServePlatform { - #[default] - Cli, - Desktop, -} - -impl From for GoosePlatform { - fn from(platform: ServePlatform) -> Self { - match platform { - ServePlatform::Cli => GoosePlatform::GooseCli, - ServePlatform::Desktop => GoosePlatform::GooseDesktop, - } - } -} - #[derive(Parser)] #[command(name = "goose", author, version, display_name = "", about, long_about = None)] pub struct Cli { @@ -91,7 +75,7 @@ pub struct Identifier { alias = "id", value_name = "SESSION_ID", help = "Session ID (e.g., '20250921_143022')", - long_help = "Specify a session ID to resume. Requires --resume." + long_help = "Specify a session ID directly. When used with --resume, will resume this specific session if it exists." )] pub session_id: Option, @@ -376,13 +360,6 @@ pub struct RunBehavior { )] pub resume: bool, - /// Print generation statistics after completion - #[arg( - long = "stats", - help = "Print generation statistics after the run completes" - )] - pub stats: bool, - /// Scheduled job ID (used internally for scheduled executions) #[arg( long = "scheduled-job-id", @@ -408,9 +385,7 @@ async fn get_or_create_session_id( let resolved_id = if resume { let Some(id) = identifier else { - let sessions = session_manager - .list_sessions_by_types(&[SessionType::User]) - .await?; + let sessions = session_manager.list_sessions().await?; let session_id = sessions .first() .map(|s| s.id.clone()) @@ -584,13 +559,9 @@ enum SessionCommand { )] relays: Vec, }, - #[command( - about = "Import a session from JSON, a Claude Code / Codex / Pi .jsonl, or an encrypted Nostr share link" - )] + #[command(about = "Import a session from JSON or an encrypted Nostr share link")] Import { - #[arg( - help = "Path to a goose session export, a Claude Code, Codex, or Pi .jsonl transcript, or a goose://sessions/nostr share link" - )] + #[arg(help = "Path to a JSON session export, or a goose://sessions/nostr share link")] input: String, #[arg(long = "nostr", help = "Treat input as an encrypted Nostr share link")] @@ -598,9 +569,11 @@ enum SessionCommand { }, #[command(name = "diagnostics")] Diagnostics { + /// Session identifier for generating diagnostics #[command(flatten)] identifier: Option, + /// Output path for the diagnostics zip file (optional, defaults to current directory) #[arg(short = 'o', long)] output: Option, }, @@ -849,18 +822,6 @@ enum Command { #[arg(long, default_value = "3284")] port: u16, - #[arg(long, help = "Serve ACP over TLS")] - tls: bool, - - #[arg(long = "tls-cert-path", value_name = "PATH")] - tls_cert_path: Option, - - #[arg(long = "tls-key-path", value_name = "PATH")] - tls_key_path: Option, - - #[arg(long, value_enum, default_value_t = ServePlatform::Cli)] - platform: ServePlatform, - #[arg( long = "with-builtin", value_name = "NAME", @@ -870,20 +831,6 @@ enum Command { action = clap::ArgAction::Append )] builtins: Vec, - - #[arg( - long = "dangerously-unauthenticated", - help = "Start the ACP endpoint without requiring GOOSE_SERVER__SECRET_KEY" - )] - dangerously_unauthenticated: bool, - - #[arg( - long = "allowed-origin", - value_name = "ORIGIN", - action = clap::ArgAction::Append, - help = "Allow an exact Origin value for ACP CORS; may be specified multiple times and replaces the default loopback origins" - )] - allowed_origins: Vec, }, /// Start or resume interactive chat sessions @@ -916,15 +863,6 @@ enum Command { )] fork: bool, - /// Open the session's conversation in $EDITOR before starting - #[arg( - long, - requires = "resume", - help = "Edit the session conversation in $EDITOR before starting", - long_help = "Open the session's conversation in your editor ($VISUAL / $EDITOR / vi) for modification before resuming. When combined with --fork, creates a new session from the edited result." - )] - edit: bool, - /// Show message history when resuming #[arg( long, @@ -1120,9 +1058,8 @@ enum Command { #[arg(long = "override-model", value_name = "MODEL")] override_model: Option, - /// Default `turn-limit` for orchestrated main-pass subprocesses and - /// for checks that do not declare their own. Does not cap the legacy - /// `--no-orchestrate` in-process main agent. + /// Default `turn-limit` applied to checks that do not declare their + /// own. #[arg(long = "turn-limit", value_name = "N")] turn_limit: Option, @@ -1186,6 +1123,7 @@ enum Command { #[arg(long = "severity", value_name = "LEVEL", default_value = "medium")] severity: String, }, + #[command( name = "validate-extensions", about = "Validate a bundled-extensions.json file", @@ -1200,8 +1138,8 @@ enum Command { #[cfg(feature = "local-inference")] #[derive(Subcommand)] enum LocalModelsCommand { - /// Search HuggingFace for local models - #[command(about = "Search HuggingFace for local GGUF and MLX models")] + /// Search HuggingFace for GGUF models + #[command(about = "Search HuggingFace for GGUF models")] Search { /// Search query query: String, @@ -1212,9 +1150,9 @@ enum LocalModelsCommand { }, /// Download a model from HuggingFace - #[command(about = "Download a local model from a search result")] + #[command(about = "Download a GGUF model (e.g. bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M)")] Download { - /// Model spec/download id, e.g. user/repo:Q4_K_M or user/repo + /// Model spec in user/repo:quantization format spec: String, }, @@ -1377,39 +1315,13 @@ async fn handle_mcp_command(server: McpCommand) -> Result<()> { Ok(()) } -struct ServeCommandArgs { - host: String, - - port: u16, - tls: bool, - tls_cert_path: Option, - tls_key_path: Option, - platform: ServePlatform, - builtins: Vec, - dangerously_unauthenticated: bool, - allowed_origins: Vec, -} - -async fn handle_serve_command(args: ServeCommandArgs) -> Result<()> { - use axum::http::HeaderValue; +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 std::net::SocketAddr; use std::sync::Arc; - use tracing::{info, warn}; - - let ServeCommandArgs { - host, - port, - tls, - tls_cert_path, - tls_key_path, - platform, - builtins, - dangerously_unauthenticated, - allowed_origins, - } = args; + use tracing::info; let builtins = if builtins.is_empty() { vec!["developer".to_string()] @@ -1433,93 +1345,25 @@ async fn handle_serve_command(args: ServeCommandArgs) -> Result<()> { builtins, data_dir: Paths::data_dir(), config_dir: Paths::config_dir(), - goose_platform: platform.into(), + goose_platform: GoosePlatform::GooseCli, additional_source_roots, })); - let env_secret = std::env::var(GOOSE_SERVER_SECRET_KEY_ENV) + let secret_key = std::env::var(GOOSE_SERVER_SECRET_KEY_ENV) .ok() .map(|secret| secret.trim().to_string()) - .filter(|secret| !secret.is_empty()); - let require_token = env_secret.is_some(); - if !require_token && !dangerously_unauthenticated { - anyhow::bail!( - "{GOOSE_SERVER_SECRET_KEY_ENV} must be set to start `goose serve`; pass --dangerously-unauthenticated to run without ACP authentication" - ); - } - if dangerously_unauthenticated && !require_token { - warn!( - "{GOOSE_SERVER_SECRET_KEY_ENV} is not set and --dangerously-unauthenticated was passed; the ACP endpoint will accept unauthenticated connections" - ); - } - let additional_allowed_origins = allowed_origins - .into_iter() - .map(|origin| { - let origin = origin.trim(); - if origin.is_empty() || origin == "*" { - anyhow::bail!("--allowed-origin must be a non-wildcard Origin value"); - } - HeaderValue::from_str(origin).map_err(|error| { - anyhow::anyhow!("invalid --allowed-origin value `{origin}`: {error}") - }) - }) - .collect::>>()?; - let secret_key = env_secret.unwrap_or_else(generate_serve_secret_key); - let router = create_router( - server, - secret_key, - require_token, - additional_allowed_origins, - ); - - let config = Config::global(); - let tls_cert_path = - tls_cert_path.or_else(|| config.get_param::("GOOSE_TLS_CERT_PATH").ok()); - let tls_key_path = - tls_key_path.or_else(|| config.get_param::("GOOSE_TLS_KEY_PATH").ok()); - let tls = tls - || config.get_param::("GOOSE_TLS").unwrap_or(false) - || tls_cert_path.is_some() - || tls_key_path.is_some(); + .filter(|secret| !secret.is_empty()) + .unwrap_or_else(generate_serve_secret_key); + let router = create_router(server, secret_key); let addr: SocketAddr = format!("{}:{}", host, port).parse()?; - if tls { - #[cfg(any(feature = "rustls-tls", feature = "native-tls"))] - { - let tls_setup = goose::acp::transport::tls::setup_tls( - tls_cert_path.as_deref(), - tls_key_path.as_deref(), - ) - .await?; - info!("Starting ACP server on https://{}", addr); + info!("Starting ACP server on {}", addr); - #[cfg(feature = "rustls-tls")] - axum_server::bind_rustls(addr, tls_setup.config) - .serve(router.into_make_service_with_connect_info::()) - .await?; - - #[cfg(feature = "native-tls")] - axum_server::bind_openssl(addr, tls_setup.config) - .serve(router.into_make_service_with_connect_info::()) - .await?; - } - - #[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))] - { - let _ = (tls_cert_path, tls_key_path); - anyhow::bail!( - "TLS was requested but no TLS backend is enabled. \ - Enable the `rustls-tls` or `native-tls` feature." - ); - } - } else { - info!("Starting ACP server on http://{}", addr); - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve( - listener, - router.into_make_service_with_connect_info::(), - ) - .await?; - } + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .await?; Ok(()) } @@ -1604,7 +1448,6 @@ async fn handle_interactive_session( identifier: Option, resume: bool, fork: bool, - edit: bool, history: bool, session_opts: SessionOptions, extension_opts: ExtensionOptions, @@ -1644,31 +1487,12 @@ async fn handle_interactive_session( let goose_mode = Config::global().get_goose_mode().unwrap_or_default(); let mut session_id = get_or_create_session_id(identifier, resume, false, goose_mode).await?; - if edit || fork { - if let Some(ref id) = session_id { + if fork { + if let Some(id) = session_id { let session_manager = SessionManager::instance(); - let original = session_manager.get_session(id, true).await?; - - let target_id = if fork { - let copied = session_manager - .copy_session(id, original.name.clone()) - .await?; - let copied_id = copied.id.clone(); - session_id = Some(copied.id); - copied_id - } else { - id.clone() - }; - - if edit { - let conversation = original - .conversation - .ok_or_else(|| anyhow::anyhow!("session has no messages to edit"))?; - let edited = crate::session::editor::edit_conversation(&conversation)?; - session_manager - .replace_conversation(&target_id, &edited) - .await?; - } + let original = session_manager.get_session(&id, false).await?; + let copied = session_manager.copy_session(&id, original.name).await?; + session_id = Some(copied.id); } } @@ -1693,7 +1517,6 @@ async fn handle_interactive_session( quiet: false, output_format: "text".to_string(), container: session_opts.container.map(Container::new), - stats: false, }) .await; @@ -1718,7 +1541,7 @@ async fn log_session_completion( let (total_tokens, message_count) = session .get_session() .await - .map(|m| (m.usage.total_tokens.unwrap_or(0), m.message_count)) + .map(|m| (m.total_tokens.unwrap_or(0), m.message_count)) .unwrap_or((0, 0)); tracing::info!( @@ -1906,7 +1729,6 @@ async fn handle_run_command( quiet: output_opts.quiet, output_format: output_opts.output_format, container: session_opts.container.map(Container::new), - stats: run_behavior.stats, }) .await; @@ -2014,40 +1836,20 @@ async fn handle_term_subcommand(command: TermCommand) -> Result<()> { } } -#[cfg(feature = "local-inference")] -fn print_download_progress(manager: &goose::download_manager::DownloadManager) { - let Some(progress) = manager - .list_progress() - .into_iter() - .find(|progress| progress.status == goose::download_manager::DownloadStatus::Downloading) - else { - return; - }; - - print!( - "\r {:.1}% ({:.0}MB / {:.0}MB)", - progress.progress_percent, - progress.bytes_downloaded as f64 / (1024.0 * 1024.0), - progress.total_bytes as f64 / (1024.0 * 1024.0), - ); - use std::io::Write; - std::io::stdout().flush().ok(); -} - #[cfg(feature = "local-inference")] async fn handle_local_models_command(command: LocalModelsCommand) -> Result<()> { use goose::providers::local_inference::hf_models; - use goose::providers::local_inference::local_model_registry::get_registry; - - goose::providers::local_inference::configure_huggingface_auth(); + use goose::providers::local_inference::local_model_registry::{ + get_registry, mmproj_local_path, model_id_from_repo, LocalModelEntry, + }; match command { LocalModelsCommand::Search { query, limit } => { println!("Searching HuggingFace for '{}'...", query); - let results = hf_models::search_local_models(&query, limit).await?; + let results = hf_models::search_gguf_models(&query, limit).await?; if results.is_empty() { - println!("No compatible local models found."); + println!("No GGUF models found."); return Ok(()); } @@ -2056,68 +1858,126 @@ async fn handle_local_models_command(command: LocalModelsCommand) -> Result<()> "\n{} (by {}) โ€” {} downloads", model.model_name, model.author, model.downloads ); - for variant in &model.variants { - let size = if variant.size_bytes > 0 { + for file in &model.gguf_files { + let size = if file.size_bytes > 0 { format!( "{:.1}GB", - variant.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0) + file.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0) ) } else { "unknown".to_string() }; - let support = if variant.supported { - String::new() - } else { - format!( - " ({})", - variant - .unsupported_reason - .as_deref() - .unwrap_or("unsupported on this platform") - ) - }; - println!( - " [{}] {} โ€” {} โ€” {}{}", - variant.format, variant.label, size, variant.description, support - ); - if variant.supported { - println!( - " Download: goose local-models download '{}'", - variant.download_id - ); - } + println!(" {} โ€” {}", file.quantization, size); } + println!( + " Download: goose local-models download {}:", + model.repo_id + ); } } LocalModelsCommand::Download { spec } => { println!("Resolving {}...", spec); - let manager = goose::download_manager::get_download_manager(); - let resolve_task = hf_models::resolve_local_model_spec(&spec); - tokio::pin!(resolve_task); - let resolved = loop { - tokio::select! { - result = &mut resolve_task => break result?, - _ = tokio::time::sleep(std::time::Duration::from_millis(500)) => { - print_download_progress(manager); - } - } - }; - let model_id = resolved.model_id(); - let total_size = resolved.total_size(); + let (repo_id, resolved) = hf_models::resolve_model_spec_full(&spec).await?; + if resolved.files.len() > 1 { + anyhow::bail!( + "Model '{}' is sharded ({} files) โ€” download it from the desktop UI", + spec, + resolved.files.len() + ); + } + let mmproj = resolved.mmproj; + let file = resolved.files.into_iter().next().unwrap(); + let model_id = model_id_from_repo(&repo_id, &file.quantization); + let local_path = + goose::config::paths::Paths::in_data_dir("models").join(&file.filename); + let mmproj_path = mmproj + .as_ref() + .map(|mmproj| mmproj_local_path(&repo_id, &mmproj.filename)); + let mmproj_source_url = mmproj.as_ref().map(|mmproj| mmproj.download_url.clone()); + let mmproj_size_bytes = mmproj.as_ref().map_or(0, |mmproj| mmproj.size_bytes); + let mut download_files = vec![(file.download_url.clone(), local_path.clone())]; + if let Some(mmproj) = mmproj { + download_files.push((mmproj.download_url, mmproj_path.clone().unwrap())); + } println!( - "\nDownloaded {} ({}). Registering...", + "Downloading {} ({})...", model_id, - if total_size > 0 { - format!("{:.1}GB", total_size as f64 / (1024.0 * 1024.0 * 1024.0)) + if file.size_bytes > 0 { + format!( + "{:.1}GB", + file.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0) + ) } else { "unknown size".to_string() } ); - let model_id = hf_models::register_resolved_model(resolved, &spec)?; + // Register + let entry = LocalModelEntry { + id: model_id.clone(), + repo_id: repo_id.clone(), + filename: file.filename.clone(), + quantization: file.quantization.clone(), + local_path: local_path.clone(), + source_url: file.download_url.clone(), + settings: Default::default(), + size_bytes: file.size_bytes, + mmproj_path, + mmproj_source_url, + mmproj_size_bytes, + mmproj_checked: true, + shard_files: vec![], + }; - println!("Registered: {}", model_id); + { + let mut registry = get_registry() + .lock() + .map_err(|_| anyhow::anyhow!("Failed to acquire registry lock"))?; + registry.add_model(entry)?; + } + + // Download + let manager = goose::download_manager::get_download_manager(); + manager + .download_model_sharded( + format!("{}-model", model_id), + download_files, + file.size_bytes + mmproj_size_bytes, + None, + ) + .await?; + + // Poll progress + loop { + if let Some(progress) = manager.get_progress(&format!("{}-model", model_id)) { + match progress.status { + goose::download_manager::DownloadStatus::Downloading => { + print!( + "\r {:.1}% ({:.0}MB / {:.0}MB)", + progress.progress_percent, + progress.bytes_downloaded as f64 / (1024.0 * 1024.0), + progress.total_bytes as f64 / (1024.0 * 1024.0), + ); + use std::io::Write; + std::io::stdout().flush().ok(); + } + goose::download_manager::DownloadStatus::Completed => { + println!("\nDownloaded: {}", model_id); + break; + } + goose::download_manager::DownloadStatus::Failed => { + let err = progress.error.unwrap_or_default(); + anyhow::bail!("Download failed: {}", err); + } + goose::download_manager::DownloadStatus::Cancelled => { + println!("\nDownload cancelled."); + break; + } + } + } + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } } LocalModelsCommand::List => { let registry = get_registry() @@ -2130,16 +1990,12 @@ async fn handle_local_models_command(command: LocalModelsCommand) -> Result<()> return Ok(()); } - println!( - "{:<50} {:<10} {:<12} Downloaded", - "ID", "Backend", "Variant" - ); - println!("{}", "-".repeat(88)); + println!("{:<50} {:<10} Downloaded", "ID", "Quant"); + println!("{}", "-".repeat(70)); for m in models { println!( - "{:<50} {:<10} {:<12} {}", + "{:<50} {:<10} {}", m.id, - m.backend_id.as_deref().unwrap_or("llamacpp"), m.quantization, if m.is_downloaded() { "โœ“" } else { "โœ—" } ); @@ -2150,8 +2006,11 @@ async fn handle_local_models_command(command: LocalModelsCommand) -> Result<()> .lock() .map_err(|_| anyhow::anyhow!("Failed to acquire registry lock"))?; - if registry.get_model(&id).is_some() { - registry.delete_model(&id)?; + if let Some(entry) = registry.get_model(&id) { + if entry.local_path.exists() { + std::fs::remove_file(&entry.local_path)?; + } + registry.remove_model(&id)?; println!("Deleted model: {}", id); } else { println!("Model not found: {}", id); @@ -2196,7 +2055,6 @@ async fn handle_default_session() -> Result<()> { quiet: false, output_format: "text".to_string(), container: None, - stats: false, }) .await; session.interactive(None).await @@ -2232,27 +2090,8 @@ pub async fn cli() -> anyhow::Result<()> { Some(Command::Serve { host, port, - tls, - tls_cert_path, - tls_key_path, - platform, builtins, - dangerously_unauthenticated, - allowed_origins, - }) => { - handle_serve_command(ServeCommandArgs { - host, - port, - tls, - tls_cert_path, - tls_key_path, - platform, - builtins, - dangerously_unauthenticated, - allowed_origins, - }) - .await - } + }) => handle_serve_command(host, port, builtins).await, Some(Command::Session { command: Some(cmd), .. }) => handle_session_subcommand(cmd).await, @@ -2261,7 +2100,6 @@ pub async fn cli() -> anyhow::Result<()> { identifier, resume, fork, - edit, history, session_opts, extension_opts, @@ -2270,7 +2108,6 @@ pub async fn cli() -> anyhow::Result<()> { identifier, resume, fork, - edit, history, session_opts, extension_opts, @@ -2436,134 +2273,4 @@ mod tests { let help = String::from_utf8(buffer).expect("utf8"); assert!(help.contains("nu")); } - - #[test] - fn skills_command_accepts_list_subcommand() { - let cli = Cli::try_parse_from(["goose", "skills", "list"]).expect("parse failed"); - - match cli.command { - Some(Command::Skills { - command: SkillsCommand::List, - }) => {} - _ => panic!("expected skills list command"), - } - } - - #[test] - fn serve_command_accepts_dangerously_unauthenticated_flag() { - let cli = Cli::try_parse_from([ - "goose", - "serve", - "--dangerously-unauthenticated", - "--allowed-origin", - "app://localhost", - "--allowed-origin", - "https://app.example", - ]) - .expect("parse failed"); - - match cli.command { - Some(Command::Serve { - dangerously_unauthenticated, - allowed_origins, - .. - }) => { - assert!(dangerously_unauthenticated); - assert_eq!( - allowed_origins, - vec!["app://localhost", "https://app.example"] - ); - } - _ => panic!("expected serve command"), - } - } - - #[test] - fn review_command_accepts_options() { - let cli = Cli::try_parse_from([ - "goose", - "review", - "origin/main...HEAD", - "--prompt", - "REVIEW.md", - "--model", - "test-model", - "--provider", - "openai", - "--override-model", - "check-model", - "--turn-limit", - "4", - "--dry-run", - "--quiet", - "--no-orchestrate", - "--instructions", - "focus on correctness", - "--files", - "src/lib.rs", - "--check-filter", - "security", - "--check-scope", - ".agents", - "--checks-only", - "--summary-only", - "--severity", - "low", - ]) - .expect("parse failed"); - - match cli.command { - Some(Command::Review { - range, - prompt, - model, - provider, - override_model, - turn_limit, - dry_run, - quiet, - no_orchestrate, - instructions, - files, - check_filter, - check_scope, - checks_only, - summary_only, - severity, - }) => { - assert_eq!(range.as_deref(), Some("origin/main...HEAD")); - assert_eq!(prompt.as_deref(), Some(std::path::Path::new("REVIEW.md"))); - assert_eq!(model.as_deref(), Some("test-model")); - assert_eq!(provider.as_deref(), Some("openai")); - assert_eq!(override_model.as_deref(), Some("check-model")); - assert_eq!(turn_limit, Some(4)); - assert!(dry_run); - assert!(quiet); - assert!(no_orchestrate); - assert_eq!(instructions.as_deref(), Some("focus on correctness")); - assert_eq!(files, vec!["src/lib.rs"]); - assert_eq!(check_filter, vec!["security"]); - assert_eq!( - check_scope.as_deref(), - Some(std::path::Path::new(".agents")) - ); - assert!(checks_only); - assert!(summary_only); - assert_eq!(severity, "low"); - } - _ => panic!("expected review command"), - } - } - - #[cfg(feature = "tui")] - #[test] - fn tui_command_accepts_trailing_args() { - let cli = - Cli::try_parse_from(["goose", "tui", "--", "--theme", "dark"]).expect("parse failed"); - - match cli.command { - Some(Command::Tui { args }) => assert_eq!(args, vec!["--theme", "dark"]), - _ => panic!("expected tui command"), - } - } } diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index 96f1574b65..ce6cb63aca 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -19,31 +19,20 @@ use goose::config::{ configure_tetrate, Config, ConfigError, ExperimentManager, ExtensionEntry, GooseMode, PermissionManager, }; +use goose::model::ModelConfig; #[cfg(feature = "telemetry")] use goose::posthog::{get_telemetry_choice, TELEMETRY_ENABLED_KEY}; use goose::providers::base::ConfigKey; use goose::providers::provider_test::test_provider_configuration; use goose::providers::{create, providers, retry_operation, RetryConfig}; use goose::session::SessionType; -use goose_providers::thinking::ThinkingEffort; use serde_json::Value; use std::collections::HashMap; -use std::io::{IsTerminal, Write}; +use std::io::IsTerminal; // useful for light themes where there is no discernible colour contrast between // cursor-selected and cursor-unselected items. const MULTISELECT_VISIBILITY_HINT: &str = "<"; -const SHOW_CURSOR: &[u8] = b"\x1b[?25h"; - -struct CursorRestoreGuard; - -impl Drop for CursorRestoreGuard { - fn drop(&mut self) { - let mut stdout = std::io::stdout().lock(); - let _ = stdout.write_all(SHOW_CURSOR); - let _ = stdout.flush(); - } -} pub async fn handle_configure() -> anyhow::Result<()> { if !std::io::stdin().is_terminal() { @@ -53,7 +42,6 @@ pub async fn handle_configure() -> anyhow::Result<()> { ); } - let _cursor_restore = CursorRestoreGuard; let config = Config::global(); if !config.exists() { @@ -351,7 +339,8 @@ async fn handle_oauth_configuration(provider_name: &str, key_name: &str) -> anyh )); // Create a temporary provider instance to handle OAuth - match create(provider_name, Vec::new()).await { + let temp_model = ModelConfig::new("temp")?.with_canonical_limits(provider_name); + match create(provider_name, temp_model, Vec::new()).await { Ok(provider) => match provider.configure_oauth().await { Ok(_) => { let _ = cliclack::log::success("OAuth authentication completed successfully!"); @@ -376,12 +365,7 @@ async fn handle_oauth_configuration(provider_name: &str, key_name: &str) -> anyh } } -const UNLISTED_MODEL_KEY: &str = "__unlisted__"; - -fn interactive_model_search( - models: &[String], - provider_meta: &goose::providers::base::ProviderMetadata, -) -> anyhow::Result { +fn interactive_model_search(models: &[String]) -> anyhow::Result { const MAX_VISIBLE: usize = 30; let mut query = String::new(); @@ -411,20 +395,7 @@ fn interactive_model_search( }; if filtered.is_empty() { - let selection = cliclack::select("No matching models. What would you like to do?") - .item( - "__new_search__", - "Start a new search...", - "Enter a different search term", - ) - .item(UNLISTED_MODEL_KEY, "Enter a model not listed...", "") - .interact()?; - - if selection == UNLISTED_MODEL_KEY { - return prompt_unlisted_model(provider_meta); - } - - query.clear(); + let _ = cliclack::log::warning("No matching models. Try a different search."); continue; } @@ -458,12 +429,6 @@ fn interactive_model_search( ); } - items.push(( - UNLISTED_MODEL_KEY.to_string(), - "Enter a model not listed...".to_string(), - "", - )); - let selection = cliclack::select("Select a model:") .items(&items) .interact()?; @@ -473,8 +438,6 @@ fn interactive_model_search( } else if selection == "__new_search__" { query.clear(); continue; - } else if selection == UNLISTED_MODEL_KEY { - return prompt_unlisted_model(provider_meta); } else { return Ok(selection); } @@ -486,6 +449,7 @@ fn select_model_from_list( provider_meta: &goose::providers::base::ProviderMetadata, ) -> anyhow::Result { const MAX_MODELS: usize = 10; + const UNLISTED_MODEL_KEY: &str = "__unlisted__"; // Smart model selection: // If we have more than MAX_MODELS models, show the recommended models with additional search option. @@ -524,14 +488,14 @@ fn select_model_from_list( .interact()?; if selection == "search_all" { - interactive_model_search(models, provider_meta) + Ok(interactive_model_search(models)?) } else if selection == UNLISTED_MODEL_KEY { prompt_unlisted_model(provider_meta) } else { Ok(selection) } } else { - interactive_model_search(models, provider_meta) + Ok(interactive_model_search(models)?) } } else { let mut model_items: Vec<(String, String, &str)> = @@ -772,11 +736,11 @@ pub async fn configure_provider_dialog() -> anyhow::Result { let spin = spinner(); spin.start("Attempting to fetch supported models..."); - let temp_provider = create(provider_name, Vec::new()).await?; + let temp_model_config = + ModelConfig::new(&provider_meta.default_model)?.with_canonical_limits(provider_name); + let temp_provider = create(provider_name, temp_model_config, Vec::new()).await?; let models_res = retry_operation(&RetryConfig::default(), || async { - temp_provider - .fetch_recommended_models(goose::model_config::global_toolshim()) - .await + temp_provider.fetch_recommended_models().await }) .await; spin.stop(style("Model fetch complete").green()); @@ -801,20 +765,20 @@ pub async fn configure_provider_dialog() -> anyhow::Result { { let supports_thinking = match temp_provider.fetch_model_info(&model).await { Ok(model_info) => model_info.reasoning, - Err(_) => goose_providers::model::ModelConfig::new(&model).is_reasoning_model(), + Err(_) => goose::model::ModelConfig::new(&model) + .map(|c| c.is_reasoning_model()) + .unwrap_or(false), }; if supports_thinking { - let effort: ThinkingEffort = cliclack::select("Select thinking effort:") + let effort: &str = cliclack::select("Select thinking effort:") .item("off", "Off - No extended thinking", "") .item("low", "Low - Better latency, lighter reasoning", "") .item("medium", "Medium - Moderate thinking", "") .item("high", "High - Deep reasoning", "") .item("max", "Max - No constraints on thinking depth", "") .initial_value("off") - .interact()? - .parse() - .map_err(|_| anyhow::anyhow!("invalid thinking effort"))?; + .interact()?; config.set_goose_thinking_effort(effort)?; } } @@ -1116,7 +1080,6 @@ fn configure_stdio_extension() -> anyhow::Result<()> { env_keys, description, timeout: Some(timeout), - cwd: None, bundled: None, available_tools: Vec::new(), }, @@ -1588,7 +1551,7 @@ pub async fn configure_tool_permissions_dialog() -> anyhow::Result<()> { let model: String = config .get_goose_model() .expect("No model configured. Please set model first"); - let model_config = goose::model_config::model_config_from_user_config(&provider_name, &model)?; + let model_config = ModelConfig::new(&model)?.with_canonical_limits(&provider_name); let agent = Agent::new(); @@ -1625,10 +1588,8 @@ pub async fn configure_tool_permissions_dialog() -> anyhow::Result<()> { } let extensions = extension_config.into_iter().collect::>(); - let new_provider = create(&provider_name, extensions).await?; - agent - .update_provider(new_provider, model_config, &session.id) - .await?; + let new_provider = create(&provider_name, model_config, extensions).await?; + agent.update_provider(new_provider, &session.id).await?; let permission_manager = PermissionManager::instance(); let selected_tools = agent @@ -1810,21 +1771,22 @@ pub async fn handle_openrouter_auth() -> anyhow::Result<()> { // Test configuration - get the model that was configured println!("\nTesting configuration..."); let configured_model: String = config.get_goose_model()?; - let model_config = - match goose::model_config::model_config_from_user_config("openrouter", &configured_model) { - Ok(config) => config, - Err(e) => { - eprintln!("โš ๏ธ Invalid model configuration: {}", e); - eprintln!("Your settings have been saved. Please check your model configuration."); - return Ok(()); - } - }; + let model_config = match goose::model::ModelConfig::new(&configured_model) { + Ok(config) => config.with_canonical_limits("openrouter"), + Err(e) => { + eprintln!("โš ๏ธ Invalid model configuration: {}", e); + eprintln!("Your settings have been saved. Please check your model configuration."); + return Ok(()); + } + }; - match create("openrouter", Vec::new()).await { + match create("openrouter", model_config, Vec::new()).await { Ok(provider) => { + let provider_model_config = provider.get_model_config(); let test_result = provider .complete( - &model_config, + &provider_model_config, + "", "You are goose, an AI assistant.", &[Message::user().with_text("Say 'Configuration test successful!'")], &[], @@ -1890,14 +1852,16 @@ pub async fn handle_tetrate_auth() -> anyhow::Result<()> { // Test configuration println!("\nTesting configuration..."); let configured_model: String = config.get_goose_model()?; - if let Err(e) = goose::model_config::model_config_from_user_config("tetrate", &configured_model) - { - eprintln!("โš ๏ธ Invalid model configuration: {}", e); - eprintln!("Your settings have been saved. Please check your model configuration."); - return Ok(()); - } + let model_config = match goose::model::ModelConfig::new(&configured_model) { + Ok(config) => config.with_canonical_limits("tetrate"), + Err(e) => { + eprintln!("โš ๏ธ Invalid model configuration: {}", e); + eprintln!("Your settings have been saved. Please check your model configuration."); + return Ok(()); + } + }; - match create("tetrate", Vec::new()).await { + match create("tetrate", model_config, Vec::new()).await { Ok(provider) => { let test_result = provider.fetch_supported_models().await; diff --git a/crates/goose-cli/src/commands/info.rs b/crates/goose-cli/src/commands/info.rs index f471018cb3..d702b3e43e 100644 --- a/crates/goose-cli/src/commands/info.rs +++ b/crates/goose-cli/src/commands/info.rs @@ -3,8 +3,8 @@ 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 goose_providers::errors::ProviderError; use serde_yaml; use std::time::Duration; @@ -73,10 +73,11 @@ async fn check_provider( } }; - let model_config = goose::model_config::model_config_from_user_config(&provider, &model) - .map_err(|e| ProviderCheckError::InvalidModel(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, Vec::new()) + let provider_client = goose::providers::create(&provider, model_config, Vec::new()) .await .map_err(|e| { let error = e.to_string(); @@ -87,13 +88,12 @@ async fn check_provider( })?; let test_msg = Message::user().with_text("Say 'ok'"); + let model_config = provider_client.get_model_config(); let start = std::time::Instant::now(); - goose::session_context::with_session_id( - Some("check".to_string()), - provider_client.complete(&model_config, "", &[test_msg], &[]), - ) - .await - .map_err(ProviderCheckError::ProviderRequest)?; + provider_client + .complete(&model_config, "check", "", &[test_msg], &[]) + .await + .map_err(ProviderCheckError::ProviderRequest)?; Ok(ProviderCheckSuccess { provider, diff --git a/crates/goose-cli/src/commands/project.rs b/crates/goose-cli/src/commands/project.rs index 43fd67d60d..c0a55dedac 100644 --- a/crates/goose-cli/src/commands/project.rs +++ b/crates/goose-cli/src/commands/project.rs @@ -36,7 +36,7 @@ pub fn handle_project_default() -> Result<()> { } // Sort projects by last_accessed (newest first) - projects.sort_by_key(|project| std::cmp::Reverse(project.last_accessed)); + projects.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed)); // Get the most recent project let project = &projects[0]; @@ -178,7 +178,7 @@ pub fn handle_projects_interactive() -> Result<()> { } // Sort projects by last_accessed (newest first) - projects.sort_by_key(|project| std::cmp::Reverse(project.last_accessed)); + projects.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed)); // Format project paths for display let project_choices: Vec<(String, String)> = projects diff --git a/crates/goose-cli/src/commands/review/handler.rs b/crates/goose-cli/src/commands/review/handler.rs index 8c6f6d4cda..1b362f6d8d 100644 --- a/crates/goose-cli/src/commands/review/handler.rs +++ b/crates/goose-cli/src/commands/review/handler.rs @@ -29,9 +29,7 @@ pub struct ReviewOptions { /// Force every discovered check to run with this model, regardless of /// the check's own `model:` field. pub override_model: Option, - /// Default `turn-limit` for orchestrated main-pass subprocesses and for - /// checks that do not declare their own. Does not cap the legacy - /// `--no-orchestrate` in-process main agent. + /// Default `turn-limit` applied to checks that do not declare their own. pub default_turn_limit: Option, /// Print the assembled prompt and discovered checks instead of dispatching /// the review. diff --git a/crates/goose-cli/src/commands/review/orchestrator.rs b/crates/goose-cli/src/commands/review/orchestrator.rs index 151bd181f3..af2b72e7b3 100644 --- a/crates/goose-cli/src/commands/review/orchestrator.rs +++ b/crates/goose-cli/src/commands/review/orchestrator.rs @@ -12,8 +12,7 @@ //! //! - One subprocess per check (`goose run -q -t `) //! - Concurrency capped at [`MAX_WORKERS`] via a Tokio semaphore -//! - Per-subprocess turn limit via `--max-turns` (see -//! [`resolve_main_turn_limit`] and [`Check::resolved_turn_limit`]) +//! - Per-check timeout of [`CHECK_TIMEOUT_SECS`] //! - Each check is given a strict, tool-free prompt and is required to //! return only `{"findings": [...]}` JSON //! - Findings are tagged with the originating `check` name in Rust, not @@ -32,19 +31,27 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::process::Stdio; use std::sync::Arc; +use std::time::Duration; use tokio::io::AsyncWriteExt; use tokio::process::Command; use tokio::sync::Semaphore; use tokio::task::JoinSet; +use tokio::time::timeout; use super::handler::ReviewOptions; -use goose::checks::{Check, DEFAULT_CHECK_TURN_LIMIT}; +use goose::checks::Check; /// Maximum number of check subprocesses we run concurrently. 4 is /// empirically the sweet spot before LLM-side rate limits and local /// resource contention start hurting wall-clock. pub const MAX_WORKERS: usize = 4; +/// Hard wall-clock cap for a single check subprocess. A check that +/// takes longer than this is almost always stuck in a tool-call loop +/// or a retry storm; we'd rather surface the timeout than block the +/// whole review. +pub const CHECK_TIMEOUT_SECS: u64 = 5 * 60; + /// One review finding emitted by a check or by the main correctness /// pass. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -80,7 +87,7 @@ struct RawFinding { /// Run all discovered checks concurrently as `goose run` subprocesses. /// /// Returns one `Vec` per check, in the same order as `checks`. -/// A failed check (subprocess error, turn-limit exhaustion, malformed JSON) yields an +/// A failed check (subprocess error, timeout, malformed JSON) yields an /// empty findings list and a warning on stderr; a single broken check /// must never block the rest of the review. pub async fn run_checks_in_parallel( @@ -179,14 +186,6 @@ fn resolve_check_model(check: &Check, opts: &ReviewOptions) -> Option { opts.default_model.clone() } -/// Resolve the turn limit for a main-pass subprocess. -/// -/// Uses `goose review --turn-limit` when set, otherwise -/// [`DEFAULT_CHECK_TURN_LIMIT`]. -fn resolve_main_turn_limit(default_turn_limit: Option) -> usize { - default_turn_limit.unwrap_or(DEFAULT_CHECK_TURN_LIMIT) -} - /// Spawn a single `goose run` subprocess for one check and parse its /// output into [`Finding`]s. async fn run_single_check_subprocess( @@ -197,8 +196,7 @@ async fn run_single_check_subprocess( instructions: Option<&str>, max_turns: Option, ) -> Result> { - let turns = max_turns.expect("check subprocess always has a resolved turn limit"); - let prompt = build_check_prompt(check, diff, instructions, turns); + let prompt = build_check_prompt(check, diff, instructions); let raw = run_subprocess_for_findings( &prompt, &format!("check '{}'", check.name), @@ -224,7 +222,8 @@ async fn run_single_check_subprocess( /// Generic `goose run` subprocess that hands a prompt to the model /// and parses `{"findings": [...]}` JSON out of the response. Shared /// by the per-check and per-file main-pass orchestrators so both get -/// the same robust JSON extraction and error reporting. +/// the same robust JSON extraction, timeout handling, and error +/// reporting. async fn run_subprocess_for_findings( prompt: &str, label: &str, @@ -244,8 +243,11 @@ async fn run_subprocess_for_findings( .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - // If this future is dropped, kill the child so it does not keep - // running (and racking up tokens) in the background. + // Drop-on-cancel safety: when the outer `timeout` fires it drops + // this future, which drops the Child handle. Without + // kill_on_drop, the Tokio runtime leaves the subprocess running + // (and racking up tokens) in the background โ€” kill_on_drop sends + // SIGKILL on Drop instead. .kill_on_drop(true); if let Some(p) = provider { @@ -271,10 +273,13 @@ async fn run_subprocess_for_findings( drop(stdin); } - let output = child - .wait_with_output() - .await - .with_context(|| format!("wait on {label}"))?; + let wait = child.wait_with_output(); + let output = match timeout(Duration::from_secs(CHECK_TIMEOUT_SECS), wait).await { + Ok(o) => o.with_context(|| format!("wait on {label}"))?, + Err(_) => { + anyhow::bail!("{label} timed out after {}s", CHECK_TIMEOUT_SECS); + } + }; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -316,7 +321,6 @@ pub async fn run_main_pass_in_parallel( let semaphore = Arc::new(Semaphore::new(MAX_WORKERS)); let mut set: JoinSet<(usize, String, Result>, bool)> = JoinSet::new(); - let max_turns = resolve_main_turn_limit(opts.default_turn_limit); for (idx, (path, file_diff)) in per_file.iter().enumerate() { let sem = semaphore.clone(); @@ -330,20 +334,15 @@ pub async fn run_main_pass_in_parallel( set.spawn(async move { let _permit = sem.acquire().await.expect("semaphore is never closed"); - let prompt = build_main_pass_prompt( - &path, - &file_diff, - &base_prompt, - instructions.as_deref(), - max_turns, - ); + let prompt = + build_main_pass_prompt(&path, &file_diff, &base_prompt, instructions.as_deref()); let label = format!("main:{path}"); let result = run_subprocess_for_findings( &prompt, &label, provider.as_deref(), model.as_deref(), - Some(max_turns), + None, ) .await; (idx, path, result, quiet) @@ -568,22 +567,6 @@ fn take_quoted(s: &str) -> Option<(String, &str)> { None } -/// Prompt section telling review subprocesses about the `--max-turns` -/// cap enforced by goose. Without this, models routinely burn turns on -/// tool loops and return nothing when the limit stops the session. -fn build_subprocess_turn_budget_section(max_turns: usize) -> String { - format!( - "## Turn budget\n\n\ - You may take at most {max_turns} agent turns (model/tool iterations) in this run. \ - goose enforces this via `--max-turns`; when you exhaust it, the session stops and \ - any findings not yet emitted as JSON are lost.\n\n\ - Plan for the limit:\n\ - - As turns run low, stop exploring and return JSON with the findings you have verified.\n\ - - Always emit valid JSON (`{{\"findings\":[...]}}` or `{{\"findings\":[]}}`) before \ - the turn limit โ€” an empty or missing response counts as failure.\n\n" - ) -} - /// Build the strict, JSON-only prompt sent to one main-pass /// subprocess. The base prompt (custom or /// [`DEFAULT_REVIEW_PROMPT`]) supplies the reviewer voice; we then @@ -594,7 +577,6 @@ fn build_main_pass_prompt( file_diff: &str, base_prompt: &str, instructions: Option<&str>, - max_turns: usize, ) -> String { let mut s = String::new(); s.push_str(base_prompt.trim_end()); @@ -607,7 +589,6 @@ fn build_main_pass_prompt( s.push_str("\n\n"); } } - s.push_str(&build_subprocess_turn_budget_section(max_turns)); s.push_str("## File under review\n\n"); s.push_str(&format!("Path: `{path}`\n\n")); s.push_str( @@ -630,12 +611,7 @@ fn build_main_pass_prompt( /// Shape matches the prompt format Amp-authored checks already expect, /// so a check written for `amp review` runs the same way under /// `goose review`. -fn build_check_prompt( - check: &Check, - diff: &str, - instructions: Option<&str>, - max_turns: usize, -) -> String { +fn build_check_prompt(check: &Check, diff: &str, instructions: Option<&str>) -> String { let mut s = String::new(); s.push_str("You are running an automated code review check.\n\n"); s.push_str(&format!("Check name: {}\n", check.name)); @@ -657,9 +633,7 @@ fn build_check_prompt( s.push('\n'); } } - s.push('\n'); - s.push_str(&build_subprocess_turn_budget_section(max_turns)); - s.push_str("Review ONLY the git diff provided below.\n"); + s.push_str("\nReview ONLY the git diff provided below.\n"); s.push_str("Do not ask for missing context.\n"); s.push_str("Use repo-relative file paths.\n"); s.push_str("Use post-change line numbers from the diff.\n"); @@ -846,7 +820,7 @@ mod tests { #[test] fn check_prompt_is_strict_and_diff_aware() { - let p = build_check_prompt(&ck("perf"), "diff content", None, DEFAULT_CHECK_TURN_LIMIT); + let p = build_check_prompt(&ck("perf"), "diff content", None); assert!(p.contains("automated code review check")); assert!(p.contains("Check name: perf")); assert!(p.contains("```diff\ndiff content\n```")); @@ -859,7 +833,7 @@ mod tests { fn check_prompt_restricts_findings_to_added_or_modified_lines() { // Mirrors Amp's prompt language; without these the model // happily flags pre-existing code shown for context. - let p = build_check_prompt(&ck("perf"), "diff content", None, DEFAULT_CHECK_TURN_LIMIT); + let p = build_check_prompt(&ck("perf"), "diff content", None); assert!(p.contains("ONLY in the changed lines")); assert!(p.contains("lines beginning with `+`")); assert!(p.contains("ONLY for code that was added or modified")); @@ -872,7 +846,6 @@ mod tests { &ck("perf"), "diff content", Some("This is a refactor; flag any behavior change."), - DEFAULT_CHECK_TURN_LIMIT, ); assert!(p.contains("Reviewer instructions:")); assert!(p.contains("flag any behavior change")); @@ -880,29 +853,10 @@ mod tests { #[test] fn check_prompt_skips_blank_reviewer_instructions() { - let p = build_check_prompt( - &ck("perf"), - "diff content", - Some(" \n "), - DEFAULT_CHECK_TURN_LIMIT, - ); + let p = build_check_prompt(&ck("perf"), "diff content", Some(" \n ")); assert!(!p.contains("Reviewer instructions")); } - #[test] - fn check_prompt_includes_turn_budget() { - let p = build_check_prompt(&ck("perf"), "diff content", None, 12); - assert!(p.contains("## Turn budget")); - assert!(p.contains("at most 12 agent turns")); - assert!(p.contains("--max-turns")); - } - - #[test] - fn resolve_main_turn_limit_uses_cli_default_or_fallback() { - assert_eq!(resolve_main_turn_limit(Some(40)), 40); - assert_eq!(resolve_main_turn_limit(None), DEFAULT_CHECK_TURN_LIMIT); - } - #[test] fn parse_findings_accepts_bare_json() { let raw = r#"{"findings":[{"severity":"high","path":"a.py","line_start":1,"line_end":2,"summary":"bad"}]}"#; @@ -1136,7 +1090,6 @@ rename to new/name.rs "diff --git a/src/foo.rs b/src/foo.rs\n@@ -1 +1 @@\n-old\n+new\n", "BASE PROMPT", None, - DEFAULT_CHECK_TURN_LIMIT, ); assert!(p.starts_with("BASE PROMPT")); assert!(p.contains("Path: `src/foo.rs`")); @@ -1155,7 +1108,6 @@ rename to new/name.rs "diff body", "BASE", Some("PR is a refactor; flag behavior changes."), - DEFAULT_CHECK_TURN_LIMIT, ); assert!(p.contains("## Reviewer instructions")); assert!(p.contains("flag behavior changes")); @@ -1163,20 +1115,7 @@ rename to new/name.rs #[test] fn main_pass_prompt_skips_blank_reviewer_instructions() { - let p = build_main_pass_prompt( - "src/foo.rs", - "diff body", - "BASE", - Some(" \n \t\n"), - DEFAULT_CHECK_TURN_LIMIT, - ); + let p = build_main_pass_prompt("src/foo.rs", "diff body", "BASE", Some(" \n \t\n")); assert!(!p.contains("Reviewer instructions")); } - - #[test] - fn main_pass_prompt_includes_turn_budget() { - let p = build_main_pass_prompt("src/foo.rs", "diff body", "BASE", None, 18); - assert!(p.contains("## Turn budget")); - assert!(p.contains("at most 18 agent turns")); - } } diff --git a/crates/goose-cli/src/commands/schedule.rs b/crates/goose-cli/src/commands/schedule.rs index 431afed7a1..1f56481bdd 100644 --- a/crates/goose-cli/src/commands/schedule.rs +++ b/crates/goose-cli/src/commands/schedule.rs @@ -217,11 +217,11 @@ pub async fn handle_schedule_sessions(schedule_id: String, limit: Option) println!("No sessions found for schedule ID '{}'.", schedule_id); } else { println!("Sessions for schedule ID '{}':", schedule_id); + // sessions is now Vec<(String, SessionMetadata)> for (session_name, metadata) in sessions { println!( - " - Session ID: {}, Messages: {}, Working Dir: {}, Description: \"{}\", Schedule ID: {:?}", - session_name, - metadata.message_count, + " - Session ID: {}, Working Dir: {}, Description: \"{}\", Schedule ID: {:?}", + session_name, // Display the session_name as Session ID metadata.working_dir.display(), metadata.name, metadata.schedule_id.as_deref().unwrap_or("N/A") diff --git a/crates/goose-cli/src/commands/session.rs b/crates/goose-cli/src/commands/session.rs index 72c3610fe3..01efebf21a 100644 --- a/crates/goose-cli/src/commands/session.rs +++ b/crates/goose-cli/src/commands/session.rs @@ -7,9 +7,7 @@ use etcetera::home_dir; use goose::config::Config; #[cfg(feature = "nostr")] use goose::session::nostr_share; -use goose::session::{ - generate_diagnostics, DiagnosticsLevel, Session, SessionManager, SessionType, -}; +use goose::session::{generate_diagnostics, Session, SessionManager, SessionType}; use goose::utils::safe_truncate; use regex::Regex; use std::fs; @@ -70,8 +68,7 @@ fn prompt_interactive_session_removal(sessions: &[Session]) -> Result(out: &mut W, line: &str) -> Result chrono::DateTime { - session.last_message_at.unwrap_or(session.updated_at) -} - pub async fn handle_session_list( format: String, ascending: bool, @@ -175,9 +168,9 @@ pub async fn handle_session_list( } if ascending { - sessions.sort_by_key(session_activity_at); + sessions.sort_by(|a, b| a.updated_at.cmp(&b.updated_at)); } else { - sessions.sort_by_key(|b| std::cmp::Reverse(session_activity_at(b))); + sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); } if let Some(n) = limit { @@ -211,7 +204,7 @@ pub async fn handle_session_list( "{} - {} - {} - {}", session.id, session.name, - session_activity_at(&session), + session.updated_at, display_path_with_tilde(&session.working_dir) ); if !write_line_or_broken_pipe_ok(&mut out, &output)? { @@ -307,15 +300,6 @@ pub async fn handle_session_import(input: String, nostr: bool) -> Result<()> { .with_context(|| format!("Failed to read session import file: {input}"))? }; - let format = goose::session::import_formats::detect_format(&json); - let label = match format { - goose::session::import_formats::ImportFormat::Goose => "goose", - goose::session::import_formats::ImportFormat::ClaudeCode => "Claude Code", - goose::session::import_formats::ImportFormat::Codex => "Codex", - goose::session::import_formats::ImportFormat::Pi => "Pi", - }; - println!("Detected format: {}", label); - let session_manager = SessionManager::instance(); let session = session_manager .import_session(&json, Some(SessionType::User)) @@ -329,27 +313,24 @@ pub async fn handle_session_import(input: String, nostr: bool) -> Result<()> { pub async fn handle_diagnostics(session_id: &str, output_path: Option) -> Result<()> { println!( - "Generating diagnostics report for session '{}'...", + "Generating diagnostics bundle for session '{}'...", session_id ); let session_manager = SessionManager::instance(); - let diagnostics_report = - generate_diagnostics(&session_manager, session_id, DiagnosticsLevel::Full) - .await - .with_context(|| { - format!( - "Failed to generate diagnostics report for session '{}'", - session_id - ) - })?; - let diagnostics_data = serde_json::to_vec_pretty(&diagnostics_report) - .context("Failed to serialize diagnostics report")?; + let diagnostics_data = generate_diagnostics(&session_manager, session_id) + .await + .with_context(|| { + format!( + "Failed to write to generate diagnostics bundle for session '{}'", + session_id + ) + })?; let output_file = if let Some(path) = output_path { path.clone() } else { - PathBuf::from(format!("diagnostics_{}.json", session_id)) + PathBuf::from(format!("diagnostics_{}.zip", session_id)) }; let mut file = fs::File::create(&output_file).context(format!( @@ -360,7 +341,7 @@ pub async fn handle_diagnostics(session_id: &str, output_path: Option) file.write_all(&diagnostics_data) .context("Failed to write diagnostics data")?; - println!("Diagnostics report saved to: {}", output_file.display()); + println!("Diagnostics bundle saved to: {}", output_file.display()); Ok(()) } diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs index 9c80fc1e0e..8596eeb782 100644 --- a/crates/goose-cli/src/commands/term.rs +++ b/crates/goose-cli/src/commands/term.rs @@ -280,15 +280,9 @@ pub async fn handle_term_run(prompt: Vec) -> Result<()> { }; if let Some(oldest_user) = user_messages_after_last_assistant.last() { - if let Some(message_id) = oldest_user.id.as_deref() { - session_manager - .truncate_conversation_from_message(&session_id, message_id) - .await?; - } else { - session_manager - .truncate_conversation(&session_id, oldest_user.created) - .await?; - } + session_manager + .truncate_conversation(&session_id, oldest_user.created) + .await?; } let prompt_with_context = if user_messages_after_last_assistant.is_empty() { @@ -330,10 +324,7 @@ pub async fn handle_term_info() -> Result<()> { let session_manager = SessionManager::instance(); let session = session_manager.get_session(&session_id, false).await.ok(); - let total_tokens = session - .as_ref() - .and_then(|s| s.usage.total_tokens) - .unwrap_or(0) as usize; + let total_tokens = session.as_ref().and_then(|s| s.total_tokens).unwrap_or(0) as usize; let config = goose::config::Config::global(); let model_name = config @@ -354,7 +345,9 @@ pub async fn handle_term_info() -> Result<()> { .ok() .and_then(|model_name| { config.get_goose_provider().ok().and_then(|provider_name| { - goose::model_config::model_config_from_user_config(&provider_name, &model_name).ok() + goose::model::ModelConfig::new(&model_name) + .ok() + .map(|c| c.with_canonical_limits(&provider_name)) }) }) .map(|mc| mc.context_limit()) diff --git a/crates/goose-cli/src/commands/update.rs b/crates/goose-cli/src/commands/update.rs index 7c600a92c3..04272d2059 100644 --- a/crates/goose-cli/src/commands/update.rs +++ b/crates/goose-cli/src/commands/update.rs @@ -1,8 +1,4 @@ use anyhow::{bail, Context, Result}; -use reqwest::{ - header::{HeaderValue, AUTHORIZATION}, - StatusCode, -}; use sha2::{Digest, Sha256}; use sigstore_verify::trust_root::{TrustedRoot, SIGSTORE_PRODUCTION_TRUSTED_ROOT}; use sigstore_verify::types::{Bundle, Sha256Hash}; @@ -83,32 +79,6 @@ struct AttestationEntry { const GITHUB_ACTIONS_ISSUER: &str = "https://token.actions.githubusercontent.com"; -fn sanitized_token(token: Option<&str>) -> Option<&str> { - token.map(str::trim).filter(|tok| !tok.is_empty()) -} - -fn authorization_header_value(token: &str) -> Option { - HeaderValue::from_str(&format!("Bearer {token}")).ok() -} - -fn github_token() -> Option { - env::var("GITHUB_TOKEN") - .ok() - .and_then(|tok| sanitized_token(Some(&tok)).map(str::to_owned)) - .or_else(|| { - env::var("GH_TOKEN") - .ok() - .and_then(|tok| sanitized_token(Some(&tok)).map(str::to_owned)) - }) -} - -fn should_retry_attestations_without_token(status: StatusCode, token: Option<&str>) -> bool { - sanitized_token(token) - .and_then(authorization_header_value) - .is_some() - && matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) -} - async fn fetch_attestations(digest: &str, token: Option<&str>) -> Result> { let url = format!( "https://api.github.com/repos/aaif-goose/goose/attestations/sha256:{digest}\ @@ -116,14 +86,17 @@ async fn fetch_attestations(digest: &str, token: Option<&str>) -> Result) -> Result, -) -> Result { - let mut req = client - .get(url) - .header("Accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2022-11-28") - .header("User-Agent", "goose-cli"); - - if let Some(value) = token.and_then(authorization_header_value) { - req = req.header(AUTHORIZATION, value); - } - - req.send().await.context("Failed to fetch attestations") -} - // Verify a single attestation bundle against the artifact digest and workflow. fn verify_bundle( bundle_json: &serde_json::Value, @@ -187,8 +142,8 @@ fn verify_bundle( Ok(()) } -/// Returns `Ok(())` when the downloaded archive has verified provenance. -async fn verify_provenance(archive_data: &[u8], tag: &str) -> Result<()> { +/// Returns `Ok(true)` verified, `Ok(false)` skipped (soft warning), `Err` hard failure. +async fn verify_provenance(archive_data: &[u8], tag: &str) -> Result { let digest = sha256_hex(archive_data); println!("Archive SHA-256: {digest}"); @@ -197,19 +152,30 @@ async fn verify_provenance(archive_data: &[u8], tag: &str) -> Result<()> { _ => "release.yml", }; - let token = github_token(); + let token = env::var("GITHUB_TOKEN") + .ok() + .or_else(|| env::var("GH_TOKEN").ok()); println!("Verifying SLSA provenance via Sigstore..."); - let bundles = fetch_attestations(&digest, token.as_deref()) - .await - .context( - "Sigstore provenance check could not complete; refusing to install unverifiable update", - )?; - - if bundles.is_empty() { - bail!("No Sigstore attestation found for downloaded archive; refusing to install unverifiable update"); - } + let bundles = match fetch_attestations(&digest, token.as_deref()).await { + Ok(b) if b.is_empty() => { + eprintln!( + "Warning: No Sigstore attestation found for this build. \ + This may be expected for canary or nightly builds." + ); + return Ok(false); + } + Ok(b) => b, + Err(e) => { + eprintln!( + "Warning: Sigstore provenance check could not complete: {e}\n\ + This may be expected for releases published before provenance \ + attestations were enabled." + ); + return Ok(false); + } + }; let trusted_root = TrustedRoot::from_json(SIGSTORE_PRODUCTION_TRUSTED_ROOT) .context("Failed to load Sigstore trusted root")?; @@ -229,7 +195,7 @@ async fn verify_provenance(archive_data: &[u8], tag: &str) -> Result<()> { ) { Ok(()) => { println!("Sigstore provenance verification passed."); - return Ok(()); + return Ok(true); } Err(e) => last_err = Some(e), } @@ -281,7 +247,7 @@ pub async fn update(canary: bool, reconfigure: bool) -> Result<()> { println!("Downloaded {} bytes.", bytes.len()); // --- Verify SLSA provenance via Sigstore -------------------------------- - verify_provenance(&bytes, tag).await?; + let provenance_verified = verify_provenance(&bytes, tag).await?; // --- Extract to temp dir (hardened against path traversal) -------------- let tmp_dir = tempfile::tempdir().context("Failed to create temp directory")?; @@ -308,7 +274,11 @@ pub async fn update(canary: bool, reconfigure: bool) -> Result<()> { #[cfg(target_os = "windows")] copy_dlls(&extracted_binary, ¤t_exe)?; - println!("goose updated successfully (verified with Sigstore SLSA provenance)."); + if provenance_verified { + println!("goose updated successfully (verified with Sigstore SLSA provenance)."); + } else { + println!("goose updated successfully."); + } // --- Reconfigure if requested ------------------------------------------- if reconfigure { @@ -767,48 +737,6 @@ mod tests { ); } - #[test] - fn test_sanitized_token_trims_blank_values() { - assert_eq!(sanitized_token(None), None); - assert_eq!(sanitized_token(Some("")), None); - assert_eq!(sanitized_token(Some(" ")), None); - assert_eq!(sanitized_token(Some(" token\n")), Some("token")); - } - - #[test] - fn test_authorization_header_value_rejects_malformed_tokens() { - assert!(authorization_header_value("token").is_some()); - assert!(authorization_header_value("bad\ntoken").is_none()); - } - - #[test] - fn test_attestation_lookup_retries_auth_failures_without_token() { - assert!(should_retry_attestations_without_token( - StatusCode::UNAUTHORIZED, - Some("token") - )); - assert!(should_retry_attestations_without_token( - StatusCode::FORBIDDEN, - Some("token") - )); - assert!(!should_retry_attestations_without_token( - StatusCode::INTERNAL_SERVER_ERROR, - Some("token") - )); - assert!(!should_retry_attestations_without_token( - StatusCode::UNAUTHORIZED, - Some("") - )); - assert!(!should_retry_attestations_without_token( - StatusCode::UNAUTHORIZED, - Some("bad\ntoken") - )); - assert!(!should_retry_attestations_without_token( - StatusCode::UNAUTHORIZED, - None - )); - } - // ----------------------------------------------------------------------- // Path validation and extraction hardening tests // ----------------------------------------------------------------------- @@ -880,11 +808,13 @@ mod tests { // ----------------------------------------------------------------------- #[tokio::test] - async fn test_verify_provenance_fails_closed_when_unverifiable() { + async fn test_verify_provenance_warns_on_missing_attestation() { let result = verify_provenance(b"not a real archive", "stable").await; - assert!( - result.is_err(), - "verify_provenance must fail closed when provenance cannot be verified" + // Network failures and missing attestations are soft warnings: Ok(false), not hard errors. + assert_eq!( + result.ok(), + Some(false), + "verify_provenance should return Ok(false) when attestations cannot be fetched" ); } diff --git a/crates/goose-cli/src/lib.rs b/crates/goose-cli/src/lib.rs index 285144c468..be821a2a29 100644 --- a/crates/goose-cli/src/lib.rs +++ b/crates/goose-cli/src/lib.rs @@ -1,5 +1,3 @@ -#![recursion_limit = "256"] - #[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))] compile_error!("At least one of `rustls-tls` or `native-tls` features must be enabled"); diff --git a/crates/goose-cli/src/logging.rs b/crates/goose-cli/src/logging.rs index a7f8af7c95..68d435c3c2 100644 --- a/crates/goose-cli/src/logging.rs +++ b/crates/goose-cli/src/logging.rs @@ -1,31 +1,53 @@ use anyhow::Result; -use goose::providers::utils::init_goose_request_log; -use std::sync::OnceLock; +use std::sync::Once; // Used to ensure we only set up tracing once -static INIT: OnceLock> = OnceLock::new(); +static INIT: Once = Once::new(); /// Sets up the logging infrastructure for the CLI. /// Logs go to a JSON file only (no console output). -pub fn setup_logging(name: Option<&str>) -> &'static Result<()> { - INIT.get_or_init(|| { - use tracing_subscriber::util::SubscriberInitExt; +pub fn setup_logging(name: Option<&str>) -> Result<()> { + setup_logging_internal(name, false) +} - init_goose_request_log()?; - let config = goose::logging::LoggingConfig { - component: "cli", - name, - extra_directives: &["goose_cli=info"], - console: false, - json: true, - }; - let subscriber = goose::logging::build_logging_subscriber(&config)?; +fn setup_logging_internal(name: Option<&str>, force: bool) -> Result<()> { + let mut result = Ok(()); - subscriber - .try_init() - .map_err(|e| anyhow::anyhow!("Failed to set global subscriber: {}", e))?; - Ok(()) - }) + let mut setup = || { + result = (|| { + use tracing_subscriber::util::SubscriberInitExt; + + let config = goose::logging::LoggingConfig { + component: "cli", + name, + extra_directives: &["goose_cli=info"], + console: false, + json: true, + }; + let subscriber = goose::logging::build_logging_subscriber(&config)?; + + if force { + let _guard = subscriber.set_default(); + tracing::warn!("Test log entry from setup"); + tracing::info!("Another test log entry from setup"); + std::thread::sleep(std::time::Duration::from_millis(100)); + Ok(()) + } else { + subscriber + .try_init() + .map_err(|e| anyhow::anyhow!("Failed to set global subscriber: {}", e))?; + Ok(()) + } + })(); + }; + + if force { + setup(); + } else { + INIT.call_once(setup); + } + + result } #[cfg(test)] diff --git a/crates/goose-cli/src/main.rs b/crates/goose-cli/src/main.rs index b5b3c19d5b..afecc21c82 100644 --- a/crates/goose-cli/src/main.rs +++ b/crates/goose-cli/src/main.rs @@ -1,5 +1,3 @@ -#![recursion_limit = "256"] - use anyhow::Result; use goose_cli::cli::cli; diff --git a/crates/goose-cli/src/recipes/secret_discovery.rs b/crates/goose-cli/src/recipes/secret_discovery.rs index 29a102ee31..3a9a17f8ee 100644 --- a/crates/goose-cli/src/recipes/secret_discovery.rs +++ b/crates/goose-cli/src/recipes/secret_discovery.rs @@ -178,7 +178,6 @@ mod tests { envs: Envs::new(HashMap::new()), env_keys: vec!["SLACK_TOKEN".to_string()], timeout: None, - cwd: None, description: "slack-mcp".to_string(), bundled: None, available_tools: Vec::new(), @@ -276,7 +275,6 @@ mod tests { envs: Envs::new(HashMap::new()), env_keys: vec!["API_KEY".to_string()], // Same original key, different extension timeout: None, - cwd: None, description: "service-b".to_string(), bundled: None, available_tools: Vec::new(), diff --git a/crates/goose-cli/src/scenario_tests/scenario_runner.rs b/crates/goose-cli/src/scenario_tests/scenario_runner.rs index 2498c78cc0..eae04307b3 100644 --- a/crates/goose-cli/src/scenario_tests/scenario_runner.rs +++ b/crates/goose-cli/src/scenario_tests/scenario_runner.rs @@ -9,6 +9,7 @@ use anyhow::Result; use goose::agents::{Agent, AgentConfig, GoosePlatform}; use goose::config::permission::PermissionManager; use goose::config::GooseMode; +use goose::model::ModelConfig; use goose::providers::{create, testprovider::TestProvider}; use goose::session::session_manager::SessionType; use goose::session::SessionManager; @@ -185,7 +186,12 @@ where let original_env = setup_environment(config)?; - let inner_provider = create(&factory_name, Vec::new()).await?; + let inner_provider = create( + &factory_name, + ModelConfig::new(config.model_name)?.with_canonical_limits(&factory_name), + Vec::new(), + ) + .await?; let test_provider = Arc::new(TestProvider::new_recording(inner_provider, &file_path)); ( @@ -240,12 +246,9 @@ where ) .await?; - let scenario_model_config = - goose::model_config::model_config_from_user_config(&factory_name, config.model_name)?; agent .update_provider( provider_arc as Arc, - scenario_model_config, &session.id, ) .await?; @@ -259,7 +262,6 @@ where None, None, "text".to_string(), - false, ) .await; diff --git a/crates/goose-cli/src/scenario_tests/scenarios.rs b/crates/goose-cli/src/scenario_tests/scenarios.rs index c52df9753f..d1c7695742 100644 --- a/crates/goose-cli/src/scenario_tests/scenarios.rs +++ b/crates/goose-cli/src/scenario_tests/scenarios.rs @@ -80,7 +80,7 @@ mod tests { // run_scenario( // "context_length_exceeded", // Box::new(|provider| { - // let model_config = resolve_global_model_config(); + // let model_config = provider.get_model_config(); // let context_length = model_config.context_limit.unwrap_or(300_000); // // "hello " is only one token in most models, since the hello and space often // // occur together in the training data. diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index de228d6e99..a013952081 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -6,7 +6,6 @@ use console::style; use goose::agents::{Agent, Container, ExtensionError}; use goose::config::resolve_extensions_for_new_session; use goose::config::{Config, ExtensionConfig, GooseMode}; -use goose::model_config::model_config_from_user_config; use goose::providers::create; use goose::recipe::Recipe; use goose::session::session_manager::SessionType; @@ -117,8 +116,6 @@ pub struct SessionBuilderConfig { pub output_format: String, /// Docker container to run stdio extensions inside pub container: Option, - /// Print generation statistics after headless runs. - pub stats: bool, } /// Manual implementation of Default to ensure proper initialization of output_format @@ -146,7 +143,6 @@ impl Default for SessionBuilderConfig { quiet: false, output_format: "text".to_string(), container: None, - stats: false, } } } @@ -230,14 +226,14 @@ async fn load_extensions( struct ResolvedProviderConfig { provider_name: String, model_name: String, - model_config: goose_providers::model::ModelConfig, + model_config: goose::model::ModelConfig, } fn resolve_provider_and_model( session_config: &SessionBuilderConfig, config: &Config, saved_provider: Option, - saved_model_config: Option, + saved_model_config: Option, ) -> ResolvedProviderConfig { let recipe_settings = session_config .recipe @@ -278,16 +274,14 @@ fn resolve_provider_and_model( } config } else { - let mut config = - goose::model_config::model_config_from_user_config(&provider_name, &model_name) - .unwrap_or_else(|e| { - output::render_error(&format!("Failed to create model configuration: {}", e)); - process::exit(1); - }); - if let Some(temp) = recipe_settings.and_then(|s| s.temperature) { - config = config.with_temperature(Some(temp)); - } - config + let temperature = recipe_settings.and_then(|s| s.temperature); + goose::model::ModelConfig::new(&model_name) + .unwrap_or_else(|e| { + output::render_error(&format!("Failed to create model configuration: {}", e)); + process::exit(1); + }) + .with_canonical_limits(&provider_name) + .with_temperature(temperature) }; ResolvedProviderConfig { @@ -333,10 +327,7 @@ async fn resolve_session_id( } } } else { - match session_manager - .list_sessions_by_types(&[SessionType::User]) - .await - { + match session_manager.list_sessions().await { Ok(sessions) if !sessions.is_empty() => sessions[0].id.clone(), _ => { output::render_error("Cannot resume - no previous sessions found"); @@ -417,7 +408,6 @@ async fn collect_extension_configs( recipe: Option<&Recipe>, session_id: &str, ) -> Result, ExtensionError> { - let recipe_extensions = recipe.and_then(|r| r.extensions.as_deref()); let configured_extensions: Vec = if session_config.resume { EnabledExtensionsState::for_session( &agent.config.session_manager, @@ -428,7 +418,7 @@ async fn collect_extension_configs( } else if session_config.no_profile { Vec::new() } else { - resolve_extensions_for_new_session(recipe_extensions, None) + resolve_extensions_for_new_session(recipe.and_then(|r| r.extensions.as_deref()), None) }; let cli_flag_extensions = parse_cli_flag_extensions( @@ -438,12 +428,6 @@ async fn collect_extension_configs( ); let mut all: Vec = configured_extensions; - if !session_config.no_profile && !session_config.resume && recipe_extensions.is_none() { - let project_root = std::env::current_dir().ok(); - all.extend(goose::plugins::mcp_servers::enabled_plugin_mcp_servers( - project_root.as_deref(), - )); - } all.extend(cli_flag_extensions.into_iter().map(|(_, cfg)| cfg)); Ok(all) @@ -547,79 +531,29 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { } }; - let (new_provider, effective_provider_name, effective_model_name, effective_model_config) = - match create(&resolved.provider_name, extensions_for_provider.clone()).await { - Ok(provider) => ( - provider, - resolved.provider_name.clone(), - resolved.model_name.clone(), - resolved.model_config.clone(), - ), - Err(e) - if session_config.resume - && session_config.provider.is_none() - && is_provider_unavailable_error(&e) => - { - let fallback_provider = config.get_goose_provider().unwrap_or_else(|_| { - output::render_error("No provider configured. Run 'goose configure' first."); - process::exit(1); - }); - let fallback_model = config.get_goose_model().unwrap_or_else(|_| { - output::render_error("No model configured. Run 'goose configure' first."); - process::exit(1); - }); - eprintln!( - "{}", - style(format!( - "Warning: Could not create the session's original provider '{}' ({}). \ - Falling back to the default provider '{}'.", - resolved.provider_name, e, fallback_provider - )) - .yellow() - ); - let fallback_model_config = - model_config_from_user_config(fallback_provider.as_str(), &fallback_model) - .unwrap_or_else(|e| { - output::render_error(&format!( - "Failed to create model configuration: {}", - e - )); - process::exit(1); - }); - match create(&fallback_provider, extensions_for_provider.clone()).await { - Ok(provider) => ( - provider, - fallback_provider, - fallback_model, - fallback_model_config, - ), - Err(e2) => { - output::render_error(&format!( - "Error {}.\n\ - Please check your system keychain and run 'goose configure' again.\n\ - If your system is unable to use the keyring, please try setting secret key(s) via environment variables.\n\ - For more info, see: https://goose-docs.ai/docs/troubleshooting/#keychainkeyring-errors", - e2 - )); - process::exit(1); - } - } - } - Err(e) => { - output::render_error(&format!( + let new_provider = match create( + &resolved.provider_name, + resolved.model_config, + extensions_for_provider.clone(), + ) + .await + { + Ok(provider) => provider, + Err(e) => { + output::render_error(&format!( "Error {}.\n\ Please check your system keychain and run 'goose configure' again.\n\ If your system is unable to use the keyring, please try setting secret key(s) via environment variables.\n\ For more info, see: https://goose-docs.ai/docs/troubleshooting/#keychainkeyring-errors", e )); - process::exit(1); - } - }; - tracing::info!("๐Ÿค– Using model: {}", effective_model_name); + process::exit(1); + } + }; + tracing::info!("๐Ÿค– Using model: {}", resolved.model_name); agent - .update_provider(new_provider, effective_model_config, &session_id) + .update_provider(new_provider, &session_id) .await .unwrap_or_else(|e| { output::render_error(&format!("Failed to initialize agent: {}", e)); @@ -671,7 +605,6 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { edit_mode, recipe.and_then(|r| r.retry.clone()), session_config.output_format.clone(), - session_config.stats, ) .await; @@ -680,26 +613,17 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { if !session_config.quiet { output::display_session_info( session_config.resume, - &effective_provider_name, - &effective_model_name, + &resolved.provider_name, + &resolved.model_name, &Some(session_id), ); } session } -fn is_provider_unavailable_error(e: &anyhow::Error) -> bool { - let msg = e.to_string(); - msg.contains("is not set") - || msg.contains("not configured") - || msg.contains("Configuration value not found") -} - #[cfg(test)] mod tests { use super::*; - use goose::session::SessionManager; - use tempfile::TempDir; #[test] fn test_session_builder_config_creation() { @@ -727,7 +651,6 @@ mod tests { quiet: false, output_format: "text".to_string(), container: None, - stats: false, }; assert_eq!(config.extensions.len(), 1); @@ -763,44 +686,6 @@ mod tests { assert!(!config.fork); } - #[tokio::test] - async fn test_implicit_resume_ignores_newer_scheduled_sessions() { - let temp_dir = TempDir::new().unwrap(); - let session_manager = SessionManager::new(temp_dir.path().to_path_buf()); - let goose_mode = GooseMode::default(); - - let user_session = session_manager - .create_session( - temp_dir.path().to_path_buf(), - "User session".to_string(), - SessionType::User, - goose_mode, - ) - .await - .unwrap(); - session_manager - .create_session( - temp_dir.path().to_path_buf(), - "Scheduled job: test".to_string(), - SessionType::Scheduled, - goose_mode, - ) - .await - .unwrap(); - - let resolved = resolve_session_id( - &SessionBuilderConfig { - resume: true, - ..SessionBuilderConfig::default() - }, - &session_manager, - goose_mode, - ) - .await; - - assert_eq!(resolved, user_session.id); - } - #[test] fn test_truncate_with_ellipsis() { assert_eq!(truncate_with_ellipsis("abc", 5), "abc"); diff --git a/crates/goose-cli/src/session/completion.rs b/crates/goose-cli/src/session/completion.rs index 410acd9356..69bffe1a7d 100644 --- a/crates/goose-cli/src/session/completion.rs +++ b/crates/goose-cli/src/session/completion.rs @@ -1,4 +1,3 @@ -use goose::agents::execute_commands::list_commands; use goose::config::GooseMode; use rustyline::completion::{Completer, FilenameCompleter, Pair}; use rustyline::highlight::{CmdKind, Highlighter}; @@ -37,8 +36,8 @@ impl GooseCompleter { // Create completion candidates that match the prefix let candidates: Vec = cache .prompts - .values() - .flatten() + .iter() + .flat_map(|(_, names)| names) .filter(|name| name.starts_with(prefix.trim())) .map(|name| Pair { display: name.clone(), @@ -153,25 +152,22 @@ impl GooseCompleter { /// Complete slash commands fn complete_slash_commands(&self, line: &str) -> Result<(usize, Vec)> { - let mut commands = vec![ - "/exit".to_string(), - "/quit".to_string(), - "/help".to_string(), - "/?".to_string(), - "/t".to_string(), - "/extension".to_string(), - "/builtin".to_string(), - "/mode".to_string(), - "/model".to_string(), - "/recipe".to_string(), + // Define available slash commands + let commands = [ + "/exit", + "/quit", + "/help", + "/?", + "/t", + "/extension", + "/builtin", + "/prompts", + "/prompt", + "/mode", + "/model", + "/recipe", + "/skills", ]; - commands.extend( - list_commands() - .iter() - .map(|command| format!("/{}", command.name)), - ); - commands.sort(); - commands.dedup(); // Find commands that match the prefix let matching_commands: Vec = commands @@ -581,15 +577,6 @@ mod tests { let (pos, candidates) = completer.complete_slash_commands("/").unwrap(); assert_eq!(pos, 0); assert!(candidates.len() > 1); - for command in list_commands() { - assert!( - candidates - .iter() - .any(|candidate| candidate.display == format!("/{}", command.name)), - "slash completion should list /{}", - command.name - ); - } // Test no match let (_pos, candidates) = completer.complete_slash_commands("/nonexistent").unwrap(); diff --git a/crates/goose-cli/src/session/editor.rs b/crates/goose-cli/src/session/editor.rs index 6ed3e297cc..1e42d6069a 100644 --- a/crates/goose-cli/src/session/editor.rs +++ b/crates/goose-cli/src/session/editor.rs @@ -1,10 +1,7 @@ -use anyhow::{Context, Result}; +use anyhow::Result; use goose::config::Config; -use goose::conversation::message::Message; -use goose::conversation::Conversation; use std::fs; use std::io::Read; -use std::io::Write; use std::path::PathBuf; use std::process::Command; use tempfile::Builder; @@ -24,6 +21,9 @@ pub fn resolve_editor_command() -> Option { ) } +/// Inner resolution logic, separated for testability. +/// Checks sources in priority order: config, VISUAL, EDITOR. +/// Skips empty strings at each level. fn resolve_editor_from_sources( config_editor: Option<&str>, visual: Option<&str>, @@ -37,57 +37,6 @@ fn resolve_editor_from_sources( None } -/// Resolve the editor command, falling back to vi (or notepad on Windows). -pub fn resolve_editor_or_default() -> String { - let config = Config::global(); - let config_editor = config.get_goose_prompt_editor().ok().flatten(); - let visual = std::env::var("VISUAL").ok(); - let editor_env = std::env::var("EDITOR").ok(); - resolve_editor_or_default_from_sources( - config_editor.as_deref(), - visual.as_deref(), - editor_env.as_deref(), - ) -} - -fn resolve_editor_default() -> String { - if cfg!(windows) { - "notepad".to_string() - } else { - "vi".to_string() - } -} - -fn resolve_editor_or_default_from_sources( - config_editor: Option<&str>, - visual: Option<&str>, - editor_env: Option<&str>, -) -> String { - resolve_editor_from_sources(config_editor, visual, editor_env) - .unwrap_or_else(resolve_editor_default) -} - -/// Open a YAML temp file with the user's editor to edit a conversation. -/// Returns the edited conversation, or an error if the editor failed or YAML was invalid. -pub fn edit_conversation(conversation: &Conversation) -> Result { - let yaml = serde_yaml::to_string(conversation.messages())?; - - let mut tmp = NamedTempFile::with_suffix(".yaml")?; - tmp.write_all(yaml.as_bytes())?; - tmp.flush()?; - - let editor = resolve_editor_or_default(); - let path = tmp.path().to_path_buf(); - - launch_editor(&editor, &path).with_context(|| format!("failed to launch editor '{editor}'"))?; - - let edited = std::fs::read_to_string(&path)?; - let messages: Vec = - serde_yaml::from_str(&edited).context("invalid YAML โ€” session unchanged")?; - - Ok(Conversation::new_unvalidated(messages)) -} - /// Build the markdown template content for the editor prompt. fn build_template(messages: &[&str], prefill: Option<&str>) -> String { let mut content = String::from("# Goose Prompt Editor\n\n"); @@ -135,36 +84,21 @@ impl SymlinkCleanup { impl Drop for SymlinkCleanup { fn drop(&mut self) { + // Always try to clean up the symlink, ignoring any errors let _ = std::fs::remove_file(&self.symlink_path); } } -/// Split an editor command into program and arguments. -/// -/// Uses shell-word splitting only when the command contains quotes, so values like -/// `"/Applications/Sublime Text.app/.../subl" -w` work. Unquoted commands are split on -/// whitespace to avoid shlex stripping backslashes from Windows paths like -/// `C:\Windows\System32\notepad.exe`. -fn split_editor_command(editor_cmd: &str) -> Result> { - if editor_cmd.contains(['"', '\'']) { - shlex::split(editor_cmd).ok_or_else(|| { - anyhow::anyhow!("Invalid editor command: unmatched quotes in '{editor_cmd}'") - }) - } else { - Ok(editor_cmd.split_whitespace().map(String::from).collect()) - } -} - /// Launch editor and wait for completion fn launch_editor(editor_cmd: &str, file_path: &PathBuf) -> Result<()> { use std::process::Stdio; - let parts = split_editor_command(editor_cmd)?; + let parts: Vec<&str> = editor_cmd.split_whitespace().collect(); if parts.is_empty() { return Err(anyhow::anyhow!("Empty editor command")); } - let mut cmd = Command::new(&parts[0]); + let mut cmd = Command::new(parts[0]); if let Ok(cwd) = std::env::current_dir() { cmd.current_dir(cwd); } @@ -480,64 +414,54 @@ with multiple lines. ); } + // --- resolve_editor_from_sources tests --- + #[test] - fn test_resolve_editor_resolution_priority() { - assert_eq!( - resolve_editor_from_sources(Some("config-val"), Some("visual-val"), Some("editor-val")), - Some("config-val".to_string()) - ); - - assert_eq!( - resolve_editor_from_sources(Some(""), Some("visual-val"), Some("editor-val")), - Some("visual-val".to_string()) - ); - - assert_eq!( - resolve_editor_from_sources(None, Some(""), Some("editor-val")), - Some("editor-val".to_string()) - ); - - assert_eq!(resolve_editor_from_sources(None, None, None), None); - assert_eq!( - resolve_editor_from_sources(Some(""), Some(""), Some("")), - None - ); - - let default_val = resolve_editor_default(); - assert_eq!( - resolve_editor_or_default_from_sources(None, None, None), - default_val - ); - assert_eq!( - resolve_editor_or_default_from_sources(Some(""), Some(""), Some("")), - default_val - ); + fn test_resolve_editor_returns_config_when_set() { + let result = resolve_editor_from_sources(Some("code"), Some("vim"), Some("nano")); + assert_eq!(result.as_deref(), Some("code")); } #[test] - fn test_split_editor_command() { - assert_eq!( - split_editor_command("code --wait").unwrap(), - vec!["code", "--wait"] - ); + fn test_resolve_editor_falls_back_to_visual() { + let result = resolve_editor_from_sources(None, Some("vim"), Some("nano")); + assert_eq!(result.as_deref(), Some("vim")); + } - assert_eq!( - split_editor_command( - r#""/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl" -w"# - ) - .unwrap(), - vec![ - "/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl", - "-w" - ] - ); + #[test] + fn test_resolve_editor_falls_back_to_editor_env() { + let result = resolve_editor_from_sources(None, None, Some("nano")); + assert_eq!(result.as_deref(), Some("nano")); + } - assert_eq!( - split_editor_command(r"C:\Windows\System32\notepad.exe").unwrap(), - vec![r"C:\Windows\System32\notepad.exe"] - ); + #[test] + fn test_resolve_editor_returns_none_when_nothing_set() { + let result = resolve_editor_from_sources(None, None, None); + assert_eq!(result, None); + } - assert!(split_editor_command(r#"code --wait "unclosed"#).is_err()); + #[test] + fn test_resolve_editor_skips_empty_config() { + let result = resolve_editor_from_sources(Some(""), Some("vim"), None); + assert_eq!(result.as_deref(), Some("vim")); + } + + #[test] + fn test_resolve_editor_skips_empty_visual() { + let result = resolve_editor_from_sources(None, Some(""), Some("nano")); + assert_eq!(result.as_deref(), Some("nano")); + } + + #[test] + fn test_resolve_editor_skips_all_empty() { + let result = resolve_editor_from_sources(Some(""), Some(""), Some("")); + assert_eq!(result, None); + } + + #[test] + fn test_resolve_editor_skips_empty_config_and_visual() { + let result = resolve_editor_from_sources(Some(""), Some(""), Some("emacs")); + assert_eq!(result.as_deref(), Some("emacs")); } // --- build_template edge case tests --- @@ -545,7 +469,9 @@ with multiple lines. #[test] fn test_build_template_empty_prefill_string() { let content = build_template(&["## User: Hello"], Some("")); + // Empty prefill should not appear in content assert!(content.contains("# Your prompt:\n\n#")); + // Should go directly to conversation context assert!(content.contains("# Recent conversation for context")); } @@ -572,8 +498,11 @@ with multiple lines. assert!(prefill_pos < context_pos); } + // --- extract_user_input with prefilled content tests --- + #[test] fn test_extract_user_input_with_prefill_kept() { + // Simulates a user who opened the editor with prefill and kept it unchanged let content = build_template(&["## User: Hello"], Some("fix the login bug")); let result = extract_user_input(&content); assert_eq!(result, "fix the login bug"); @@ -581,6 +510,7 @@ with multiple lines. #[test] fn test_extract_user_input_with_prefill_edited() { + // Simulates a user who edited the prefill text let mut content = build_template(&["## User: Hello"], Some("fix the login bug")); content = content.replace( "fix the login bug", @@ -592,6 +522,7 @@ with multiple lines. #[test] fn test_extract_user_input_prefill_replaced() { + // Simulates a user who deleted the prefill and wrote something new let mut content = build_template(&["## User: Hello"], Some("fix the login bug")); content = content.replace("fix the login bug\n", "completely different prompt\n"); let result = extract_user_input(&content); @@ -600,6 +531,7 @@ with multiple lines. #[test] fn test_extract_user_input_prefill_cleared() { + // Simulates a user who deleted the prefill and left nothing let mut content = build_template(&["## User: Hello"], Some("fix the login bug")); content = content.replace("fix the login bug\n", ""); let result = extract_user_input(&content); diff --git a/crates/goose-cli/src/session/elicitation.rs b/crates/goose-cli/src/session/elicitation.rs index 012295ff96..ef4930d76e 100644 --- a/crates/goose-cli/src/session/elicitation.rs +++ b/crates/goose-cli/src/session/elicitation.rs @@ -1,15 +1,12 @@ use console::style; -use rmcp::model::ElicitationAction; use serde_json::Value; use std::collections::HashMap; use std::io::{self, BufRead, IsTerminal, Write}; -pub struct ElicitationInput { - pub action: ElicitationAction, - pub user_data: HashMap, -} - -pub fn collect_elicitation_input(message: &str, schema: &Value) -> io::Result { +pub fn collect_elicitation_input( + message: &str, + schema: &Value, +) -> io::Result>> { if !message.is_empty() { println!("\n{}", style(message).cyan()); } @@ -27,18 +24,9 @@ pub fn collect_elicitation_input(message: &str, schema: &Value) -> io::Result Ok(ElicitationInput { - action: ElicitationAction::Accept, - user_data: HashMap::new(), - }), - Ok(false) => Ok(ElicitationInput { - action: ElicitationAction::Decline, - user_data: HashMap::new(), - }), - Err(e) if e.kind() == io::ErrorKind::Interrupted => Ok(ElicitationInput { - action: ElicitationAction::Cancel, - user_data: HashMap::new(), - }), + Ok(true) => Ok(Some(HashMap::new())), + Ok(false) => Ok(None), + Err(e) if e.kind() == io::ErrorKind::Interrupted => Ok(None), Err(e) => Err(e), }; } @@ -77,12 +65,7 @@ pub fn collect_elicitation_input(message: &str, schema: &Value) -> io::Result { data.insert(name.clone(), Value::Bool(v)); } - Err(e) if e.kind() == io::ErrorKind::Interrupted => { - return Ok(ElicitationInput { - action: ElicitationAction::Cancel, - user_data: HashMap::new(), - }); - } + Err(e) if e.kind() == io::ErrorKind::Interrupted => return Ok(None), Err(e) => return Err(e), } continue; @@ -110,10 +93,7 @@ pub fn collect_elicitation_input(message: &str, schema: &Value) -> io::Result io::Result io::Result> { diff --git a/crates/goose-cli/src/session/export.rs b/crates/goose-cli/src/session/export.rs index f4969e6803..9f6b509a8a 100644 --- a/crates/goose-cli/src/session/export.rs +++ b/crates/goose-cli/src/session/export.rs @@ -353,7 +353,7 @@ pub fn message_to_markdown(message: &Message, export_all_content: bool) -> Strin message )); } - ActionRequiredData::ElicitationResponse { id, user_data, .. } => { + ActionRequiredData::ElicitationResponse { id, user_data } => { md.push_str(&format!( "**Action Required** (elicitation_response): {}\n```json\n{}\n```\n\n", id, diff --git a/crates/goose-cli/src/session/input.rs b/crates/goose-cli/src/session/input.rs index 6b9da991af..729428b294 100644 --- a/crates/goose-cli/src/session/input.rs +++ b/crates/goose-cli/src/session/input.rs @@ -401,17 +401,10 @@ fn parse_plan_command(input: String) -> Option { Some(InputResult::Plan(options)) } -fn help_text() -> String { +fn print_help() { let newline_key = get_newline_key().to_ascii_uppercase(); let modes = GooseMode::VARIANTS.join(", "); - let additional_builtin_help = additional_builtin_help(); - let additional_builtin_help = if additional_builtin_help.is_empty() { - String::new() - } else { - format!("{additional_builtin_help}\n") - }; - - format!( + println!( "Available commands: /exit or /quit - Exit the session /t - Toggle Light/Dark/Ansi theme @@ -432,7 +425,6 @@ fn help_text() -> String { /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 the current conversation to reduce context length while preserving key information. -{additional_builtin_help}/status - Show session status: model, provider, mode, and token usage. /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 [...]) @@ -443,23 +435,7 @@ Navigation: Ctrl+C - Clear current line if text is entered, otherwise exit the session Ctrl+{newline_key} - Add a newline (configurable via GOOSE_CLI_NEWLINE_KEY) Up/Down arrows - Navigate through command history" - ) -} - -fn additional_builtin_help() -> String { - const DOCUMENTED_BUILTINS: &[&str] = - &["prompts", "prompt", "compact", "clear", "skills", "status"]; - - goose::agents::execute_commands::list_commands() - .iter() - .filter(|command| !DOCUMENTED_BUILTINS.contains(&command.name)) - .map(|command| format!("/{} - {}", command.name, command.description)) - .collect::>() - .join("\n") -} - -fn print_help() { - println!("{}", help_text()); + ); } /// Extract recent messages for editor context @@ -558,19 +534,6 @@ mod tests { assert!(handle_slash_command("/unknown").is_none()); } - #[test] - fn help_lists_builtin_agent_commands() { - let help = help_text(); - - for command in goose::agents::execute_commands::list_commands() { - assert!( - help.contains(&format!("/{}", command.name)), - "help output should list /{}", - command.name - ); - } - } - #[test] fn test_prompts_command() { // Test basic prompts command diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 1c02ececb9..cd7e63b740 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -1,6 +1,6 @@ mod builder; mod completion; -pub mod editor; +mod editor; mod elicitation; mod export; mod input; @@ -13,7 +13,6 @@ use crate::session::task_execution_display::{ format_task_execution_notification, TASK_EXECUTION_NOTIFICATION_TYPE, }; use goose::conversation::Conversation; -use std::env; use std::io::Write; use std::str::FromStr; use tokio::signal::ctrl_c; @@ -28,7 +27,6 @@ use goose::permission::permission_confirmation::PrincipalType; use goose::permission::Permission; use goose::permission::PermissionConfirmation; use goose::providers::base::Provider; -use goose::providers::base::ProviderUsage; use goose::utils::safe_truncate; use anyhow::{Context, Result}; @@ -39,8 +37,8 @@ use goose::agents::{Agent, SessionConfig, COMPACT_TRIGGERS}; use goose::config::extensions::name_to_key; use goose::config::{Config, GooseMode}; use input::InputResult; +use rmcp::model::PromptMessage; use rmcp::model::ServerNotification; -use rmcp::model::{ElicitationAction, PromptMessage}; use rmcp::model::{ErrorCode, ErrorData}; use strum::VariantNames; @@ -53,13 +51,11 @@ use std::collections::{HashMap, HashSet}; use std::io::IsTerminal; use std::path::PathBuf; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Instant; use tokio; use tokio_util::sync::CancellationToken; use tracing::warn; -const GOOSE_PLANNER_CONTEXT_LIMIT: &str = "GOOSE_PLANNER_CONTEXT_LIMIT"; - #[derive(Serialize, Deserialize, Debug)] struct JsonOutput { messages: Vec, @@ -176,7 +172,6 @@ pub struct CliSession { edit_mode: Option, retry_config: Option, output_format: String, - stats: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -219,23 +214,22 @@ pub async fn classify_planner_response( session_id: &str, message_text: String, provider: Arc, - model_config: goose_providers::model::ModelConfig, ) -> Result { let prompt = format!( "The text below is the output from an AI model which can either provide a plan or list of clarifying questions. Based on the text below, decide if the output is a \"plan\" or \"clarifying questions\".\n---\n{message_text}" ); let message = Message::user().with_text(&prompt); - let (result, _usage) = goose::session_context::with_session_id( - Some(session_id.to_string()), - provider.complete( + let model_config = provider.get_model_config(); + let (result, _usage) = provider + .complete( &model_config, + session_id, "Reply only with the classification label: \"plan\" or \"clarifying questions\"", &[message], &[], - ), - ) - .await?; + ) + .await?; let predicted = result.as_concat_text(); if predicted.to_lowercase().contains("plan") { @@ -256,7 +250,6 @@ impl CliSession { edit_mode: Option, retry_config: Option, output_format: String, - stats: bool, ) -> Self { let messages = agent .config @@ -278,7 +271,6 @@ impl CliSession { edit_mode, retry_config, output_format, - stats, } } @@ -320,7 +312,6 @@ impl CliSession { env_keys: Vec::new(), description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), - cwd: None, bundled: None, available_tools: Vec::new(), }) @@ -487,6 +478,10 @@ impl CliSession { /// Start an interactive session, optionally with an initial message pub async fn interactive(&mut self, prompt: Option) -> Result<()> { + self.agent + .emit_hook(goose::hooks::HookEvent::SessionStart, &self.session_id) + .await; + let result = self.run_interactive(prompt).await; self.agent @@ -652,8 +647,6 @@ impl CliSession { prefill.as_deref(), ) { Ok((message, true)) => { - editor.add_history_entry(message.as_str())?; - history.save(editor); self.handle_message_input(&message, history, editor).await?; } Ok((_, false)) => {} @@ -721,8 +714,8 @@ impl CliSession { RunMode::Plan => { let mut plan_messages = self.messages.clone(); plan_messages.push(Message::user().with_text(content)); - let (reasoner, reasoner_model_config) = get_reasoner().await?; - self.plan_with_reasoner_model(plan_messages, reasoner, reasoner_model_config) + let reasoner = get_reasoner().await?; + self.plan_with_reasoner_model(plan_messages, reasoner) .await?; } } @@ -809,10 +802,7 @@ impl CliSession { async fn handle_model(&self, model: Option<&str>) -> Result<()> { let provider = self.agent.provider().await?; let current_provider_name = provider.get_name().to_string(); - let current_model_config = self - .agent - .model_config_for_session(&self.session_id) - .await?; + let current_model_config = provider.get_model_config(); let current_model_name = current_model_config.model_name.clone(); if model.is_none() { @@ -847,11 +837,8 @@ impl CliSession { let new_model_config = build_switched_model_config(¤t_provider_name, model_name, ¤t_model_config)?; - let configured_effort = Config::global().get_goose_thinking_effort(); - let new_effort = new_model_config.thinking_effort().or(configured_effort); - let current_effort = current_model_config.thinking_effort().or(configured_effort); if new_model_config.model_name == current_model_config.model_name - && new_effort == current_effort + && new_model_config.thinking_effort() == current_model_config.thinking_effort() { output::goose_mode_message(&format!( "Session already using model '{}' for provider '{}'", @@ -861,12 +848,13 @@ impl CliSession { } let extensions = self.agent.get_extension_configs().await; - let new_provider = goose::providers::create(¤t_provider_name, extensions) - .await - .map_err(|e| anyhow::anyhow!("Failed to create provider: {e}"))?; + let new_provider = + goose::providers::create(¤t_provider_name, new_model_config, extensions) + .await + .map_err(|e| anyhow::anyhow!("Failed to create provider: {e}"))?; self.agent - .update_provider(new_provider, new_model_config, &self.session_id) + .update_provider(new_provider, &self.session_id) .await?; let mode = self.agent.goose_mode().await; @@ -889,9 +877,8 @@ impl CliSession { let mut plan_messages = self.messages.clone(); plan_messages.push(Message::user().with_text(&options.message_text)); - let (reasoner, reasoner_model_config) = get_reasoner().await?; - self.plan_with_reasoner_model(plan_messages, reasoner, reasoner_model_config) - .await + let reasoner = get_reasoner().await?; + self.plan_with_reasoner_model(plan_messages, reasoner).await } async fn handle_clear(&mut self) -> Result<()> { @@ -911,11 +898,9 @@ impl CliSession { .config .session_manager .update(&self.session_id) - .usage(goose_providers::conversation::token_usage::Usage::new( - Some(0), - Some(0), - Some(0), - )) + .total_tokens(Some(0)) + .input_tokens(Some(0)) + .output_tokens(Some(0)) .apply() .await { @@ -1053,24 +1038,25 @@ impl CliSession { &mut self, plan_messages: Conversation, reasoner: Arc, - model_config: goose_providers::model::ModelConfig, ) -> Result<(), anyhow::Error> { let plan_prompt = self.agent.get_plan_prompt(&self.session_id).await?; output::show_thinking(); - let (plan_response, _usage) = goose::session_context::with_session_id( - Some(self.session_id.clone()), - reasoner.complete(&model_config, &plan_prompt, plan_messages.messages(), &[]), - ) - .await?; + let model_config = reasoner.get_model_config(); + let (plan_response, _usage) = reasoner + .complete( + &model_config, + &self.session_id, + &plan_prompt, + plan_messages.messages(), + &[], + ) + .await?; output::render_message(&plan_response, self.debug); output::hide_thinking(); let planner_response_type = classify_planner_response( &self.session_id, plan_response.as_concat_text(), self.agent.provider().await?, - self.agent - .model_config_for_session(&self.session_id) - .await?, ) .await?; @@ -1135,6 +1121,9 @@ impl CliSession { /// Process a single message and exit pub async fn headless(&mut self, prompt: String) -> Result<()> { + self.agent + .emit_hook(goose::hooks::HookEvent::SessionStart, &self.session_id) + .await; let message = Message::user().with_text(&prompt); let result = self .process_message(message, CancellationToken::default(), false) @@ -1187,9 +1176,6 @@ impl CliSession { let mut markdown_buffer = streaming_buffer::MarkdownBuffer::new(); let mut prompted_credits_urls: HashSet = HashSet::new(); let mut thinking_header_shown = false; - let run_started = Instant::now(); - let mut first_token_at: Option = None; - let mut last_usage: Option = None; use futures::StreamExt; loop { @@ -1197,9 +1183,6 @@ impl CliSession { result = stream.next() => { match result { Some(Ok(AgentEvent::Message(message))) => { - if first_token_at.is_none() && message_has_text(&message) { - first_token_at = Some(Instant::now()); - } if let Some((id, security_prompt)) = find_tool_confirmation(&message) { let permission = if interactive { prompt_tool_confirmation(&security_prompt)? @@ -1265,37 +1248,25 @@ impl CliSession { let _ = progress_bars.hide(); match elicitation::collect_elicitation_input(&elicitation_message, &schema) { - Ok(input) => { - match &input.action { - ElicitationAction::Decline => { - output::render_text("Information request declined.", Some(Color::Yellow), true); - } - ElicitationAction::Cancel => { - output::render_text("Information request cancelled.", Some(Color::Yellow), true); - } - ElicitationAction::Accept => {} - } - - let should_cancel = input.action == ElicitationAction::Cancel; - let action = input.action; - let user_data_value = serde_json::to_value(input.user_data) + Ok(Some(user_data)) => { + let user_data_value = serde_json::to_value(user_data) .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); let response_message = Message::user() .with_content(MessageContent::action_required_elicitation_response( elicitation_id, user_data_value, - action, )) .with_visibility(false, true); self.messages.push(response_message.clone()); // Elicitation responses return an empty stream - the response // unblocks the waiting tool call via ActionRequiredManager let _ = self.agent.reply(response_message, session_config.clone(), Some(cancel_token.clone())).await?; - if should_cancel { - cancel_token_clone.cancel(); - drop(stream); - break; - } + } + Ok(None) => { + output::render_text("Information request cancelled.", Some(Color::Yellow), true); + cancel_token_clone.cancel(); + drop(stream); + break; } Err(e) => { output::render_error(&format!("Failed to collect input: {}", e)); @@ -1323,10 +1294,6 @@ impl CliSession { } } } - Some(Ok(AgentEvent::Usage(usage))) => { - last_usage = Some(usage); - } - Some(Ok(AgentEvent::MessageUsage { .. })) => {} Some(Ok(AgentEvent::McpNotification((extension_id, notification)))) => { handle_mcp_notification( &extension_id, @@ -1383,18 +1350,9 @@ impl CliSession { .await { Ok(session) => JsonMetadata { - total_tokens: session - .accumulated_usage - .total_tokens - .or(session.usage.total_tokens), - input_tokens: session - .accumulated_usage - .input_tokens - .or(session.usage.input_tokens), - output_tokens: session - .accumulated_usage - .output_tokens - .or(session.usage.output_tokens), + total_tokens: session.accumulated_total_tokens.or(session.total_tokens), + input_tokens: session.accumulated_input_tokens.or(session.input_tokens), + output_tokens: session.accumulated_output_tokens.or(session.output_tokens), status: "completed".to_string(), }, Err(_) => JsonMetadata { @@ -1419,9 +1377,9 @@ impl CliSession { .ok(); let (total_tokens, input_tokens, output_tokens) = match session { Some(s) => ( - s.accumulated_usage.total_tokens.or(s.usage.total_tokens), - s.accumulated_usage.input_tokens.or(s.usage.input_tokens), - s.accumulated_usage.output_tokens.or(s.usage.output_tokens), + s.accumulated_total_tokens.or(s.total_tokens), + s.accumulated_input_tokens.or(s.input_tokens), + s.accumulated_output_tokens.or(s.output_tokens), ), None => (None, None, None), }; @@ -1432,9 +1390,6 @@ impl CliSession { }); } else { println!(); - if self.stats { - print_run_stats(run_started, first_token_at, last_usage.as_ref()); - } } Ok(()) @@ -1589,20 +1544,14 @@ impl CliSession { pub async fn get_total_token_usage(&self) -> Result> { let metadata = self.get_session().await?; - Ok(metadata.accumulated_usage.total_tokens) + Ok(metadata.accumulated_total_tokens) } /// Display enhanced context usage with session totals pub async fn display_context_usage(&self) -> Result<()> { let provider = self.agent.provider().await?; - let model_config = self - .agent - .model_config_for_session(&self.session_id) - .await?; - let context_limit = provider - .get_context_limit(&model_config) - .await - .unwrap_or_else(|_| model_config.context_limit()); + let model_config = provider.get_model_config(); + let context_limit = model_config.context_limit(); let config = Config::global(); let show_cost = config @@ -1615,15 +1564,18 @@ impl CliSession { match self.get_session().await { Ok(metadata) => { - let total_tokens = metadata.usage.total_tokens.unwrap_or(0) as usize; + let total_tokens = metadata.total_tokens.unwrap_or(0) as usize; output::display_context_usage(total_tokens, context_limit); if show_cost { + let input_tokens = metadata.input_tokens.unwrap_or(0) as usize; + let output_tokens = metadata.output_tokens.unwrap_or(0) as usize; output::display_cost_usage( &provider_name, &model_config.model_name, - &metadata.usage, + input_tokens, + output_tokens, ); } } @@ -1757,87 +1709,6 @@ impl CliSession { } } -fn message_has_text(message: &Message) -> bool { - message.content.iter().any( - |content| matches!(content, MessageContent::Text(text) if !text.text.trim().is_empty()), - ) -} - -fn print_run_stats( - run_started: Instant, - first_token_at: Option, - usage: Option<&ProviderUsage>, -) { - let elapsed = run_started.elapsed(); - let stats = usage.and_then(|usage| usage.stats.as_ref()); - let generation_elapsed = stats - .and_then(|stats| stats.elapsed_ms) - .map(Duration::from_millis); - let output_tokens = usage - .and_then(|usage| usage.usage.output_tokens) - .and_then(|tokens| usize::try_from(tokens).ok()) - .or_else(|| stats.and_then(|stats| stats.output_tokens)); - let tokens_per_second = output_tokens.map(|tokens| { - let rate_elapsed = generation_elapsed.unwrap_or(elapsed); - if rate_elapsed.as_secs_f64() > 0.0 { - tokens as f64 / rate_elapsed.as_secs_f64() - } else { - 0.0 - } - }); - let model_load_ms = stats.and_then(|stats| stats.model_load_ms); - let generation_time_to_first_token_ms = stats.and_then(|stats| stats.time_to_first_token_ms); - - eprintln!("\nStats:"); - if let Some(ms) = model_load_ms { - eprintln!(" Model load: {:.2}s", ms as f64 / 1000.0); - } - if model_load_ms.is_some() { - match generation_time_to_first_token_ms { - Some(ms) => eprintln!( - " Generation time to first token: {:.2}s", - ms as f64 / 1000.0 - ), - None => eprintln!(" Generation time to first token: unavailable"), - } - match first_token_at { - Some(first) => eprintln!( - " End-to-end time to first token: {:.2}s", - first.duration_since(run_started).as_secs_f64() - ), - None => eprintln!(" End-to-end time to first token: unavailable"), - } - } else if let Some(ms) = generation_time_to_first_token_ms { - eprintln!(" Time to first token: {:.2}s", ms as f64 / 1000.0); - } else { - match first_token_at { - Some(first) => eprintln!( - " Time to first token: {:.2}s", - first.duration_since(run_started).as_secs_f64() - ), - None => eprintln!(" Time to first token: unavailable"), - } - } - match tokens_per_second { - Some(rate) => eprintln!(" Tokens/sec: {:.2}", rate), - None => eprintln!(" Tokens/sec: unavailable"), - } - if let Some(tokens) = output_tokens { - eprintln!(" Output tokens: {tokens}"); - } - - if let Some(draft) = stats.and_then(|stats| stats.draft.as_ref()) { - eprintln!(" Draft accept rate: {:.1}%", draft.accept_rate * 100.0); - eprintln!( - " Draft tokens: {} accepted: {} target verified: {} rounds: {}", - draft.draft_tokens, draft.accepted_tokens, draft.target_tokens, draft.rounds - ); - if let Some(model) = &draft.model { - eprintln!(" Draft model: {model}"); - } - } -} - fn maybe_open_credits_top_up_url( message: &Message, interactive: bool, @@ -2220,11 +2091,11 @@ fn handle_agent_error(e: &anyhow::Error, is_stream_json_mode: bool) { }); } - if e.downcast_ref::() + if e.downcast_ref::() .map(|provider_error| { matches!( provider_error, - goose_providers::errors::ProviderError::ContextLengthExceeded(_) + goose::providers::errors::ProviderError::ContextLengthExceeded(_) ) }) .unwrap_or(false) @@ -2244,8 +2115,8 @@ fn handle_agent_error(e: &anyhow::Error, is_stream_json_mode: bool) { } } -async fn get_reasoner( -) -> Result<(Arc, goose_providers::model::ModelConfig), anyhow::Error> { +async fn get_reasoner() -> Result, anyhow::Error> { + use goose::model::ModelConfig; use goose::providers::create; let config = Config::global(); @@ -2270,23 +2141,12 @@ async fn get_reasoner( .expect("No model configured. Run 'goose configure' first") }; - let planner_context_limit = match env::var(GOOSE_PLANNER_CONTEXT_LIMIT) - .ok() - .map(|v| v.parse::()) - { - Some(Ok(n)) if n >= 4096 => Some(n), - Some(Ok(_)) => anyhow::bail!("{} must be at least 4096", GOOSE_PLANNER_CONTEXT_LIMIT), - Some(Err(e)) => anyhow::bail!("{}: {}", GOOSE_PLANNER_CONTEXT_LIMIT, e), - None => None, - }; - let model_config = - goose::model_config::model_config_from_user_config(&provider, model.as_str())? - .with_context_limit(planner_context_limit); + ModelConfig::new_with_context_env(model, &provider, Some("GOOSE_PLANNER_CONTEXT_LIMIT"))?; let extensions = goose::config::extensions::get_enabled_extensions_with_config(config); - let reasoner = create(&provider, extensions).await?; + let reasoner = create(&provider, model_config, extensions).await?; - Ok((reasoner, model_config)) + Ok(reasoner) } /// Format elapsed time duration @@ -2305,11 +2165,12 @@ fn format_elapsed_time(duration: std::time::Duration) -> String { fn build_switched_model_config( provider_name: &str, model_name: &str, - current_model_config: &goose_providers::model::ModelConfig, -) -> Result { - goose::model_config::model_config_from_user_config(provider_name, model_name) + current_model_config: &goose::model::ModelConfig, +) -> Result { + goose::model::ModelConfig::new(model_name) .map(|config| { config + .with_canonical_limits(provider_name) .with_temperature(current_model_config.temperature) .with_toolshim(current_model_config.toolshim) .with_toolshim_model(current_model_config.toolshim_model.clone()) @@ -2402,7 +2263,6 @@ mod tests { env_keys: vec![], description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), - cwd: None, bundled: None, available_tools: vec![], } @@ -2418,7 +2278,6 @@ mod tests { env_keys: vec![], description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), - cwd: None, bundled: None, available_tools: vec![], } @@ -2434,7 +2293,6 @@ mod tests { env_keys: vec![], description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), - cwd: None, bundled: None, available_tools: vec![], } @@ -2459,13 +2317,14 @@ mod tests { ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), ]); - let current_model_config = goose_providers::model::ModelConfig { + let current_model_config = goose::model::ModelConfig { model_name: "gpt-4o".to_string(), context_limit: Some(128_000), temperature: Some(0.25), max_tokens: Some(16_384), toolshim: true, toolshim_model: Some("qwen2.5-coder".to_string()), + fast_model_config: None, request_params: Some(HashMap::from([( "anthropic_beta".to_string(), serde_json::json!(["output-128k-2025-02-19"]), @@ -2475,7 +2334,7 @@ mod tests { let switched = build_switched_model_config("openai", "gpt-5.4", ¤t_model_config).unwrap(); - let expected = goose_providers::model::ModelConfig::new("gpt-5.4") + let expected = goose::model::ModelConfig::new_or_fail("gpt-5.4") .with_canonical_limits("openai") .with_temperature(Some(0.25)) .with_toolshim(true) @@ -2502,12 +2361,12 @@ mod tests { ("GOOSE_THINKING_EFFORT", None::<&str>), ]); - let current = goose_providers::model::ModelConfig::new("gpt-5.4-high") - .with_canonical_limits("openai"); + let current = + goose::model::ModelConfig::new_or_fail("gpt-5.4-high").with_canonical_limits("openai"); assert_eq!(current.model_name, "gpt-5.4"); assert_eq!( current.thinking_effort(), - Some(goose_providers::thinking::ThinkingEffort::High) + Some(goose::model::ThinkingEffort::High) ); let switched = build_switched_model_config("openai", "gpt-5.4", ¤t).unwrap(); diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 17648a0892..b0c71e2b42 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -10,7 +10,6 @@ use goose::providers::canonical::maybe_get_canonical_model; #[cfg(target_os = "windows")] use goose::subprocess::SubprocessExt; use goose::utils::safe_truncate; -use goose_providers::conversation::token_usage::Usage; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use rmcp::model::{CallToolRequestParams, JsonObject, PromptArgument}; use serde_json::Value; @@ -248,8 +247,7 @@ pub fn render_message(message: &Message, debug: bool) { } MessageContent::SystemNotification(notification) => { match notification.notification_type { - SystemNotificationType::ThinkingMessage - | SystemNotificationType::ProgressMessage => { + SystemNotificationType::ThinkingMessage => { show_thinking(); set_thinking_message(¬ification.msg); } @@ -331,8 +329,7 @@ pub fn render_message_streaming( } MessageContent::SystemNotification(notification) => { match notification.notification_type { - SystemNotificationType::ThinkingMessage - | SystemNotificationType::ProgressMessage => { + SystemNotificationType::ThinkingMessage => { show_thinking(); set_thinking_message(¬ification.msg); } @@ -1417,33 +1414,31 @@ pub fn display_context_usage(total_tokens: usize, context_limit: usize) { ); } -fn estimate_cost_usd(provider: &str, model: &str, usage: &Usage) -> Option { +fn estimate_cost_usd( + provider: &str, + model: &str, + input_tokens: usize, + output_tokens: usize, +) -> Option { let canonical_model = maybe_get_canonical_model(provider, model)?; - canonical_model.cost.estimate_cost(usage) + + let input_cost_per_token = canonical_model.cost.input? / 1_000_000.0; + let output_cost_per_token = canonical_model.cost.output? / 1_000_000.0; + + let input_cost = input_cost_per_token * input_tokens as f64; + let output_cost = output_cost_per_token * output_tokens as f64; + Some(input_cost + output_cost) } /// Display cost information, if price data is available. -pub fn display_cost_usage(provider: &str, model: &str, usage: &Usage) { - if let Some(cost) = estimate_cost_usd(provider, model, usage) { +pub fn display_cost_usage(provider: &str, model: &str, input_tokens: usize, output_tokens: usize) { + if let Some(cost) = estimate_cost_usd(provider, model, input_tokens, output_tokens) { use console::style; - let input_tokens = usage.input_tokens.unwrap_or(0); - let output_tokens = usage.output_tokens.unwrap_or(0); - let cache_read = usage.cache_read_input_tokens.unwrap_or(0); - let cache_write = usage.cache_write_input_tokens.unwrap_or(0); - - let cache_breakdown = match (cache_read, cache_write) { - (0, 0) => String::new(), - (read, 0) => format!(" ({} cache read)", read), - (0, write) => format!(" ({} cache write)", write), - (read, write) => format!(" ({} cache read, {} cache write)", read, write), - }; - eprintln!( - "Cost: {} USD ({} tokens: in {}{}, out {})", + "Cost: {} USD ({} tokens: in {}, out {})", style(format!("${:.4}", cost)).cyan(), input_tokens + output_tokens, input_tokens, - cache_breakdown, output_tokens ); } diff --git a/crates/goose-cli/src/session/streaming_buffer.rs b/crates/goose-cli/src/session/streaming_buffer.rs index cb42cce61e..ac8ddbd95f 100644 --- a/crates/goose-cli/src/session/streaming_buffer.rs +++ b/crates/goose-cli/src/session/streaming_buffer.rs @@ -524,8 +524,10 @@ impl MarkdownBuffer { } } "]" => {} - ")" if state.in_link_url => { - state.in_link_url = false; + ")" => { + if state.in_link_url { + state.in_link_url = false; + } } _ => {} } diff --git a/crates/goose-cli/src/session/thinking.rs b/crates/goose-cli/src/session/thinking.rs index d4948b55c9..6b36c756bc 100644 --- a/crates/goose-cli/src/session/thinking.rs +++ b/crates/goose-cli/src/session/thinking.rs @@ -1,4 +1,4 @@ -use rand::seq::IndexedRandom; +use rand::seq::SliceRandom; /// Extended list of playful thinking messages including both goose and general AI actions const THINKING_MESSAGES: &[&str] = &[ @@ -215,6 +215,6 @@ const THINKING_MESSAGES: &[&str] = &[ /// Returns a random thinking message from the extended list pub fn get_random_thinking_message() -> &'static str { THINKING_MESSAGES - .choose(&mut rand::rng()) + .choose(&mut rand::thread_rng()) .unwrap_or(&THINKING_MESSAGES[0]) } diff --git a/crates/goose-download-manager/Cargo.toml b/crates/goose-download-manager/Cargo.toml deleted file mode 100644 index e0b99e8d73..0000000000 --- a/crates/goose-download-manager/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "goose-download-manager" -version = "0.1.0-alpha.0" -edition.workspace = true -rust-version.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -description.workspace = true - -[lints] -workspace = true - -[dependencies] -anyhow = { workspace = true } -once_cell = { workspace = true } -reqwest = { workspace = true, features = ["stream"] } -serde = { workspace = true } -tokio = { workspace = true, features = ["fs", "io-util", "time"] } -tracing = { workspace = true } -utoipa = { workspace = true } diff --git a/crates/goose-download-manager/src/lib.rs b/crates/goose-download-manager/src/lib.rs deleted file mode 100644 index 89942e527c..0000000000 --- a/crates/goose-download-manager/src/lib.rs +++ /dev/null @@ -1,691 +0,0 @@ -use anyhow::Result; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; -use tokio::io::AsyncWriteExt; -use tracing::info; -use utoipa::ToSchema; - -fn partial_path_for(destination: &Path) -> PathBuf { - destination.with_extension( - destination - .extension() - .map(|e| format!("{}.part", e.to_string_lossy())) - .unwrap_or_else(|| "part".to_string()), - ) -} - -/// Remove orphaned `.part` files in the given directory (and one level of subdirectories). -/// Preserves `.part` files whose final destination is in `registered_paths` so that -/// in-progress shard downloads can resume after a restart. -pub fn cleanup_partial_downloads( - dir: &Path, - registered_paths: &std::collections::HashSet, -) { - let should_keep = |part_path: &Path| -> bool { - // Derive the final path by stripping the trailing ".part" extension - let final_path = part_path.with_extension(""); - registered_paths.contains(&final_path) - }; - - if let Ok(entries) = std::fs::read_dir(dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().is_some_and(|e| e == "part") && !should_keep(&path) { - let _ = std::fs::remove_file(&path); - } - if path.is_dir() { - if let Ok(sub_entries) = std::fs::read_dir(&path) { - for sub in sub_entries.flatten() { - let sub_path = sub.path(); - if sub_path.extension().is_some_and(|e| e == "part") - && !should_keep(&sub_path) - { - let _ = std::fs::remove_file(&sub_path); - } - } - } - } - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct DownloadProgress { - /// Model ID being downloaded - pub model_id: String, - /// Download status - pub status: DownloadStatus, - /// Bytes downloaded so far - pub bytes_downloaded: u64, - /// Total bytes to download - pub total_bytes: u64, - /// Download progress percentage (0-100) - pub progress_percent: f32, - /// Download speed in bytes per second - pub speed_bps: Option, - /// Estimated time remaining in seconds - pub eta_seconds: Option, - /// Error message if failed - pub error: Option, - /// Whether the background download task has exited - #[serde(skip)] - pub task_exited: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum DownloadStatus { - Downloading, - Completed, - Failed, - Cancelled, -} - -type DownloadMap = Arc>>; - -pub struct DownloadManager { - downloads: DownloadMap, -} - -impl Default for DownloadManager { - fn default() -> Self { - Self::new() - } -} - -impl DownloadManager { - pub fn new() -> Self { - Self { - downloads: Arc::new(Mutex::new(HashMap::new())), - } - } - - pub fn get_progress(&self, model_id: &str) -> Option { - self.downloads.lock().ok()?.get(model_id).cloned() - } - - pub fn is_downloading(&self, model_id: &str) -> bool { - self.get_progress(model_id) - .is_some_and(|progress| progress.status == DownloadStatus::Downloading) - } - - pub fn list_progress(&self) -> Vec { - self.downloads - .lock() - .map(|downloads| downloads.values().cloned().collect()) - .unwrap_or_default() - } - - pub fn set_progress(&self, progress: DownloadProgress) { - if let Ok(mut downloads) = self.downloads.lock() { - downloads.insert(progress.model_id.clone(), progress); - } - } - - pub fn reserve_download(&self, progress: DownloadProgress) -> Result { - let mut downloads = self - .downloads - .lock() - .map_err(|_| anyhow::anyhow!("Failed to acquire lock"))?; - - if let Some(existing) = downloads.get(&progress.model_id) { - if existing.status == DownloadStatus::Downloading - || (existing.status == DownloadStatus::Cancelled && !existing.task_exited) - { - return Ok(false); - } - } - - downloads.insert(progress.model_id.clone(), progress); - Ok(true) - } - - pub fn update_progress(&self, model_id: &str, update: impl FnOnce(&mut DownloadProgress)) { - if let Ok(mut downloads) = self.downloads.lock() { - if let Some(progress) = downloads.get_mut(model_id) { - update(progress); - } - } - } - - pub fn cancel_download(&self, model_id: &str) -> Result<()> { - let mut downloads = self - .downloads - .lock() - .map_err(|_| anyhow::anyhow!("Failed to acquire lock"))?; - - if let Some(progress) = downloads.get_mut(model_id) { - progress.status = DownloadStatus::Cancelled; - Ok(()) - } else { - anyhow::bail!("Download not found") - } - } - - pub async fn download_model( - &self, - model_id: String, - url: String, - destination: PathBuf, - on_complete: Option>, - ) -> Result<()> { - self.download_model_sharded(model_id, vec![(url, destination)], 0, on_complete) - .await - } - - pub async fn download_model_with_bearer_token( - &self, - model_id: String, - url: String, - destination: PathBuf, - bearer_token: Option, - on_complete: Option>, - ) -> Result<()> { - self.download_model_sharded_with_bearer_token( - model_id, - vec![(url, destination)], - 0, - bearer_token, - on_complete, - ) - .await - } - - pub async fn download_model_sharded( - &self, - model_id: String, - files: Vec<(String, PathBuf)>, - total_size_hint: u64, - on_complete: Option>, - ) -> Result<()> { - self.download_model_sharded_with_bearer_token( - model_id, - files, - total_size_hint, - None, - on_complete, - ) - .await - } - - pub async fn download_model_sharded_with_bearer_token( - &self, - model_id: String, - files: Vec<(String, PathBuf)>, - total_size_hint: u64, - bearer_token: Option, - on_complete: Option>, - ) -> Result<()> { - info!(model_id = %model_id, file_count = files.len(), "Starting model download"); - { - let mut downloads = self - .downloads - .lock() - .map_err(|_| anyhow::anyhow!("Failed to acquire lock"))?; - - if let Some(existing) = downloads.get(&model_id) { - if existing.status == DownloadStatus::Downloading { - anyhow::bail!("Download already in progress"); - } - if existing.status == DownloadStatus::Cancelled && !existing.task_exited { - anyhow::bail!( - "Download is being cancelled; wait for it to finish before restarting" - ); - } - } - - downloads.insert( - model_id.clone(), - DownloadProgress { - model_id: model_id.clone(), - status: DownloadStatus::Downloading, - bytes_downloaded: 0, - total_bytes: total_size_hint, - progress_percent: 0.0, - speed_bps: None, - eta_seconds: None, - error: None, - task_exited: false, - }, - ); - } - - // Create parent directories for all files - for (_, dest) in &files { - if let Some(parent) = dest.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|e| anyhow::anyhow!("Failed to create directory: {}", e))?; - } - } - - let downloads = self.downloads.clone(); - let model_id_clone = model_id.clone(); - let files_for_cleanup: Vec = files.iter().map(|(_, d)| d.clone()).collect(); - - tokio::spawn(async move { - let result = Self::download_files_sequentially( - &files, - &downloads, - &model_id_clone, - bearer_token.as_deref(), - ) - .await; - - match result { - Ok(_) => { - info!(model_id = %model_id_clone, "Download completed successfully"); - if let Ok(mut downloads) = downloads.lock() { - if let Some(progress) = downloads.get_mut(&model_id_clone) { - progress.status = DownloadStatus::Completed; - progress.progress_percent = 100.0; - progress.task_exited = true; - } - } - - if let Some(callback) = on_complete { - callback(); - } - } - Err(e) => { - for dest in &files_for_cleanup { - let partial = partial_path_for(dest); - let _ = tokio::fs::remove_file(&partial).await; - } - - if let Ok(mut downloads) = downloads.lock() { - if let Some(progress) = downloads.get_mut(&model_id_clone) { - if progress.status != DownloadStatus::Cancelled { - progress.status = DownloadStatus::Failed; - } - progress.error = Some(e.to_string()); - progress.task_exited = true; - } - } - } - } - }); - - Ok(()) - } - - const MAX_RETRIES: u32 = 10; - const RETRY_BASE_DELAY: std::time::Duration = std::time::Duration::from_secs(2); - const RETRY_MAX_DELAY: std::time::Duration = std::time::Duration::from_secs(60); - - async fn cancellable_sleep( - delay: std::time::Duration, - downloads: &DownloadMap, - model_id: &str, - ) -> Result<(), anyhow::Error> { - let check_interval = std::time::Duration::from_millis(500); - let start = std::time::Instant::now(); - while start.elapsed() < delay { - if Self::is_cancelled(downloads, model_id) { - anyhow::bail!("Download cancelled"); - } - let remaining = delay.saturating_sub(start.elapsed()); - tokio::time::sleep(std::cmp::min(check_interval, remaining)).await; - } - Ok(()) - } - - fn is_cancelled(downloads: &DownloadMap, model_id: &str) -> bool { - if let Ok(downloads) = downloads.lock() { - if let Some(progress) = downloads.get(model_id) { - return progress.status == DownloadStatus::Cancelled; - } - } - false - } - - #[allow(clippy::too_many_arguments)] - /// Download multiple files sequentially, tracking cumulative progress under one model_id. - async fn download_files_sequentially( - files: &[(String, PathBuf)], - downloads: &DownloadMap, - model_id: &str, - bearer_token: Option<&str>, - ) -> Result<(), anyhow::Error> { - let client = reqwest::Client::builder() - .connect_timeout(std::time::Duration::from_secs(30)) - .read_timeout(std::time::Duration::from_secs(120)) - .build()?; - - // HEAD each file to get accurate total size. Only replace the hint if - // every file returned a size; partial results would underestimate. - let mut total: u64 = 0; - let mut all_resolved = true; - for (url, _) in files { - let size = Self::apply_bearer_token(client.head(url), bearer_token) - .send() - .await - .ok() - .and_then(|r| r.content_length()) - .unwrap_or(0); - if size == 0 { - all_resolved = false; - } - total += size; - } - if all_resolved && total > 0 { - if let Ok(mut dl) = downloads.lock() { - if let Some(progress) = dl.get_mut(model_id) { - progress.total_bytes = total; - } - } - } - - let start_time = std::time::Instant::now(); - let mut cumulative_bytes: u64 = 0; - // Account for already-downloaded shards - for (_, dest) in files { - let partial = partial_path_for(dest); - if dest.exists() { - if let Ok(meta) = tokio::fs::metadata(dest).await { - cumulative_bytes += meta.len(); - } - } else if partial.exists() { - if let Ok(meta) = tokio::fs::metadata(&partial).await { - cumulative_bytes += meta.len(); - } - } - } - let bytes_at_start = cumulative_bytes; - - for (url, destination) in files { - if Self::is_cancelled(downloads, model_id) { - anyhow::bail!("Download cancelled"); - } - - // Skip already-completed shards - if destination.exists() { - continue; - } - - Self::download_one_file( - &client, - url, - destination, - downloads, - model_id, - &mut cumulative_bytes, - start_time, - bytes_at_start, - bearer_token, - ) - .await?; - } - - Ok(()) - } - - #[allow(clippy::too_many_arguments)] - async fn download_one_file( - client: &reqwest::Client, - url: &str, - destination: &Path, - downloads: &DownloadMap, - model_id: &str, - cumulative_bytes: &mut u64, - start_time: std::time::Instant, - bytes_at_start: u64, - bearer_token: Option<&str>, - ) -> Result<(), anyhow::Error> { - let partial_path = partial_path_for(destination); - let mut retries = 0u32; - - let mut file_bytes: u64 = if partial_path.exists() { - tokio::fs::metadata(&partial_path).await?.len() - } else { - 0 - }; - - // Get this file's total size - let mut file_total: u64 = Self::apply_bearer_token(client.head(url), bearer_token) - .send() - .await - .ok() - .and_then(|r| r.content_length()) - .unwrap_or(0); - - // If partial matches expected size exactly, promote it - if file_total > 0 && file_bytes == file_total { - tokio::fs::rename(&partial_path, destination).await?; - // cumulative_bytes already accounts for this file from the pre-scan - return Ok(()); - } - - // If partial is oversized or remote changed, discard and re-download - if file_total > 0 && file_bytes > file_total { - info!(model_id = %model_id, file_bytes, file_total, "Partial file oversized, re-downloading"); - *cumulative_bytes = cumulative_bytes.saturating_sub(file_bytes); - file_bytes = 0; - let _ = tokio::fs::remove_file(&partial_path).await; - } - - loop { - if Self::is_cancelled(downloads, model_id) { - let _ = tokio::fs::remove_file(&partial_path).await; - anyhow::bail!("Download cancelled"); - } - - let mut request = Self::apply_bearer_token(client.get(url), bearer_token); - if file_bytes > 0 { - request = request.header("Range", format!("bytes={}-", file_bytes)); - } - - let response = match request.send().await { - Ok(r) => r, - Err(e) => { - if retries >= Self::MAX_RETRIES { - anyhow::bail!("Download failed after {} retries: {}", retries, e); - } - retries += 1; - let delay = std::cmp::min( - Self::RETRY_BASE_DELAY * 2u32.saturating_pow(retries - 1), - Self::RETRY_MAX_DELAY, - ); - info!(model_id = %model_id, retry = retries, delay_secs = ?delay.as_secs(), error = %e, "Retrying download after connection error"); - Self::cancellable_sleep(delay, downloads, model_id).await?; - continue; - } - }; - - let status = response.status(); - if status == reqwest::StatusCode::RANGE_NOT_SATISFIABLE { - if file_total > 0 && file_bytes == file_total { - break; - } - *cumulative_bytes = cumulative_bytes.saturating_sub(file_bytes); - file_bytes = 0; - let _ = tokio::fs::remove_file(&partial_path).await; - continue; - } - - if !status.is_success() && status != reqwest::StatusCode::PARTIAL_CONTENT { - let is_transient = status.is_server_error() - || status == reqwest::StatusCode::REQUEST_TIMEOUT - || status == reqwest::StatusCode::TOO_MANY_REQUESTS; - - if !is_transient || retries >= Self::MAX_RETRIES { - anyhow::bail!("Failed to download: HTTP {}", status); - } - retries += 1; - let delay = std::cmp::min( - Self::RETRY_BASE_DELAY * 2u32.saturating_pow(retries - 1), - Self::RETRY_MAX_DELAY, - ); - info!(model_id = %model_id, retry = retries, http_status = %status, "Retrying download after transient HTTP error"); - Self::cancellable_sleep(delay, downloads, model_id).await?; - continue; - } - - if file_bytes > 0 && status == reqwest::StatusCode::OK { - info!(model_id = %model_id, "Server ignored Range header, restarting file from scratch"); - // Subtract already-counted partial bytes from cumulative - *cumulative_bytes = cumulative_bytes.saturating_sub(file_bytes); - file_bytes = 0; - let _ = tokio::fs::remove_file(&partial_path).await; - } - - // If HEAD didn't return this file's size, learn it from the GET response. - // This block only fires once per file (file_total stays non-zero after), - // so retries don't double-count. Since download_files_sequentially's HEAD - // pass contributed 0 for this file, we add the discovered size to the - // shared total so progress/ETA are accurate. - if file_total == 0 { - let new_file_total = if file_bytes > 0 { - response - .headers() - .get("content-range") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.rsplit('/').next()) - .and_then(|s| s.parse::().ok()) - } else { - response.content_length() - }; - if let Some(t) = new_file_total { - file_total = t; - if let Ok(mut dl) = downloads.lock() { - if let Some(progress) = dl.get_mut(model_id) { - progress.total_bytes = progress.total_bytes.saturating_add(t); - } - } - } - } - - let mut file = tokio::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&partial_path) - .await?; - - let file_len = tokio::fs::metadata(&partial_path).await?.len(); - if file_len != file_bytes { - file.set_len(file_bytes).await?; - } - - let mut stream_error = false; - let mut resp = response; - - loop { - let chunk_result = resp.chunk().await; - match chunk_result { - Ok(Some(chunk)) => { - if Self::is_cancelled(downloads, model_id) { - let _ = tokio::fs::remove_file(&partial_path).await; - anyhow::bail!("Download cancelled"); - } - - file.write_all(&chunk).await?; - let chunk_len = chunk.len() as u64; - file_bytes += chunk_len; - *cumulative_bytes += chunk_len; - - let elapsed = start_time.elapsed().as_secs_f64(); - let bytes_this_session = cumulative_bytes.saturating_sub(bytes_at_start); - let speed_bps = if elapsed > 0.0 { - Some((bytes_this_session as f64 / elapsed) as u64) - } else { - None - }; - - let current_total = if let Ok(dl) = downloads.lock() { - dl.get(model_id).map(|p| p.total_bytes).unwrap_or(0) - } else { - 0 - }; - - let eta_seconds = if let Some(speed) = speed_bps { - if speed > 0 && current_total > 0 { - Some(current_total.saturating_sub(*cumulative_bytes) / speed) - } else { - None - } - } else { - None - }; - - if let Ok(mut dl) = downloads.lock() { - if let Some(progress) = dl.get_mut(model_id) { - progress.bytes_downloaded = *cumulative_bytes; - progress.progress_percent = if current_total > 0 { - (*cumulative_bytes as f64 / current_total as f64 * 100.0) as f32 - } else { - 0.0 - }; - progress.speed_bps = speed_bps; - progress.eta_seconds = eta_seconds; - } - } - } - Ok(None) => break, - Err(e) => { - info!(model_id = %model_id, bytes = *cumulative_bytes, error = %e, "Download stream interrupted, will retry"); - stream_error = true; - break; - } - } - } - - file.flush().await?; - drop(file); - - if stream_error { - if retries >= Self::MAX_RETRIES { - anyhow::bail!( - "Download failed after {} retries due to stream interruption", - retries - ); - } - retries += 1; - let delay = std::cmp::min( - Self::RETRY_BASE_DELAY * 2u32.saturating_pow(retries - 1), - Self::RETRY_MAX_DELAY, - ); - info!(model_id = %model_id, retry = retries, delay_secs = ?delay.as_secs(), "Retrying download with resume"); - Self::cancellable_sleep(delay, downloads, model_id).await?; - continue; - } - - break; - } - - tokio::fs::rename(&partial_path, destination).await?; - Ok(()) - } - - pub fn clear_completed(&self, model_id: &str) { - if let Ok(mut downloads) = self.downloads.lock() { - if let Some(progress) = downloads.get(model_id) { - let is_terminal = progress.status == DownloadStatus::Completed - || progress.status == DownloadStatus::Failed - || progress.status == DownloadStatus::Cancelled; - if is_terminal && progress.task_exited { - downloads.remove(model_id); - } - } - } - } - - fn apply_bearer_token( - request: reqwest::RequestBuilder, - bearer_token: Option<&str>, - ) -> reqwest::RequestBuilder { - if let Some(token) = bearer_token.filter(|token| !token.is_empty()) { - request.header("Authorization", format!("Bearer {}", token)) - } else { - request - } - } -} - -static DOWNLOAD_MANAGER: once_cell::sync::Lazy = - once_cell::sync::Lazy::new(DownloadManager::new); - -pub fn get_download_manager() -> &'static DownloadManager { - &DOWNLOAD_MANAGER -} diff --git a/crates/goose-local-inference/Cargo.toml b/crates/goose-local-inference/Cargo.toml deleted file mode 100644 index c3ae342747..0000000000 --- a/crates/goose-local-inference/Cargo.toml +++ /dev/null @@ -1,54 +0,0 @@ -[package] -name = "goose-local-inference" -version = "0.1.0-alpha.0" -edition.workspace = true -rust-version.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -description.workspace = true - -[lints] -workspace = true - -[features] -default = [] -cuda = ["llama-cpp-2/cuda"] -vulkan = ["llama-cpp-2/vulkan"] -mlx = ["dep:safemlx", "dep:safemlx-lm", "dep:safemlx-lm-utils"] - -[dependencies] -anyhow = { workspace = true } -async-stream = { workspace = true } -async-trait = { workspace = true } -base64 = { workspace = true } -chrono = { workspace = true } -etcetera = { workspace = true } -encoding_rs = { version = "0.8.35", default-features = false } -fs2 = { workspace = true } -futures = { workspace = true } -goose-download-manager = { version = "0.1.0-alpha.0", path = "../goose-download-manager" } -goose-provider-types = { version = "0.1.0-alpha.0", path = "../goose-provider-types", default-features = false } -goose-sdk-types = { version = "0.1.0-alpha.0", path = "../goose-sdk-types", default-features = false } -hf-hub = { version = "1.0.0-rc.1", default-features = false } -include_dir = { workspace = true } -llama-cpp-2 = { workspace = true } -llama-cpp-sys-2 = { workspace = true } -minijinja = { version = "2.18", default-features = false, features = ["loader", "multi_template", "serde"] } -reqwest = { workspace = true, features = ["json", "stream"] } -regex = { workspace = true } -rmcp = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tokio = { workspace = true, features = ["fs", "rt-multi-thread", "sync", "time"] } -tracing = { workspace = true } -utoipa = { workspace = true, features = ["chrono"] } -uuid = { workspace = true, features = ["v4", "std"] } -tempfile = { workspace = true } - -safemlx = { default-features = false, features = ["accelerate", "metal", "safetensors"], optional = true, version = "0.1.2" } -safemlx-lm = { optional = true, version = "0.1.5" } -safemlx-lm-utils = { optional = true, version = "0.1.2" } - -[target.'cfg(target_os = "macos")'.dependencies] -llama-cpp-2 = { workspace = true, features = ["sampler", "metal", "mtmd"] } diff --git a/crates/goose-local-inference/src/config_resolver.rs b/crates/goose-local-inference/src/config_resolver.rs deleted file mode 100644 index 3c5a610222..0000000000 --- a/crates/goose-local-inference/src/config_resolver.rs +++ /dev/null @@ -1,30 +0,0 @@ -use anyhow::Result; -use std::sync::OnceLock; - -pub type StringParamResolver = fn(&'static str) -> Result>; -pub type BoolParamResolver = fn(&'static str) -> Result>; - -static STRING_PARAM_RESOLVER: OnceLock = OnceLock::new(); -static BOOL_PARAM_RESOLVER: OnceLock = OnceLock::new(); - -pub fn set_string_param_resolver(resolve_param: StringParamResolver) { - let _ = STRING_PARAM_RESOLVER.set(resolve_param); -} - -pub fn set_bool_param_resolver(resolve_param: BoolParamResolver) { - let _ = BOOL_PARAM_RESOLVER.set(resolve_param); -} - -pub fn string_param(key: &'static str) -> Result> { - match STRING_PARAM_RESOLVER.get() { - Some(resolve_param) => resolve_param(key), - None => Ok(None), - } -} - -pub fn bool_param(key: &'static str) -> Result> { - match BOOL_PARAM_RESOLVER.get() { - Some(resolve_param) => resolve_param(key), - None => Ok(None), - } -} diff --git a/crates/goose-local-inference/src/hf_models.rs b/crates/goose-local-inference/src/hf_models.rs deleted file mode 100644 index a63d34a122..0000000000 --- a/crates/goose-local-inference/src/hf_models.rs +++ /dev/null @@ -1,2490 +0,0 @@ -use anyhow::{bail, Result}; -use futures::StreamExt; -use hf_hub::progress::{DownloadEvent, FileStatus, ProgressEvent, ProgressHandler}; -use hf_hub::repository::{ModelInfo, RepoSibling}; -use hf_hub::{HFClient, HFRepository, RepoTypeModel}; - -use super::local_model_registry::{get_registry, model_id_from_repo, LocalModelStorage, ShardFile}; -use serde::{Deserialize, Serialize}; -use std::sync::{Arc, Mutex}; - -use crate::huggingface_auth; - -use utoipa::ToSchema; - -const HF_API_BASE: &str = "https://huggingface.co/api/models"; -const HF_DOWNLOAD_BASE: &str = "https://huggingface.co"; -const LLAMACPP_BACKEND_ID: &str = "llamacpp"; -const MLX_BACKEND_ID: &str = "mlx"; -const GGUF_FORMAT: &str = "gguf"; -const MLX_FORMAT: &str = "mlx-safetensors"; -const MLX_VARIANT_ID: &str = "default"; - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct HfModelInfo { - pub repo_id: String, - pub author: String, - pub model_name: String, - pub downloads: u64, - pub gguf_files: Vec, - #[serde(default)] - pub variants: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct HfModelVariant { - pub variant_id: String, - pub label: String, - pub backend_id: String, - pub format: String, - pub model_id: String, - pub download_id: String, - pub size_bytes: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub filename: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub download_url: Option, - pub description: String, - pub quality_rank: u8, - #[serde(default)] - pub sharded: bool, - #[serde(default = "default_supported")] - pub supported: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub unsupported_reason: Option, -} - -fn default_supported() -> bool { - true -} - -/// A single downloadable GGUF file (used internally and for downloads). -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct HfGgufFile { - pub filename: String, - pub size_bytes: u64, - pub quantization: String, - pub download_url: String, -} - -/// A quantization variant โ€” groups sharded files into one logical entry. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct HfQuantVariant { - pub quantization: String, - pub size_bytes: u64, - pub filename: String, - pub download_url: String, - pub description: &'static str, - pub quality_rank: u8, - #[serde(default)] - pub sharded: bool, -} - -impl HfQuantVariant { - pub fn to_model_variant(&self, repo_id: &str) -> HfModelVariant { - let model_id = model_id_from_repo(repo_id, &self.quantization); - HfModelVariant { - variant_id: self.quantization.clone(), - label: self.quantization.clone(), - backend_id: LLAMACPP_BACKEND_ID.to_string(), - format: GGUF_FORMAT.to_string(), - model_id: model_id.clone(), - download_id: model_id, - size_bytes: self.size_bytes, - filename: Some(self.filename.clone()), - download_url: Some(self.download_url.clone()), - description: self.description.to_string(), - quality_rank: self.quality_rank, - sharded: self.sharded, - supported: true, - unsupported_reason: None, - } - } -} - -/// Result of resolving a model spec โ€” may contain multiple shard files. -#[derive(Debug, Clone)] -pub struct ResolvedModel { - pub files: Vec, - pub total_size: u64, - pub mmproj: Option, -} - -#[derive(Debug, Clone)] -pub enum ResolvedLocalModel { - Gguf { - repo_id: String, - quantization: String, - resolved: ResolvedModel, - local_paths: Vec, - mmproj_path: Option, - storage: LocalModelStorage, - }, - Mlx { - repo_id: String, - variant_id: String, - snapshot_path: std::path::PathBuf, - total_size: u64, - }, -} - -impl ResolvedLocalModel { - pub fn repo_id(&self) -> &str { - match self { - Self::Gguf { repo_id, .. } | Self::Mlx { repo_id, .. } => repo_id, - } - } - - pub fn variant_id(&self) -> &str { - match self { - Self::Gguf { quantization, .. } => quantization, - Self::Mlx { variant_id, .. } => variant_id, - } - } - - pub fn backend_id(&self) -> &'static str { - match self { - Self::Gguf { .. } => "llamacpp", - Self::Mlx { .. } => "mlx", - } - } - - pub fn model_id(&self) -> String { - match self { - Self::Gguf { - repo_id, - quantization, - .. - } => model_id_from_repo(repo_id, quantization), - Self::Mlx { repo_id, .. } => repo_id.clone(), - } - } - - pub fn total_size(&self) -> u64 { - match self { - Self::Gguf { resolved, .. } => resolved.total_size, - Self::Mlx { total_size, .. } => *total_size, - } - } - - pub fn storage(&self) -> LocalModelStorage { - match self { - Self::Gguf { storage, .. } => *storage, - Self::Mlx { .. } => LocalModelStorage::HuggingFaceCache, - } - } -} - -#[derive(Debug, Deserialize)] -struct HfApiModel { - id: Option, - author: Option, - downloads: Option, - siblings: Option>, -} - -#[derive(Debug, Deserialize)] -struct HfApiSibling { - rfilename: String, - #[serde(default)] - size: Option, -} - -struct QuantInfo { - description: &'static str, - quality_rank: u8, -} - -// quality_rank groups quants by bit-level so that all N-bit variants sort -// together. Within a group, higher rank = higher quality. -// -// 1-bit: 10โ€“19 4-bit: 40โ€“49 8-bit: 80โ€“89 -// 2-bit: 20โ€“29 5-bit: 50โ€“59 16-bit: 90โ€“94 -// 3-bit: 30โ€“39 6-bit: 60โ€“69 32-bit: 95โ€“99 -// -const QUANT_TABLE: &[(&str, &str, u8)] = &[ - // 1-bit - ("TQ1_0", "Tiny, ternary quantization", 10), - ("IQ1_S", "Extremely small, very low quality", 11), - ("IQ1_M", "Extremely small, very low quality", 12), - // 2-bit - ("IQ2_XXS", "Very small, low quality", 20), - ("IQ2_XS", "Very small, low quality", 21), - ("IQ2_S", "Very small, low quality", 22), - ("IQ2_M", "Very small, low quality", 23), - ("Q2_K", "Small, low quality", 24), - ("Q2_K_S", "Small, low quality", 24), - ("Q2_K_L", "Small, low quality", 25), - ("Q2_K_XL", "Small, low quality", 26), - // 3-bit - ("IQ3_XXS", "Very small, moderate quality loss", 30), - ("IQ3_XS", "Small, moderate quality loss", 31), - ("IQ3_S", "Small, moderate quality loss", 32), - ("IQ3_M", "Small, moderate quality loss", 33), - ("Q3_K_S", "Small, moderate quality loss", 34), - ("Q3_K_M", "Small, balanced quality/size", 35), - ("Q3_K_L", "Medium-small, decent quality", 36), - ("Q3_K_XL", "Medium-small, decent quality", 37), - // 4-bit - ("IQ4_XS", "Medium, good quality", 40), - ("IQ4_NL", "Medium, good quality", 41), - ("Q4_0", "Medium, good quality", 42), - ("Q4_1", "Medium, good quality", 43), - ("Q4_K_S", "Medium, good quality/size balance", 44), - ( - "Q4_K_M", - "Medium, recommended balance of quality and size", - 45, - ), - ("Q4_K_L", "Medium, good quality", 46), - ("Q4_K_XL", "Medium, good quality", 47), - ( - "MXFP4_MOE", - "Medium, mixed-precision 4-bit for MoE models", - 48, - ), - // 5-bit - ("Q5_0", "Medium-large, high quality", 50), - ("Q5_1", "Medium-large, high quality", 51), - ("Q5_K_S", "Medium-large, high quality", 52), - ("Q5_K_M", "Medium-large, very high quality", 53), - ("Q5_K_XL", "Medium-large, very high quality", 54), - // 6-bit - ("Q6_K", "Large, near-lossless quality", 60), - ("Q6_K_XL", "Large, near-lossless quality", 61), - // 8-bit - ("Q8_0", "Large, near-lossless quality", 80), - ("Q8_K_XL", "Large, near-lossless quality", 81), - // 16-bit - ("F16", "Full size, original quality (16-bit)", 90), - ("BF16", "Full size, original quality (bfloat16)", 91), - // 32-bit - ("F32", "Full size, original quality (32-bit)", 95), -]; - -fn quant_info(quant: &str) -> QuantInfo { - QUANT_TABLE - .iter() - .find(|(name, _, _)| *name == quant) - .map(|(_, description, quality_rank)| QuantInfo { - description, - quality_rank: *quality_rank, - }) - .unwrap_or(QuantInfo { - description: "", - quality_rank: 45, - }) -} - -pub fn parse_quantization_from_filename(filename: &str) -> String { - parse_quantization(filename) -} - -fn parse_quantization(filename: &str) -> String { - // Strip directory prefix (e.g. "Q5_K_M/Model-Q5_K_M-00001-of-00002.gguf") - let basename = filename.rsplit('/').next().unwrap_or(filename); - let stem = basename.trim_end_matches(".gguf"); - - // Strip shard suffix like "-00001-of-00004" - let stem = if let Some(pos) = stem.rfind("-of-") { - stem.get(..pos) - .and_then(|s| s.rsplit_once('-').map(|(prefix, _)| prefix)) - .unwrap_or(stem) - } else { - stem - }; - - // The quantization tag is typically the last hyphen-separated component - // that looks like a quant identifier (starts with Q, IQ, F, BF, TQ, MXFP, etc.) - // e.g. "Qwen3-Coder-Next-Q4_K_M" -> "Q4_K_M" - // "Model-UD-IQ1_M" -> "IQ1_M" - if let Some((_, candidate)) = stem.rsplit_once('-') { - if looks_like_quant(candidate) { - return candidate.to_string(); - } - } - - // Fallback: try dot separator (e.g. "model.Q4_K_M") - if let Some((_, candidate)) = stem.rsplit_once('.') { - if looks_like_quant(candidate) { - return candidate.to_string(); - } - } - - "unknown".to_string() -} - -fn quant_bits(quantization: &str) -> u8 { - let digits: String = quantization - .chars() - .skip_while(|c| !c.is_ascii_digit()) - .take_while(|c| c.is_ascii_digit()) - .collect(); - digits.parse().unwrap_or(0) -} - -fn mmproj_precision_preference(quantization: &str) -> u8 { - match quantization.to_uppercase().as_str() { - "BF16" => 3, - "F16" => 2, - "F32" => 1, - _ => 0, - } -} - -fn looks_like_quant(s: &str) -> bool { - let upper = s.to_uppercase(); - upper.starts_with("Q") - || upper.starts_with("IQ") - || upper.starts_with("TQ") - || upper.starts_with("MXFP") - || upper == "F16" - || upper == "F32" - || upper == "BF16" -} - -fn is_shard_file(filename: &str) -> bool { - // Matches patterns like "-00001-of-00003.gguf" - parse_shard_index(filename).is_some() -} - -/// Parse the shard index (1-based) from a filename like "model-BF16-00001-of-00002.gguf". -fn parse_shard_index(filename: &str) -> Option { - let basename = filename.rsplit('/').next().unwrap_or(filename); - let stem = basename.trim_end_matches(".gguf"); - let pos = stem.rfind("-of-")?; - let before = stem.get(..pos)?; - let idx_str = before.rsplit('-').next()?; - if !idx_str.is_empty() && idx_str.chars().all(|c| c.is_ascii_digit()) { - idx_str.parse().ok() - } else { - None - } -} - -/// Parse the total shard count from a filename like "model-BF16-00001-of-00002.gguf". -fn parse_shard_total(filename: &str) -> Option { - let basename = filename.rsplit('/').next().unwrap_or(filename); - let stem = basename.trim_end_matches(".gguf"); - let pos = stem.rfind("-of-")?; - let total_str = stem.get(pos + 4..)?; - total_str.parse().ok() -} - -fn build_download_url(repo_id: &str, filename: &str) -> String { - format!("{}/{}/resolve/main/{}", HF_DOWNLOAD_BASE, repo_id, filename) -} - -pub fn hf_authorization_header(token: Option<&str>) -> Option { - token - .filter(|token| !token.is_empty()) - .map(|token| format!("Bearer {}", token)) -} - -fn apply_hf_auth(request: reqwest::RequestBuilder, token: Option<&str>) -> reqwest::RequestBuilder { - if let Some(header) = hf_authorization_header(token) { - request.header("Authorization", header) - } else { - request - } -} - -async fn optional_hf_token( - token: impl std::future::Future>>, -) -> Option { - token.await.ok().flatten() -} - -fn parent_components(filename: &str) -> Vec<&str> { - filename.rsplit_once('/').map_or(Vec::new(), |(parent, _)| { - parent.split('/').filter(|part| !part.is_empty()).collect() - }) -} - -fn is_prefix(prefix: &[&str], parts: &[&str]) -> bool { - prefix.len() <= parts.len() && prefix.iter().zip(parts).all(|(a, b)| a == b) -} - -fn select_best_mmproj( - repo_id: &str, - siblings: &[HfApiSibling], - model_filename: &str, - model_quantization: &str, -) -> Option { - let model_dir = parent_components(model_filename); - let model_bits = quant_bits(model_quantization); - - siblings - .iter() - .filter(|s| { - let lowercase = s.rfilename.to_lowercase(); - lowercase.ends_with(".gguf") && lowercase.contains("mmproj") - }) - .filter_map(|s| { - let mmproj_dir = parent_components(&s.rfilename); - if !is_prefix(&mmproj_dir, &model_dir) { - return None; - } - - let quantization = parse_quantization(&s.rfilename); - let bits = quant_bits(&quantization); - let diff = bits.abs_diff(model_bits); - let proximity = u8::MAX - diff; - - Some(( - mmproj_dir.len(), - proximity, - mmproj_precision_preference(&quantization), - s, - quantization, - )) - }) - .max_by(|a, b| { - a.0.cmp(&b.0) - .then_with(|| a.1.cmp(&b.1)) - .then_with(|| a.2.cmp(&b.2)) - .then_with(|| b.3.rfilename.cmp(&a.3.rfilename)) - }) - .map(|(_, _, _, sibling, quantization)| HfGgufFile { - filename: sibling.rfilename.clone(), - size_bytes: sibling.size.unwrap_or(0), - quantization, - download_url: build_download_url(repo_id, &sibling.rfilename), - }) -} - -/// Derive the expected model filename stem from a repo_id. -/// e.g. "unsloth/gemma-4-26B-A4B-it-GGUF" โ†’ "gemma-4-26b-a4b-it" (lowercased) -fn model_stem_from_repo(repo_id: &str) -> String { - let repo_name = repo_id.rsplit('/').next().unwrap_or(repo_id); - let stem = repo_name - .strip_suffix("-GGUF") - .or_else(|| repo_name.strip_suffix("-gguf")) - .unwrap_or(repo_name); - stem.to_lowercase() -} - -/// Check whether a GGUF file belongs to the main model (vs auxiliary files like mmproj). -/// Matches files whose basename starts with the model stem derived from the repo name. -fn is_model_file(filename: &str, model_stem_lower: &str) -> bool { - let basename = filename.rsplit('/').next().unwrap_or(filename); - basename.to_lowercase().starts_with(model_stem_lower) -} - -/// Collect GGUF files into quantization variants. -/// Single-file quants use the file directly. -/// Sharded quants (multiple files for one quantization) aggregate sizes and use the -/// first shard filename as the representative โ€” the download path must handle all shards. -fn group_into_variants(repo_id: &str, files: Vec) -> Vec { - use std::collections::HashMap; - - let stem = model_stem_from_repo(repo_id); - - let gguf_files: Vec<_> = files - .into_iter() - .filter(|s| { - s.rfilename.ends_with(".gguf") - && is_model_file(&s.rfilename, &stem) - && parse_quantization(&s.rfilename) != "unknown" - }) - .collect(); - - // Separate single files from shards - let mut single_files: Vec<&HfApiSibling> = Vec::new(); - let mut shard_groups: HashMap> = HashMap::new(); - - for file in &gguf_files { - if is_shard_file(&file.rfilename) { - let quant = parse_quantization(&file.rfilename); - shard_groups.entry(quant).or_default().push(file); - } else { - single_files.push(file); - } - } - - let mut variants: Vec = Vec::new(); - let mut seen_quants: std::collections::HashSet = std::collections::HashSet::new(); - - // Add single-file variants - for s in single_files { - let quant = parse_quantization(&s.rfilename); - seen_quants.insert(quant.clone()); - let info = quant_info(&quant); - let download_url = build_download_url(repo_id, &s.rfilename); - variants.push(HfQuantVariant { - quantization: quant, - size_bytes: s.size.unwrap_or(0), - filename: s.rfilename.clone(), - download_url, - description: info.description, - quality_rank: info.quality_rank, - sharded: false, - }); - } - - // Add shard-only variants (quants that only exist as sharded files) - for (quant, mut shards) in shard_groups { - if seen_quants.contains(&quant) { - continue; - } - shards.sort_by(|a, b| a.rfilename.cmp(&b.rfilename)); - let total_size: u64 = shards.iter().map(|s| s.size.unwrap_or(0)).sum(); - let info = quant_info(&quant); - let first_filename = &shards[0].rfilename; - let download_url = build_download_url(repo_id, first_filename); - variants.push(HfQuantVariant { - quantization: quant, - size_bytes: total_size, - filename: first_filename.clone(), - download_url, - description: info.description, - quality_rank: info.quality_rank, - sharded: true, - }); - } - - // Sort descending by quality_rank, then by size descending as tiebreaker - variants.sort_by(|a, b| { - b.quality_rank - .cmp(&a.quality_rank) - .then_with(|| b.size_bytes.cmp(&a.size_bytes)) - }); - variants -} - -pub async fn search_local_models(query: &str, limit: usize) -> Result> { - let mut results = Vec::new(); - - if looks_like_repo_id(query) { - if let Some(model) = get_local_model_info_for_repo(query).await? { - results.push(model); - } - } else if let Some(model) = get_exact_name_local_model_info(query).await? { - results.push(model); - } - - let mut gguf_results = search_gguf_models(query, limit).await?; - for model in &mut gguf_results { - let gguf_variants = get_repo_gguf_variants(&model.repo_id) - .await - .unwrap_or_default(); - model.variants = gguf_variants - .iter() - .map(|variant| variant.to_model_variant(&model.repo_id)) - .collect(); - } - - results.extend(gguf_results); - append_optional_mlx_results(&mut results, search_mlx_models(query, limit).await, query); - dedupe_models(&mut results); - results.sort_by(|a, b| { - model_search_rank(query, a) - .cmp(&model_search_rank(query, b)) - .then_with(|| b.downloads.cmp(&a.downloads)) - }); - results.truncate(limit); - Ok(results) -} - -fn append_optional_mlx_results( - results: &mut Vec, - mlx_results: Result>, - query: &str, -) { - match mlx_results { - Ok(models) => results.extend(models), - Err(error) => tracing::warn!( - query, - error = %error, - "Failed to search MLX models; returning non-MLX results" - ), - } -} - -pub async fn search_gguf_models(query: &str, limit: usize) -> Result> { - let client = reqwest::Client::new(); - let token = optional_hf_token(huggingface_auth::resolve_token_async()).await; - let url = format!( - "{}?search={}&filter=gguf&sort=downloads&direction=-1&limit={}", - HF_API_BASE, query, limit - ); - - let response = apply_hf_auth(client.get(&url), token.as_deref()) - .header("User-Agent", "goose-ai-agent") - .send() - .await?; - - if !response.status().is_success() { - bail!("HuggingFace API returned status {}", response.status()); - } - - let models: Vec = response.json().await?; - - let results = models - .into_iter() - .filter_map(|m| { - let repo_id = m.id?; - let siblings = m.siblings.unwrap_or_default(); - - // The search endpoint may not include `siblings`; parse whatever - // is available. Files are fetched on-demand via `get_repo_gguf_variants`. - let gguf_files: Vec = siblings - .into_iter() - .filter(|s| s.rfilename.ends_with(".gguf")) - .map(|s| { - let quantization = parse_quantization(&s.rfilename); - let download_url = build_download_url(&repo_id, &s.rfilename); - HfGgufFile { - filename: s.rfilename, - size_bytes: s.size.unwrap_or(0), - quantization, - download_url, - } - }) - .collect(); - - let author = m - .author - .unwrap_or_else(|| repo_id.split('/').next().unwrap_or_default().to_string()); - let model_name = repo_id - .split('/') - .next_back() - .unwrap_or(&repo_id) - .to_string(); - - Some(HfModelInfo { - repo_id, - author, - model_name, - downloads: m.downloads.unwrap_or(0), - gguf_files, - variants: Vec::new(), - }) - }) - .collect(); - - Ok(results) -} - -/// Fetch GGUF files for a repo and return them grouped by quantization. -pub async fn get_repo_gguf_variants(repo_id: &str) -> Result> { - let client = reqwest::Client::new(); - let token = optional_hf_token(huggingface_auth::resolve_token_async()).await; - let url = format!("{}/{}?blobs=true", HF_API_BASE, repo_id); - - let response = apply_hf_auth(client.get(&url), token.as_deref()) - .header("User-Agent", "goose-ai-agent") - .send() - .await?; - - if !response.status().is_success() { - bail!( - "HuggingFace API returned status {} for repo {}", - response.status(), - repo_id - ); - } - - let model: HfApiModel = response.json().await?; - let siblings = model.siblings.unwrap_or_default(); - - Ok(group_into_variants(repo_id, siblings)) -} - -/// Fetch raw GGUF files (kept for resolve_model_spec). -pub async fn get_repo_gguf_files(repo_id: &str) -> Result> { - let client = reqwest::Client::new(); - let token = optional_hf_token(huggingface_auth::resolve_token_async()).await; - let url = format!("{}/{}?blobs=true", HF_API_BASE, repo_id); - - let response = apply_hf_auth(client.get(&url), token.as_deref()) - .header("User-Agent", "goose-ai-agent") - .send() - .await?; - - if !response.status().is_success() { - bail!( - "HuggingFace API returned status {} for repo {}", - response.status(), - repo_id - ); - } - - let model: HfApiModel = response.json().await?; - let siblings = model.siblings.unwrap_or_default(); - - let stem = model_stem_from_repo(repo_id); - - let files = siblings - .into_iter() - .filter(|s| s.rfilename.ends_with(".gguf")) - .filter(|s| !is_shard_file(&s.rfilename)) - .filter(|s| is_model_file(&s.rfilename, &stem)) - .map(|s| { - let quantization = parse_quantization(&s.rfilename); - let download_url = build_download_url(repo_id, &s.rfilename); - HfGgufFile { - filename: s.rfilename, - size_bytes: s.size.unwrap_or(0), - quantization, - download_url, - } - }) - .collect(); - - Ok(files) -} - -/// Parse a model spec like "bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M" into (repo_id, quantization). -pub fn parse_model_spec(spec: &str) -> Result<(String, String)> { - let (repo_id, quant) = spec.rsplit_once(':').ok_or_else(|| { - anyhow::anyhow!( - "Invalid model spec '{}': expected format 'user/repo:quantization'", - spec - ) - })?; - - if !repo_id.contains('/') { - bail!("Invalid repo_id '{}': expected format 'user/repo'", repo_id); - } - - if quant.is_empty() { - bail!( - "Invalid model spec '{}': expected format 'user/repo:quantization'", - spec - ); - } - - Ok((repo_id.to_string(), quant.to_string())) -} - -/// Resolve a model spec to all GGUF files for that quantization (handles shards). -pub async fn resolve_model_spec_full(spec: &str) -> Result<(String, ResolvedModel)> { - let (repo_id, quant) = parse_model_spec(spec)?; - - let client = reqwest::Client::new(); - let token = optional_hf_token(huggingface_auth::resolve_token_async()).await; - let url = format!("{}/{}?blobs=true", HF_API_BASE, repo_id); - let response = apply_hf_auth(client.get(&url), token.as_deref()) - .header("User-Agent", "goose-ai-agent") - .send() - .await?; - - if !response.status().is_success() { - bail!( - "HuggingFace API returned status {} for repo {}", - response.status(), - repo_id - ); - } - - let model: HfApiModel = response.json().await?; - let siblings = model.siblings.unwrap_or_default(); - let stem = model_stem_from_repo(&repo_id); - - // Collect all GGUF files matching the quantization - let matching: Vec<_> = siblings - .iter() - .filter(|s| { - s.rfilename.ends_with(".gguf") - && is_model_file(&s.rfilename, &stem) - && parse_quantization(&s.rfilename).eq_ignore_ascii_case(&quant) - }) - .collect(); - - if matching.is_empty() { - bail!( - "No GGUF file with quantization '{}' found in {}", - quant, - repo_id - ); - } - - // Separate single files from shards - let mut single_files: Vec<&HfApiSibling> = Vec::new(); - let mut shard_files: Vec<&HfApiSibling> = Vec::new(); - for &f in &matching { - if is_shard_file(&f.rfilename) { - shard_files.push(f); - } else { - single_files.push(f); - } - } - - // Prefer single file if available - if let Some(single) = single_files.first() { - let mmproj = select_best_mmproj(&repo_id, &siblings, &single.rfilename, &quant); - let file = HfGgufFile { - filename: single.rfilename.clone(), - size_bytes: single.size.unwrap_or(0), - quantization: quant, - download_url: build_download_url(&repo_id, &single.rfilename), - }; - let total_size = file.size_bytes; - return Ok(( - repo_id, - ResolvedModel { - files: vec![file], - total_size, - mmproj, - }, - )); - } - - // Use shards, sorted by filename so shard 1 is first - shard_files.sort_by(|a, b| a.rfilename.cmp(&b.rfilename)); - - // Validate shard set completeness: every file must parse to the same - // -of-N total, and indices must be contiguous 1..=N. - let expected_total = parse_shard_total(&shard_files[0].rfilename).ok_or_else(|| { - anyhow::anyhow!( - "Cannot parse shard total from '{}'", - shard_files[0].rfilename - ) - })?; - if shard_files.len() != expected_total as usize { - bail!( - "Incomplete shard set for '{}' in {}: found {} of {} shards", - quant, - repo_id, - shard_files.len(), - expected_total - ); - } - for (i, shard) in shard_files.iter().enumerate() { - let shard_total = parse_shard_total(&shard.rfilename); - if shard_total != Some(expected_total) { - bail!( - "Inconsistent shard totals for '{}' in {}: shard '{}' has total {:?}, expected {}", - quant, - repo_id, - shard.rfilename, - shard_total, - expected_total - ); - } - let idx = parse_shard_index(&shard.rfilename); - if idx != Some((i + 1) as u32) { - bail!( - "Non-contiguous shard set for '{}' in {}: expected shard {} but found {:?}", - quant, - repo_id, - i + 1, - idx - ); - } - } - - let files: Vec = shard_files - .iter() - .map(|s| HfGgufFile { - filename: s.rfilename.clone(), - size_bytes: s.size.unwrap_or(0), - quantization: quant.clone(), - download_url: build_download_url(&repo_id, &s.rfilename), - }) - .collect(); - let total_size: u64 = files.iter().map(|f| f.size_bytes).sum(); - - let mmproj = select_best_mmproj(&repo_id, &siblings, &files[0].filename, &quant); - - Ok(( - repo_id, - ResolvedModel { - files, - total_size, - mmproj, - }, - )) -} - -/// Resolve a model spec to a specific GGUF file from the repo. -pub async fn resolve_model_spec(spec: &str) -> Result<(String, HfGgufFile)> { - let (repo_id, resolved) = resolve_model_spec_full(spec).await?; - if resolved.files.len() > 1 { - bail!( - "Model '{}' is sharded ({} files) โ€” use resolve_model_spec_full instead", - spec, - resolved.files.len() - ); - } - Ok((repo_id, resolved.files.into_iter().next().unwrap())) -} - -/// Recommend which quantization variant to use based on available memory. -pub fn recommend_variant( - variants: &[HfQuantVariant], - available_memory_bytes: u64, -) -> Option { - // We need ~10-20% overhead beyond model size for inference context. - // Pick the highest-quality variant that fits. - let usable = (available_memory_bytes as f64 * 0.85) as u64; - - let mut best: Option = None; - for (i, v) in variants.iter().enumerate() { - if v.size_bytes <= usable { - match best { - Some(bi) if variants[bi].quality_rank < v.quality_rank => best = Some(i), - None => best = Some(i), - _ => {} - } - } - } - best -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_quantization() { - assert_eq!(parse_quantization("Model-Q4_K_M.gguf"), "Q4_K_M"); - assert_eq!(parse_quantization("Model-Q8_0.gguf"), "Q8_0"); - assert_eq!(parse_quantization("Model-IQ4_NL.gguf"), "IQ4_NL"); - assert_eq!(parse_quantization("Model-F16.gguf"), "F16"); - assert_eq!(parse_quantization("random-name.gguf"), "unknown"); - } - - #[test] - fn test_hf_authorization_header() { - assert_eq!( - hf_authorization_header(Some("hf_test")).as_deref(), - Some("Bearer hf_test") - ); - assert_eq!(hf_authorization_header(Some("")), None); - assert_eq!(hf_authorization_header(None), None); - } - - #[test] - fn test_parse_quantization_with_directory() { - assert_eq!( - parse_quantization("Q5_K_M/Model-Q5_K_M-00001-of-00002.gguf"), - "Q5_K_M" - ); - } - - #[test] - fn test_parse_quantization_extended_tags() { - assert_eq!(parse_quantization("Model-MXFP4_MOE.gguf"), "MXFP4_MOE"); - assert_eq!(parse_quantization("Model-UD-TQ1_0.gguf"), "TQ1_0"); - assert_eq!(parse_quantization("Model-Q2_K_L.gguf"), "Q2_K_L"); - assert_eq!(parse_quantization("Model-UD-Q4_K_XL.gguf"), "Q4_K_XL"); - assert_eq!(parse_quantization("Model-UD-IQ1_M.gguf"), "IQ1_M"); - } - - #[test] - fn test_is_shard_file() { - assert!(is_shard_file("Q5_K_M/Model-Q5_K_M-00001-of-00002.gguf")); - assert!(is_shard_file("Model-BF16-00003-of-00004.gguf")); - assert!(!is_shard_file("Model-Q4_K_M.gguf")); - } - - #[test] - fn test_parse_model_spec() { - let (repo, quant) = - parse_model_spec("bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M").unwrap(); - assert_eq!(repo, "bartowski/Llama-3.2-1B-Instruct-GGUF"); - assert_eq!(quant, "Q4_K_M"); - } - - #[test] - fn test_parse_model_spec_invalid() { - assert!(parse_model_spec("no-colon").is_err()); - assert!(parse_model_spec("noslash:Q4_K_M").is_err()); - assert!(parse_model_spec("owner/repo:").is_err()); - } - - #[test] - fn test_dedupe_models_merges_variants_for_same_repo() { - let repo_id = "mixed/repo".to_string(); - let gguf_variant = HfModelVariant { - variant_id: "Q4_K_M".to_string(), - label: "Q4_K_M".to_string(), - backend_id: LLAMACPP_BACKEND_ID.to_string(), - format: GGUF_FORMAT.to_string(), - model_id: "mixed/repo:Q4_K_M".to_string(), - download_id: "mixed/repo:Q4_K_M".to_string(), - size_bytes: 4, - filename: Some("model-Q4_K_M.gguf".to_string()), - download_url: Some("https://example.test/model-Q4_K_M.gguf".to_string()), - description: "Medium".to_string(), - quality_rank: 45, - sharded: false, - supported: true, - unsupported_reason: None, - }; - let mlx_variant = HfModelVariant { - variant_id: MLX_VARIANT_ID.to_string(), - label: "Default".to_string(), - backend_id: MLX_BACKEND_ID.to_string(), - format: MLX_FORMAT.to_string(), - model_id: repo_id.clone(), - download_id: repo_id.clone(), - size_bytes: 8, - filename: None, - download_url: None, - description: "MLX".to_string(), - quality_rank: 91, - sharded: true, - supported: true, - unsupported_reason: None, - }; - let mut models = vec![ - HfModelInfo { - repo_id: repo_id.clone(), - author: "mixed".to_string(), - model_name: "repo".to_string(), - downloads: 1, - gguf_files: vec![HfGgufFile { - filename: "model-Q4_K_M.gguf".to_string(), - size_bytes: 4, - quantization: "Q4_K_M".to_string(), - download_url: "https://example.test/model-Q4_K_M.gguf".to_string(), - }], - variants: vec![gguf_variant], - }, - HfModelInfo { - repo_id, - author: "mixed".to_string(), - model_name: "repo".to_string(), - downloads: 2, - gguf_files: Vec::new(), - variants: vec![mlx_variant], - }, - ]; - - dedupe_models(&mut models); - - assert_eq!(models.len(), 1); - assert_eq!(models[0].downloads, 2); - assert_eq!(models[0].gguf_files.len(), 1); - assert_eq!(models[0].variants.len(), 2); - assert!(models[0] - .variants - .iter() - .any(|variant| variant.backend_id == LLAMACPP_BACKEND_ID)); - assert!(models[0] - .variants - .iter() - .any(|variant| variant.backend_id == MLX_BACKEND_ID)); - } - - fn sibling(filename: &str) -> RepoSibling { - RepoSibling { - rfilename: filename.to_string(), - size: Some(1), - lfs: None, - } - } - - fn mlx_siblings(tokenizer_files: &[&str]) -> Vec { - let mut siblings = vec![sibling("config.json"), sibling("model.safetensors")]; - siblings.extend(tokenizer_files.iter().map(|filename| sibling(filename))); - siblings - } - - #[test] - fn mlx_compatible_repo_accepts_tokenizer_json() { - let config = Some(serde_json::json!({ "model_type": "llama" })); - - assert!(is_mlx_compatible_repo( - &config, - &mlx_siblings(&["tokenizer.json"]) - )); - } - - #[test] - fn mlx_compatible_repo_rejects_incomplete_tokenizer_files() { - let config = Some(serde_json::json!({ "model_type": "llama" })); - - for tokenizer_files in [ - vec!["tokenizer_config.json"], - vec!["tokenizer.model"], - vec!["tokenizer.tiktoken"], - vec!["vocab.json"], - vec!["merges.txt"], - vec!["vocab.json", "merges.txt"], - ] { - assert!( - !is_mlx_compatible_repo(&config, &mlx_siblings(&tokenizer_files)), - "{tokenizer_files:?}" - ); - } - } - - #[test] - fn mlx_download_filenames_include_fp8_snapshot_metadata() { - let siblings = [ - "config.json", - "configuration.json", - "generation_config.json", - "model.safetensors.index.json", - "layers-0.safetensors", - "outside.safetensors", - "tokenizer.json", - "tokenizer_config.json", - "chat_template.jinja", - "preprocessor_config.json", - "video_preprocessor_config.json", - "README.md", - ] - .into_iter() - .map(sibling) - .collect::>(); - - let filenames = mlx_download_filenames(&siblings) - .into_iter() - .collect::>(); - - for filename in [ - "configuration.json", - "chat_template.jinja", - "preprocessor_config.json", - "video_preprocessor_config.json", - ] { - assert!(filenames.contains(filename), "{filename}"); - } - assert!(!filenames.contains("README.md")); - } - - #[test] - fn mlx_download_size_uses_safetensors_metadata_when_sibling_sizes_are_missing() { - let info: ModelInfo = serde_json::from_value(serde_json::json!({ - "id": "owner/repo", - "safetensors": { - "parameters": { - "BF16": 10, - "F8_E4M3": 20 - }, - "total": 30 - } - })) - .unwrap(); - let siblings = vec![ - RepoSibling { - rfilename: "config.json".to_string(), - size: None, - lfs: None, - }, - RepoSibling { - rfilename: "model.safetensors".to_string(), - size: None, - lfs: None, - }, - ]; - - assert_eq!(mlx_download_size_bytes(&info, &siblings), 40); - } - - #[test] - fn mlx_variant_id_detects_fp8_repo_name() { - assert_eq!(mlx_variant_id("Qwen/Qwen3.6-35B-A3B-FP8", &None), "fp8"); - } - - fn test_model(repo_id: &str) -> HfModelInfo { - HfModelInfo { - repo_id: repo_id.to_string(), - author: repo_id.split_once('/').unwrap().0.to_string(), - model_name: repo_id.split_once('/').unwrap().1.to_string(), - downloads: 1, - gguf_files: Vec::new(), - variants: Vec::new(), - } - } - - #[test] - fn append_optional_mlx_results_extends_on_success() { - let mut results = vec![test_model("gguf/repo")]; - - append_optional_mlx_results( - &mut results, - Ok(vec![test_model("mlx/repo")]), - "search-query", - ); - - assert_eq!(results.len(), 2); - assert!(results.iter().any(|model| model.repo_id == "gguf/repo")); - assert!(results.iter().any(|model| model.repo_id == "mlx/repo")); - } - - #[test] - fn append_optional_mlx_results_preserves_existing_on_error() { - let mut results = vec![test_model("gguf/repo")]; - - append_optional_mlx_results( - &mut results, - Err(anyhow::anyhow!("hf-hub unavailable")), - "search-query", - ); - - assert_eq!(results.len(), 1); - assert_eq!(results[0].repo_id, "gguf/repo"); - } - - #[test] - fn best_download_count_ignores_zero_primary() { - assert_eq!(best_download_count(Some(0), Some(42)), Some(42)); - assert_eq!(best_download_count(Some(7), Some(42)), Some(7)); - assert_eq!(best_download_count(Some(0), Some(0)), None); - } - - #[test] - fn test_recommend_variant() { - let variants = vec![ - HfQuantVariant { - quantization: "Q2_K".into(), - size_bytes: 2_000_000_000, - filename: "m-Q2_K.gguf".into(), - download_url: String::new(), - description: "Small", - quality_rank: 24, - sharded: false, - }, - HfQuantVariant { - quantization: "Q4_K_M".into(), - size_bytes: 4_000_000_000, - filename: "m-Q4_K_M.gguf".into(), - download_url: String::new(), - description: "Medium", - quality_rank: 45, - sharded: false, - }, - HfQuantVariant { - quantization: "Q8_0".into(), - size_bytes: 8_000_000_000, - filename: "m-Q8_0.gguf".into(), - download_url: String::new(), - description: "Large", - quality_rank: 80, - sharded: false, - }, - ]; - - assert_eq!(recommend_variant(&variants, 5_000_000_000), Some(1)); - assert_eq!(recommend_variant(&variants, 10_000_000_000), Some(2)); - assert_eq!(recommend_variant(&variants, 1_000_000_000), None); - } - - #[test] - fn test_model_stem_from_repo() { - assert_eq!( - model_stem_from_repo("unsloth/gemma-4-26B-A4B-it-GGUF"), - "gemma-4-26b-a4b-it" - ); - assert_eq!( - model_stem_from_repo("bartowski/Llama-3.2-3B-Instruct-GGUF"), - "llama-3.2-3b-instruct" - ); - assert_eq!(model_stem_from_repo("someone/SomeModel"), "somemodel"); - } - - #[test] - fn hf_download_progress_init_preserves_cancelled_reservation() { - let model_id = "test-cancelled-hf-progress-init"; - let download_id = format!("{}-model", model_id); - let manager = crate::download_manager::get_download_manager(); - manager.set_progress(crate::download_manager::DownloadProgress { - model_id: download_id.clone(), - status: crate::download_manager::DownloadStatus::Cancelled, - bytes_downloaded: 0, - total_bytes: 0, - progress_percent: 0.0, - speed_bps: None, - eta_seconds: None, - error: None, - task_exited: false, - }); - - HfDownloadProgress::new(model_id.to_string(), 42).init(); - - let progress = manager.get_progress(&download_id).expect("progress"); - assert_eq!( - progress.status, - crate::download_manager::DownloadStatus::Cancelled - ); - assert!(!progress.task_exited); - - manager.update_progress(&download_id, |progress| { - progress.task_exited = true; - }); - manager.clear_completed(&download_id); - } - - #[test] - fn test_is_model_file() { - let stem = "gemma-3-27b-it"; - assert!(is_model_file("gemma-3-27b-it-Q4_K_M.gguf", stem)); - assert!(is_model_file( - "BF16/gemma-3-27b-it-BF16-00001-of-00002.gguf", - stem - )); - assert!(!is_model_file("mmproj-BF16.gguf", stem)); - assert!(!is_model_file("vision-encoder-Q4_K_M.gguf", stem)); - } - - #[test] - fn test_group_into_variants_filters_auxiliary_files() { - let files = vec![ - HfApiSibling { - rfilename: "gemma-3-27b-it-Q4_K_M.gguf".into(), - size: Some(4_000_000_000), - }, - HfApiSibling { - rfilename: "mmproj-BF16.gguf".into(), - size: Some(800_000_000), - }, - ]; - let variants = group_into_variants("unsloth/gemma-3-27b-it-GGUF", files); - assert_eq!(variants.len(), 1); - assert_eq!(variants[0].quantization, "Q4_K_M"); - } - - #[test] - fn test_group_into_variants_includes_shard_only_quants() { - let files = vec![ - HfApiSibling { - rfilename: "BF16/gemma-3-27b-it-BF16-00001-of-00002.gguf".into(), - size: Some(40_000_000_000), - }, - HfApiSibling { - rfilename: "BF16/gemma-3-27b-it-BF16-00002-of-00002.gguf".into(), - size: Some(10_000_000_000), - }, - HfApiSibling { - rfilename: "gemma-3-27b-it-Q4_K_M.gguf".into(), - size: Some(4_000_000_000), - }, - ]; - let variants = group_into_variants("unsloth/gemma-3-27b-it-GGUF", files); - assert_eq!(variants.len(), 2); - // Sorted descending by quality_rank: BF16 (91) > Q4_K_M (45) - assert_eq!(variants[0].quantization, "BF16"); - assert!(variants[0].sharded); - assert_eq!(variants[0].size_bytes, 50_000_000_000); - assert_eq!(variants[1].quantization, "Q4_K_M"); - assert!(!variants[1].sharded); - } - - #[test] - fn test_group_into_variants_sorted_descending() { - let files = vec![ - HfApiSibling { - rfilename: "Model-IQ1_S.gguf".into(), - size: Some(500_000_000), - }, - HfApiSibling { - rfilename: "Model-Q4_K_M.gguf".into(), - size: Some(4_000_000_000), - }, - HfApiSibling { - rfilename: "Model-Q8_0.gguf".into(), - size: Some(8_000_000_000), - }, - ]; - let variants = group_into_variants("someone/Model-GGUF", files); - assert_eq!(variants.len(), 3); - assert_eq!(variants[0].quantization, "Q8_0"); - assert_eq!(variants[1].quantization, "Q4_K_M"); - assert_eq!(variants[2].quantization, "IQ1_S"); - } - - #[test] - fn test_select_best_mmproj_prefers_closest_precision() { - let files = vec![ - HfApiSibling { - rfilename: "mmproj-F32.gguf".into(), - size: Some(3_000), - }, - HfApiSibling { - rfilename: "mmproj-BF16.gguf".into(), - size: Some(2_000), - }, - ]; - - let mmproj = - select_best_mmproj("someone/model-GGUF", &files, "model-Q4_K_M.gguf", "Q4_K_M") - .unwrap(); - - assert_eq!(mmproj.filename, "mmproj-BF16.gguf"); - assert_eq!(mmproj.quantization, "BF16"); - } - - #[tokio::test] - async fn optional_hf_token_returns_resolved_token() { - let token = optional_hf_token(async { Ok(Some("token".to_string())) }).await; - - assert_eq!(token.as_deref(), Some("token")); - } - - #[tokio::test] - async fn optional_hf_token_ignores_resolution_errors() { - let token = - optional_hf_token(async { Err(anyhow::anyhow!("refresh token revoked")) }).await; - - assert_eq!(token, None); - } - - #[test] - fn test_select_best_mmproj_prefers_bf16_over_f16_tie() { - let files = vec![ - HfApiSibling { - rfilename: "mmproj-F16.gguf".into(), - size: Some(2_000), - }, - HfApiSibling { - rfilename: "mmproj-BF16.gguf".into(), - size: Some(2_000), - }, - ]; - - let mmproj = - select_best_mmproj("someone/model-GGUF", &files, "model-Q8_0.gguf", "Q8_0").unwrap(); - - assert_eq!(mmproj.filename, "mmproj-BF16.gguf"); - } - - #[test] - fn test_select_best_mmproj_prefers_nearest_directory() { - let files = vec![ - HfApiSibling { - rfilename: "mmproj-BF16.gguf".into(), - size: Some(2_000), - }, - HfApiSibling { - rfilename: "Q4_K_M/mmproj-F32.gguf".into(), - size: Some(3_000), - }, - ]; - - let mmproj = select_best_mmproj( - "someone/model-GGUF", - &files, - "Q4_K_M/model-Q4_K_M.gguf", - "Q4_K_M", - ) - .unwrap(); - - assert_eq!(mmproj.filename, "Q4_K_M/mmproj-F32.gguf"); - } - - #[test] - fn test_select_best_mmproj_ignores_sibling_directories() { - let files = vec![ - HfApiSibling { - rfilename: "Q8_0/mmproj-BF16.gguf".into(), - size: Some(2_000), - }, - HfApiSibling { - rfilename: "mmproj-F32.gguf".into(), - size: Some(3_000), - }, - ]; - - let mmproj = select_best_mmproj( - "someone/model-GGUF", - &files, - "Q4_K_M/model-Q4_K_M.gguf", - "Q4_K_M", - ) - .unwrap(); - - assert_eq!(mmproj.filename, "mmproj-F32.gguf"); - } -} - -async fn hf_client() -> Result { - let mut builder = HFClient::builder().user_agent("goose-ai-agent"); - if let Some(token) = optional_hf_token(huggingface_auth::resolve_token_async()).await { - builder = builder.token(token); - } - builder.build().map_err(Into::into) -} - -fn model_repo(client: &HFClient, repo_id: &str) -> Result> { - let (owner, name) = split_repo_id(repo_id)?; - Ok(client.model(owner, name)) -} - -fn split_repo_id(repo_id: &str) -> Result<(&str, &str)> { - repo_id - .split_once('/') - .ok_or_else(|| anyhow::anyhow!("Invalid repo id '{}': expected owner/name", repo_id)) -} - -async fn search_mlx_models(query: &str, limit: usize) -> Result> { - let mut results = search_mlx_models_with_query(query, limit).await?; - if !query.contains('/') { - results - .extend(search_mlx_models_with_query(&format!("mlx-community/{query}"), limit).await?); - results.extend(search_mlx_models_with_query(&format!("google/{query}"), limit).await?); - } - dedupe_models(&mut results); - results.truncate(limit); - Ok(results) -} - -async fn search_mlx_models_with_query(query: &str, limit: usize) -> Result> { - let client = hf_client().await?; - let stream = client - .list_models() - .search(query.to_string()) - .sort("downloads".to_string()) - .limit(limit.saturating_mul(5).max(limit)) - .send()?; - futures::pin_mut!(stream); - - let mut results = Vec::new(); - while let Some(info) = stream.next().await { - let info = info?; - if results.len() >= limit { - break; - } - if let Some(model) = get_local_model_info_for_repo_with_client_and_downloads( - &client, - &info.id, - info.downloads, - ) - .await? - { - results.push(model); - } - } - - Ok(results) -} - -async fn get_local_model_info_for_repo(repo_id: &str) -> Result> { - let client = hf_client().await?; - get_local_model_info_for_repo_with_client(&client, repo_id).await -} - -async fn get_local_model_info_for_repo_with_client( - client: &HFClient, - repo_id: &str, -) -> Result> { - get_local_model_info_for_repo_with_client_and_downloads(client, repo_id, None).await -} - -async fn get_local_model_info_for_repo_with_client_and_downloads( - client: &HFClient, - repo_id: &str, - downloads_hint: Option, -) -> Result> { - let repo = model_repo(client, repo_id)?; - let info = repo - .info() - .expand(vec![ - "siblings".to_string(), - "config".to_string(), - "safetensors".to_string(), - ]) - .send() - .await?; - model_info_to_local_model_info(&repo, info, downloads_hint).await -} - -async fn get_exact_name_local_model_info(model_name: &str) -> Result> { - for owner in ["google", "mlx-community"] { - let repo_id = format!("{owner}/{model_name}"); - if let Ok(Some(model)) = get_local_model_info_for_repo(&repo_id).await { - return Ok(Some(model)); - } - } - Ok(None) -} - -async fn model_info_to_local_model_info( - repo: &HFRepository, - info: ModelInfo, - downloads_hint: Option, -) -> Result> { - let repo_id = info.id.clone(); - let mut variants: Vec = get_repo_gguf_variants(&repo_id) - .await - .unwrap_or_default() - .iter() - .map(|variant| variant.to_model_variant(&repo_id)) - .collect(); - if is_mlx_compatible_model_info(&info) { - let mlx_config = load_repo_config_json(repo).await.unwrap_or_else(|error| { - tracing::debug!(repo_id, %error, "Failed to load MLX config.json; falling back to API config"); - info.config.clone() - }); - variants.extend(mlx_variants_from_model_info(&repo_id, &info, &mlx_config)); - } - - if variants.is_empty() { - return Ok(None); - } - - let author = info - .author - .unwrap_or_else(|| repo_id.split('/').next().unwrap_or_default().to_string()); - let model_name = repo_id - .split('/') - .next_back() - .unwrap_or(&repo_id) - .to_string(); - let downloads = match best_download_count(info.downloads, downloads_hint) { - Some(downloads) => downloads, - None => get_repo_downloads(&repo_id).await?.unwrap_or(0), - }; - - Ok(Some(HfModelInfo { - repo_id, - author, - model_name, - downloads, - gguf_files: Vec::new(), - variants, - })) -} - -fn best_download_count(primary: Option, hint: Option) -> Option { - primary - .filter(|downloads| *downloads > 0) - .or_else(|| hint.filter(|downloads| *downloads > 0)) -} - -async fn get_repo_downloads(repo_id: &str) -> Result> { - let client = reqwest::Client::new(); - let token = optional_hf_token(huggingface_auth::resolve_token_async()).await; - let url = format!("{}/{}", HF_API_BASE, repo_id); - - let response = apply_hf_auth(client.get(&url), token.as_deref()) - .header("User-Agent", "goose-ai-agent") - .send() - .await?; - - if !response.status().is_success() { - bail!( - "HuggingFace API returned status {} for repo {}", - response.status(), - repo_id - ); - } - - let model: HfApiModel = response.json().await?; - Ok(model.downloads) -} - -pub async fn get_repo_local_variants(repo_id: &str) -> Result> { - let mut variants: Vec = get_repo_gguf_variants(repo_id) - .await - .unwrap_or_default() - .iter() - .map(|variant| variant.to_model_variant(repo_id)) - .collect(); - variants.extend(get_repo_mlx_variants(repo_id).await.unwrap_or_default()); - variants.sort_by(|a, b| { - a.backend_id - .cmp(&b.backend_id) - .then_with(|| b.quality_rank.cmp(&a.quality_rank)) - .then_with(|| a.variant_id.cmp(&b.variant_id)) - }); - Ok(variants) -} - -pub async fn get_repo_mlx_variants(repo_id: &str) -> Result> { - let client = hf_client().await?; - let repo = model_repo(&client, repo_id)?; - let info = repo - .info() - .expand(vec![ - "siblings".to_string(), - "config".to_string(), - "safetensors".to_string(), - ]) - .send() - .await?; - if !is_mlx_compatible_model_info(&info) { - return Ok(Vec::new()); - } - let mlx_config = load_repo_config_json(&repo) - .await - .unwrap_or_else(|_| info.config.clone()); - Ok(mlx_variants_from_model_info(repo_id, &info, &mlx_config)) -} - -async fn load_repo_config_json( - repo: &HFRepository, -) -> Result> { - let config_path = repo - .download_file() - .filename("config.json".to_string()) - .send() - .await?; - let config_json = tokio::fs::read_to_string(config_path).await?; - Ok(Some(serde_json::from_str(&config_json)?)) -} - -fn mlx_variants_from_model_info( - repo_id: &str, - info: &ModelInfo, - mlx_config: &Option, -) -> Vec { - let siblings = info.siblings.as_deref().unwrap_or(&[]); - - if !is_mlx_compatible_repo(&info.config, siblings) { - return Vec::new(); - } - - let size_bytes = mlx_download_size_bytes(info, siblings); - let variant_id = mlx_variant_id(repo_id, &info.config); - - vec![HfModelVariant { - variant_id: variant_id.clone(), - label: mlx_variant_label(&variant_id), - backend_id: MLX_BACKEND_ID.to_string(), - format: MLX_FORMAT.to_string(), - model_id: repo_id.to_string(), - download_id: repo_id.to_string(), - size_bytes, - filename: None, - download_url: None, - description: mlx_variant_description(mlx_config), - quality_rank: 91, - sharded: siblings - .iter() - .filter(|s| s.rfilename.ends_with(".safetensors")) - .count() - > 1, - supported: is_mlx_runtime_supported(mlx_config) - && cfg!(target_os = "macos") - && cfg!(feature = "mlx"), - unsupported_reason: mlx_unsupported_reason(mlx_config), - }] -} - -fn is_mlx_compatible_model_info(info: &ModelInfo) -> bool { - is_mlx_compatible_repo(&info.config, info.siblings.as_deref().unwrap_or_default()) -} - -fn is_mlx_compatible_repo(config: &Option, siblings: &[RepoSibling]) -> bool { - let has_config = siblings.iter().any(|s| s.rfilename == "config.json"); - let has_tokenizer = has_mlx_tokenizer(siblings); - let has_safetensors = siblings - .iter() - .any(|s| s.rfilename.ends_with(".safetensors")); - - has_config && has_tokenizer && has_safetensors && mlx_model_type(config).is_some() -} - -fn mlx_download_size_bytes(info: &ModelInfo, siblings: &[RepoSibling]) -> u64 { - let sibling_size: u64 = mlx_download_filenames(siblings) - .into_iter() - .filter_map(|filename| { - siblings - .iter() - .find(|s| s.rfilename == filename) - .and_then(|s| s.size) - }) - .sum(); - sibling_size.max(estimated_safetensors_size_bytes(info)) -} - -fn estimated_safetensors_size_bytes(info: &ModelInfo) -> u64 { - info.safetensors - .as_ref() - .map(|safetensors| { - safetensors - .parameters - .iter() - .map(|(dtype, count)| count.saturating_mul(dtype_size_bytes(dtype))) - .sum() - }) - .unwrap_or(0) -} - -fn dtype_size_bytes(dtype: &str) -> u64 { - match dtype.to_ascii_uppercase().as_str() { - "BOOL" | "I8" | "U8" | "F8_E4M3" | "F8_E4M3FN" | "F8_E5M2" | "F8_E5M2FNUZ" => 1, - "BF16" | "F16" | "I16" | "U16" => 2, - "F32" | "I32" | "U32" => 4, - "F64" | "I64" | "U64" => 8, - _ => 0, - } -} - -fn has_mlx_tokenizer(siblings: &[RepoSibling]) -> bool { - siblings - .iter() - .any(|s| is_standalone_mlx_tokenizer_file(&s.rfilename)) -} - -fn is_standalone_mlx_tokenizer_file(filename: &str) -> bool { - filename == "tokenizer.json" -} - -fn mlx_model_type(config: &Option) -> Option<&str> { - config - .as_ref() - .and_then(|config| config.get("model_type")) - .and_then(|value| value.as_str()) -} - -fn is_mlx_runtime_supported(config: &Option) -> bool { - mlx_config_support(config).is_none() -} - -fn mlx_unsupported_reason(config: &Option) -> Option { - if !cfg!(target_os = "macos") { - return Some("MLX requires macOS".to_string()); - } - if !cfg!(feature = "mlx") { - return Some("MLX support was not compiled in".to_string()); - } - - mlx_config_support(config) -} - -fn mlx_config_support(config: &Option) -> Option { - let config = config.as_ref()?; - mlx_config_support_for_value(config) -} - -#[cfg(feature = "mlx")] -fn mlx_config_support_for_value(config: &serde_json::Value) -> Option { - safemlx_lm::check_model_config(config) - .unsupported_reason() - .map(str::to_string) -} - -#[cfg(not(feature = "mlx"))] -fn mlx_config_support_for_value(_config: &serde_json::Value) -> Option { - None -} - -fn mlx_variant_description(config: &Option) -> String { - match mlx_unsupported_reason(config) { - None => "MLX safetensors snapshot".to_string(), - Some(reason) => format!("MLX safetensors snapshot ({reason})"), - } -} - -fn mlx_download_filenames(siblings: &[RepoSibling]) -> Vec { - siblings - .iter() - .filter(|s| should_download_for_mlx(&s.rfilename)) - .map(|s| s.rfilename.clone()) - .collect() -} - -fn looks_like_repo_id(query: &str) -> bool { - let Some((owner, repo)) = query.split_once('/') else { - return false; - }; - !owner.is_empty() && !repo.is_empty() && !repo.contains('/') -} - -fn model_search_rank(query: &str, model: &HfModelInfo) -> u8 { - let query = query.to_lowercase(); - let repo_id = model.repo_id.to_lowercase(); - let model_name = model.model_name.to_lowercase(); - - if repo_id == query { - 0 - } else if model_name == query { - 1 - } else if repo_id.ends_with(&format!("/{query}")) { - 2 - } else if repo_id.contains(&query) { - 3 - } else { - 4 - } -} - -fn dedupe_models(models: &mut Vec) { - let mut merged: Vec = Vec::with_capacity(models.len()); - for model in std::mem::take(models) { - if let Some(existing) = merged - .iter_mut() - .find(|existing| existing.repo_id == model.repo_id) - { - merge_model_info(existing, model); - } else { - merged.push(model); - } - } - *models = merged; -} - -fn merge_model_info(existing: &mut HfModelInfo, duplicate: HfModelInfo) { - existing.downloads = existing.downloads.max(duplicate.downloads); - - let mut filenames: std::collections::HashSet = existing - .gguf_files - .iter() - .map(|file| file.filename.clone()) - .collect(); - existing.gguf_files.extend( - duplicate - .gguf_files - .into_iter() - .filter(|file| filenames.insert(file.filename.clone())), - ); - - let mut variant_keys: std::collections::HashSet<(String, String)> = existing - .variants - .iter() - .map(|variant| (variant.backend_id.clone(), variant.variant_id.clone())) - .collect(); - existing - .variants - .extend(duplicate.variants.into_iter().filter(|variant| { - variant_keys.insert((variant.backend_id.clone(), variant.variant_id.clone())) - })); -} - -fn should_download_for_mlx(filename: &str) -> bool { - filename.ends_with(".safetensors") - || filename == "config.json" - || is_standalone_mlx_tokenizer_file(filename) - || filename == "tokenizer_config.json" - || filename == "generation_config.json" - || filename == "configuration.json" - || filename == "chat_template.jinja" - || filename == "preprocessor_config.json" - || filename == "video_preprocessor_config.json" - || filename == "special_tokens_map.json" - || filename == "model.safetensors.index.json" - || filename == "vocab.json" - || filename == "merges.txt" - || filename == "added_tokens.json" -} - -fn mlx_variant_id(repo_id: &str, config: &Option) -> String { - let repo_lower = repo_id.to_lowercase(); - for marker in ["bf16", "f16", "fp16", "f32", "fp32", "fp8", "4bit", "8bit"] { - if repo_lower.contains(marker) { - return marker.to_string(); - } - } - config - .as_ref() - .and_then(|config| config.get("torch_dtype")) - .and_then(|value| value.as_str()) - .map(|dtype| dtype.replace("float", "f")) - .unwrap_or_else(|| MLX_VARIANT_ID.to_string()) -} - -fn mlx_variant_label(variant_id: &str) -> String { - if variant_id == MLX_VARIANT_ID { - "MLX".to_string() - } else { - format!("MLX {}", variant_id.to_uppercase()) - } -} - -pub async fn resolve_local_model_selection( - repo_id: &str, - backend_id: &str, - variant_id: Option<&str>, -) -> Result { - match backend_id { - MLX_BACKEND_ID => resolve_mlx_model(repo_id, variant_id.unwrap_or(MLX_VARIANT_ID)).await, - LLAMACPP_BACKEND_ID => { - let quantization = variant_id.ok_or_else(|| { - anyhow::anyhow!("llama.cpp model '{}' is missing a quantization", repo_id) - })?; - resolve_gguf_model(repo_id, quantization).await - } - _ => bail!("Unknown local inference backend '{}'", backend_id), - } -} - -fn snapshot_root_for_file( - path: &std::path::Path, - repo_filename: &str, -) -> Option { - let mut root = path.to_path_buf(); - for _ in 0..repo_filename.split('/').count() { - root.pop(); - } - Some(root) -} - -async fn resolve_gguf_model(repo_id: &str, quantization: &str) -> Result { - let spec = format!("{}:{}", repo_id, quantization); - let (_repo, resolved) = resolve_model_spec_full(&spec).await?; - let (local_paths, mmproj_path) = - download_gguf_to_hf_cache(repo_id, quantization, &resolved).await?; - Ok(ResolvedLocalModel::Gguf { - repo_id: repo_id.to_string(), - quantization: quantization.to_string(), - resolved, - local_paths, - mmproj_path, - storage: LocalModelStorage::HuggingFaceCache, - }) -} - -async fn download_gguf_to_hf_cache( - repo_id: &str, - quantization: &str, - resolved: &ResolvedModel, -) -> Result<(Vec, Option)> { - let (owner, name) = split_repo_id(repo_id)?; - let model_id = model_id_from_repo(repo_id, quantization); - let total_size = resolved - .files - .iter() - .chain(resolved.mmproj.iter()) - .map(|file| file.size_bytes) - .sum(); - let progress = HfDownloadProgress::new(model_id, total_size); - progress.init(); - let client = hf_client().await?; - let repo = client.model(owner.to_string(), name.to_string()); - let mut paths = Vec::with_capacity(resolved.files.len()); - for file in &resolved.files { - let path = match repo - .download_file() - .filename(file.filename.clone()) - .progress(progress.clone()) - .send() - .await - .map_err(anyhow::Error::from) - { - Ok(path) => path, - Err(error) => { - progress.fail(&error); - return Err(error); - } - }; - progress.finish_file(file.size_bytes); - paths.push(path); - } - - let mmproj_path = if let Some(mmproj) = &resolved.mmproj { - let path = match repo - .download_file() - .filename(mmproj.filename.clone()) - .progress(progress.clone()) - .send() - .await - .map_err(anyhow::Error::from) - { - Ok(path) => path, - Err(error) => { - progress.fail(&error); - return Err(error); - } - }; - progress.finish_file(mmproj.size_bytes); - Some(path) - } else { - None - }; - - progress.complete(); - Ok((paths, mmproj_path)) -} - -pub async fn resolve_local_model_spec(spec: &str) -> Result { - match parse_model_spec(spec) { - Ok((repo_id, quantization)) => return resolve_gguf_model(&repo_id, &quantization).await, - Err(error) if spec.contains(':') => return Err(error), - Err(_) => {} - } - - if looks_like_repo_id(spec) { - let variants = get_repo_local_variants(spec).await?; - let mlx_variants: Vec<_> = variants - .iter() - .filter(|variant| variant.backend_id == MLX_BACKEND_ID) - .collect(); - if mlx_variants.len() == 1 - && !variants - .iter() - .any(|variant| variant.backend_id == LLAMACPP_BACKEND_ID) - { - return resolve_mlx_model(spec, &mlx_variants[0].variant_id).await; - } - bail!( - "Model spec '{}' is ambiguous; choose one of: {}", - spec, - variants - .iter() - .map(|variant| variant.download_id.as_str()) - .collect::>() - .join(", ") - ); - } - - let (repo_id, quantization) = parse_model_spec(spec)?; - resolve_gguf_model(&repo_id, &quantization).await -} - -async fn resolve_mlx_model(repo_id: &str, variant_id: &str) -> Result { - let variants = get_repo_mlx_variants(repo_id).await?; - if !variants - .iter() - .any(|variant| variant.variant_id == variant_id) - { - bail!("No MLX variant '{}' found in {}", variant_id, repo_id); - } - let (owner, name) = split_repo_id(repo_id)?; - let client = hf_client().await?; - let repo = client.model(owner.to_string(), name.to_string()); - let info = repo - .info() - .expand(vec!["siblings".to_string(), "safetensors".to_string()]) - .send() - .await?; - let siblings = info.siblings.as_deref().unwrap_or(&[]); - let filenames = mlx_download_filenames(siblings); - let total_size = mlx_download_size_bytes(&info, siblings); - let progress = HfDownloadProgress::new(repo_id.to_string(), total_size); - progress.init(); - let mut snapshot_path = None; - for filename in filenames { - let file_size = siblings - .iter() - .find(|s| s.rfilename == filename) - .and_then(|s| s.size) - .unwrap_or(0); - let path = match repo - .download_file() - .filename(filename.clone()) - .progress(progress.clone()) - .send() - .await - .map_err(anyhow::Error::from) - { - Ok(path) => path, - Err(error) => { - progress.fail(&error); - return Err(error); - } - }; - if snapshot_path.is_none() { - snapshot_path = snapshot_root_for_file(&path, &filename); - } - progress.finish_file(file_size); - } - progress.complete(); - let snapshot_path = snapshot_path - .ok_or_else(|| anyhow::anyhow!("MLX model {} has no downloadable files", repo_id))?; - let total_size = if total_size > 0 { - total_size - } else { - dir_size(&snapshot_path) - }; - Ok(ResolvedLocalModel::Mlx { - repo_id: repo_id.to_string(), - variant_id: variant_id.to_string(), - snapshot_path, - total_size, - }) -} - -#[derive(Clone)] -struct HfDownloadProgress { - model_id: String, - total_bytes: u64, - completed_bytes: Arc>, - state: Arc>, -} - -#[derive(Default)] -struct HfDownloadState { - bytes_downloaded: u64, - current_file_total_bytes: u64, - speed_bps: Option, -} - -impl HfDownloadProgress { - fn new(model_id: String, total_bytes: u64) -> Self { - Self { - model_id, - total_bytes, - completed_bytes: Arc::new(Mutex::new(0)), - state: Arc::new(Mutex::new(HfDownloadState::default())), - } - } - - fn init(&self) { - let manager = crate::download_manager::get_download_manager(); - let download_id = format!("{}-model", self.model_id); - if manager.get_progress(&download_id).is_some() { - manager.update_progress(&download_id, |progress| { - if progress.status != crate::download_manager::DownloadStatus::Cancelled { - progress.status = crate::download_manager::DownloadStatus::Downloading; - progress.bytes_downloaded = 0; - progress.total_bytes = self.total_bytes; - progress.progress_percent = 0.0; - progress.speed_bps = None; - progress.eta_seconds = None; - progress.error = None; - progress.task_exited = false; - } - }); - } else { - manager.set_progress(crate::download_manager::DownloadProgress { - model_id: download_id, - status: crate::download_manager::DownloadStatus::Downloading, - bytes_downloaded: 0, - total_bytes: self.total_bytes, - progress_percent: 0.0, - speed_bps: None, - eta_seconds: None, - error: None, - task_exited: false, - }); - } - } - - fn is_cancelled(&self) -> bool { - crate::download_manager::get_download_manager() - .get_progress(&format!("{}-model", self.model_id)) - .is_some_and(|progress| { - progress.status == crate::download_manager::DownloadStatus::Cancelled - }) - } - - fn complete(&self) { - crate::download_manager::get_download_manager().update_progress( - &format!("{}-model", self.model_id), - |progress| { - if progress.status != crate::download_manager::DownloadStatus::Cancelled { - progress.status = crate::download_manager::DownloadStatus::Completed; - progress.progress_percent = 100.0; - } - progress.task_exited = true; - }, - ); - } - - fn fail(&self, error: impl ToString) { - crate::download_manager::get_download_manager().update_progress( - &format!("{}-model", self.model_id), - |progress| { - if progress.status != crate::download_manager::DownloadStatus::Cancelled { - progress.status = crate::download_manager::DownloadStatus::Failed; - progress.error = Some(error.to_string()); - } - progress.task_exited = true; - }, - ); - } - - fn finish_file(&self, size_bytes: u64) { - let observed_size = self - .state - .lock() - .map(|state| state.current_file_total_bytes.max(state.bytes_downloaded)) - .unwrap_or(0); - if let Ok(mut completed_bytes) = self.completed_bytes.lock() { - *completed_bytes = completed_bytes.saturating_add(size_bytes.max(observed_size)); - } - if let Ok(mut state) = self.state.lock() { - state.bytes_downloaded = 0; - state.current_file_total_bytes = 0; - } - self.update_progress_from_state(); - } - - fn update_progress_from_state(&self) { - let completed_bytes = self.completed_bytes.lock().map(|value| *value).unwrap_or(0); - if let Ok(state) = self.state.lock() { - let bytes_downloaded = completed_bytes.saturating_add(state.bytes_downloaded); - let total_bytes = - self.total_bytes - .max(completed_bytes.saturating_add( - state.current_file_total_bytes.max(state.bytes_downloaded), - )); - update_download_manager_progress( - &self.model_id, - bytes_downloaded.min(total_bytes), - total_bytes, - state.speed_bps, - ); - } - } -} - -impl ProgressHandler for HfDownloadProgress { - fn on_progress(&self, event: &ProgressEvent) { - let ProgressEvent::Download(event) = event else { - return; - }; - match event { - DownloadEvent::Start { total_bytes, .. } => { - if let Ok(mut state) = self.state.lock() { - state.current_file_total_bytes = *total_bytes; - } - self.update_progress_from_state(); - } - DownloadEvent::Progress { files } => { - if self.is_cancelled() { - return; - } - let bytes_downloaded = files - .iter() - .map(|file| { - if file.status == FileStatus::Complete { - file.total_bytes - } else { - file.bytes_completed - } - }) - .sum(); - let total_bytes = files.iter().map(|file| file.total_bytes).sum(); - if let Ok(mut state) = self.state.lock() { - state.bytes_downloaded = state.bytes_downloaded.max(bytes_downloaded); - state.current_file_total_bytes = - state.current_file_total_bytes.max(total_bytes); - } - self.update_progress_from_state(); - } - DownloadEvent::AggregateProgress { - bytes_completed, - total_bytes, - bytes_per_sec, - } => { - if self.is_cancelled() { - return; - } - if let Ok(mut state) = self.state.lock() { - state.bytes_downloaded = state.bytes_downloaded.max(*bytes_completed); - state.current_file_total_bytes = - (*total_bytes).max(state.current_file_total_bytes); - state.speed_bps = bytes_per_sec.map(|speed| speed as u64); - } - self.update_progress_from_state(); - } - DownloadEvent::Complete => {} - } - } -} - -pub fn register_resolved_model(resolved: ResolvedLocalModel, source: &str) -> Result { - let model_id = resolved.model_id(); - let repo_id = resolved.repo_id().to_string(); - let variant_id = resolved.variant_id().to_string(); - let backend_id = resolved.backend_id().to_string(); - let storage = resolved.storage(); - - let entry = match resolved { - ResolvedLocalModel::Gguf { - resolved, - local_paths, - mmproj_path, - .. - } => { - let first_file = &resolved.files[0]; - let first_local_path = local_paths - .first() - .cloned() - .ok_or_else(|| anyhow::anyhow!("Resolved GGUF model has no local files"))?; - let shard_files: Vec = resolved - .files - .iter() - .skip(1) - .zip(local_paths.iter().skip(1)) - .map(|(file, local_path)| ShardFile { - filename: file.filename.clone(), - local_path: local_path.clone(), - source_url: file.download_url.clone(), - size_bytes: file.size_bytes, - }) - .collect(); - let settings = super::local_model_registry::default_settings_for_model(&model_id); - super::local_model_registry::LocalModelEntry { - id: model_id.clone(), - repo_id, - filename: first_file.filename.clone(), - quantization: variant_id, - local_path: first_local_path, - source_url: first_file.download_url.clone(), - backend_id: settings.backend_id.clone(), - storage, - settings, - size_bytes: resolved.total_size, - mmproj_path, - mmproj_source_url: resolved - .mmproj - .as_ref() - .map(|mmproj| mmproj.download_url.clone()), - mmproj_size_bytes: resolved - .mmproj - .as_ref() - .map(|mmproj| mmproj.size_bytes) - .unwrap_or(0), - mmproj_checked: true, - shard_files, - } - } - ResolvedLocalModel::Mlx { - snapshot_path, - total_size, - .. - } => { - let mut settings = super::local_model_registry::default_settings_for_model(&model_id); - settings.backend_id = Some(backend_id.clone()); - super::local_model_registry::LocalModelEntry { - id: model_id.clone(), - repo_id, - filename: variant_id.clone(), - quantization: variant_id, - local_path: snapshot_path, - source_url: source.to_string(), - backend_id: Some(backend_id), - storage, - settings, - size_bytes: total_size, - mmproj_path: None, - mmproj_source_url: None, - mmproj_size_bytes: 0, - mmproj_checked: true, - shard_files: vec![], - } - } - }; - - let mut registry = get_registry() - .lock() - .map_err(|_| anyhow::anyhow!("Failed to acquire registry lock"))?; - registry.add_model(entry)?; - Ok(model_id) -} - -fn update_download_manager_progress( - model_id: &str, - bytes_downloaded: u64, - total_bytes: u64, - speed_bps: Option, -) { - crate::download_manager::get_download_manager().update_progress( - &format!("{}-model", model_id), - |progress| { - if progress.status == crate::download_manager::DownloadStatus::Cancelled { - return; - } - progress.bytes_downloaded = bytes_downloaded; - progress.total_bytes = total_bytes; - progress.progress_percent = if total_bytes > 0 { - (bytes_downloaded as f64 / total_bytes as f64 * 100.0) as f32 - } else { - 0.0 - }; - progress.speed_bps = speed_bps; - }, - ); -} - -fn dir_size(path: &std::path::Path) -> u64 { - if path.is_file() { - return std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); - } - let mut total = 0; - if let Ok(entries) = std::fs::read_dir(path) { - for entry in entries.flatten() { - total += dir_size(&entry.path()); - } - } - total -} diff --git a/crates/goose-local-inference/src/huggingface_auth.rs b/crates/goose-local-inference/src/huggingface_auth.rs deleted file mode 100644 index c1510e0625..0000000000 --- a/crates/goose-local-inference/src/huggingface_auth.rs +++ /dev/null @@ -1,64 +0,0 @@ -use crate::{config_resolver, paths::Paths}; -use anyhow::Result; -use chrono::{DateTime, Utc}; -use futures::future::BoxFuture; -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; -use std::sync::OnceLock; - -pub const HUGGINGFACE_TOKEN_SECRET_KEY: &str = "HF_TOKEN"; -pub const HUGGINGFACE_OAUTH_CACHE_PATH: &str = "huggingface/oauth/tokens.json"; - -pub type TokenResolver = fn() -> BoxFuture<'static, Result>>; - -static TOKEN_RESOLVER: OnceLock = OnceLock::new(); - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HuggingFaceTokenData { - pub access_token: String, - #[serde(default)] - pub refresh_token: Option, - #[serde(default)] - pub expires_at: Option>, -} - -impl HuggingFaceTokenData { - pub fn is_expired(&self) -> bool { - self.expires_at - .is_some_and(|expires_at| expires_at <= Utc::now()) - } -} - -pub fn oauth_cache_path() -> PathBuf { - Paths::in_config_dir(HUGGINGFACE_OAUTH_CACHE_PATH) -} - -fn load_oauth_token_from_path(path: &Path) -> Option { - let contents = std::fs::read_to_string(path).ok()?; - serde_json::from_str(&contents).ok() -} - -pub fn usable_oauth_token() -> Option { - let token = load_oauth_token_from_path(&oauth_cache_path())?; - (!token.is_expired()).then_some(token.access_token) -} - -pub fn hf_token_secret() -> Result> { - Ok(config_resolver::string_param(HUGGINGFACE_TOKEN_SECRET_KEY)? - .filter(|token| !token.trim().is_empty())) -} - -pub fn set_token_resolver(resolve_token: TokenResolver) { - let _ = TOKEN_RESOLVER.set(resolve_token); -} - -pub async fn resolve_token_async() -> Result> { - if let Some(resolve_token) = TOKEN_RESOLVER.get() { - return resolve_token().await; - } - - if let Some(token) = usable_oauth_token() { - return Ok(Some(token)); - } - hf_token_secret() -} diff --git a/crates/goose-local-inference/src/lib.rs b/crates/goose-local-inference/src/lib.rs deleted file mode 100644 index e36ee9bed0..0000000000 --- a/crates/goose-local-inference/src/lib.rs +++ /dev/null @@ -1,950 +0,0 @@ -pub mod config_resolver; -pub use goose_download_manager as download_manager; -pub mod huggingface_auth; -pub mod paths; -pub mod prompt_template; -pub mod provider_utils; - -mod backend; -pub mod hf_models; -mod llamacpp; -pub mod local_model_registry; -pub mod management; -mod mlx; -pub(crate) mod multimodal; -#[cfg(feature = "mlx")] -mod native_tool_parsing; -pub(crate) mod thinking_output; -#[cfg(feature = "mlx")] -mod tool_emulation; -mod tool_parsing; - -use anyhow::Result; -use async_stream::try_stream; -use async_trait::async_trait; -use backend::{BackendLoadedModel, LocalInferenceBackend}; -use goose_provider_types::base::{MessageStream, Provider, ProviderDescriptor, ProviderMetadata}; -use goose_provider_types::conversation::message::{ - Message, MessageContent, SystemNotificationType, -}; -use goose_provider_types::conversation::token_usage::{ProviderUsage, Usage}; -use goose_provider_types::errors::ProviderError; -use goose_provider_types::images::ImageFormat; -use goose_provider_types::model::ModelConfig; -use goose_provider_types::request_log::{start_log, LoggerHandleExt, RequestLogHandle}; -use llamacpp::{LlamaCppBackend, LLAMACPP_BACKEND_ID}; -use local_model_registry::ChatTemplate; -use mlx::{MlxBackend, MLX_BACKEND_ID}; -use rmcp::model::Tool; -use serde_json::{json, Value}; -use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; -use std::sync::{Arc, Mutex as StdMutex}; -use tokio::sync::{Mutex, Notify}; -use uuid::Uuid; - -type ModelSlotHandle = Arc; - -struct ModelSlot { - state: Mutex, - notify: Notify, -} - -enum ModelSlotState { - Empty, - Loading, - Loaded(Box), -} - -impl ModelSlot { - fn new() -> Self { - Self { - state: Mutex::new(ModelSlotState::Empty), - notify: Notify::new(), - } - } -} - -#[derive(Clone, Debug, Eq, Hash, PartialEq)] -struct ModelCacheKey { - backend_id: &'static str, - model_id: String, - chat_template: ChatTemplate, -} - -impl ModelCacheKey { - fn new( - backend_id: &'static str, - model_id: impl Into, - chat_template: ChatTemplate, - ) -> Self { - Self { - backend_id, - model_id: model_id.into(), - chat_template, - } - } -} - -pub struct InferenceRuntime { - models: StdMutex>, - cold_load_lock: Mutex<()>, - backends: HashMap<&'static str, Arc>, -} - -pub fn builtin_chat_template_names() -> Vec { - llamacpp::builtin_chat_template_names() -} - -static RUNTIME: StdMutex>> = StdMutex::new(None); - -fn current_runtime() -> Option> { - RUNTIME.lock().expect("runtime lock poisoned").clone() -} - -impl InferenceRuntime { - pub fn get_or_init() -> Result> { - let mut guard = RUNTIME.lock().expect("runtime lock poisoned"); - if let Some(runtime) = guard.as_ref() { - return Ok(runtime.clone()); - } - let llamacpp_backend: Arc = Arc::new(LlamaCppBackend::new()?); - let mlx_backend: Arc = Arc::new(MlxBackend::new()); - let mut backends = HashMap::new(); - backends.insert(LLAMACPP_BACKEND_ID, llamacpp_backend); - backends.insert(MLX_BACKEND_ID, mlx_backend); - let runtime = Arc::new(Self { - models: StdMutex::new(HashMap::new()), - cold_load_lock: Mutex::new(()), - backends, - }); - *guard = Some(runtime.clone()); - Ok(runtime) - } - - fn default_backend(&self) -> &dyn LocalInferenceBackend { - self.backends - .get(LLAMACPP_BACKEND_ID) - .expect("default local inference backend registered") - .as_ref() - } - - fn backend_for_model( - &self, - resolved: &ResolvedModelPaths, - ) -> Result, ProviderError> { - let backend_id = resolved - .backend_id - .as_deref() - .unwrap_or(LLAMACPP_BACKEND_ID); - self.backends.get(backend_id).cloned().ok_or_else(|| { - ProviderError::ExecutionError(format!( - "Local inference backend '{}' unavailable", - backend_id - )) - }) - } - - fn get_or_create_model_slot(&self, key: ModelCacheKey) -> ModelSlotHandle { - let mut map = self.models.lock().expect("model cache lock poisoned"); - map.entry(key) - .or_insert_with(|| Arc::new(ModelSlot::new())) - .clone() - } - - fn model_slot(&self, key: &ModelCacheKey) -> Option { - let map = self.models.lock().expect("model cache lock poisoned"); - map.get(key).cloned() - } - - fn other_model_slots(&self, keep_key: &ModelCacheKey) -> Vec { - let map = self.models.lock().expect("model cache lock poisoned"); - map.iter() - .filter(|(key, _)| *key != keep_key) - .map(|(_, slot)| slot.clone()) - .collect() - } -} - -pub async fn is_model_loaded(model_name: &str) -> Result { - let resolved = match resolve_model_path(model_name) { - Some(resolved) => resolved, - None => return Ok(false), - }; - let runtime = InferenceRuntime::get_or_init().map_err(|error| { - ProviderError::ExecutionError(format!("Failed to initialize local inference: {error}")) - })?; - let backend = runtime.backend_for_model(&resolved)?; - let key = ModelCacheKey::new( - backend.id(), - model_name.to_string(), - resolved.settings.chat_template, - ); - let Some(slot) = runtime.model_slot(&key) else { - return Ok(false); - }; - - let state = slot.state.lock().await; - Ok(matches!(*state, ModelSlotState::Loaded(_))) -} - -pub async fn loaded_model_ids() -> Result, ProviderError> { - let Some(runtime) = current_runtime() else { - return Ok(HashSet::new()); - }; - let slots = { - let map = runtime.models.lock().expect("model cache lock poisoned"); - map.iter() - .map(|(key, slot)| (key.model_id.clone(), slot.clone())) - .collect::>() - }; - - let mut loaded = HashSet::new(); - for (model_id, slot) in slots { - if let Ok(state) = slot.state.try_lock() { - if matches!(*state, ModelSlotState::Loaded(_)) { - loaded.insert(model_id); - } - } else { - loaded.insert(model_id); - } - } - Ok(loaded) -} - -pub async fn evict_model(model_name: &str) -> Result { - let Some(runtime) = current_runtime() else { - return Ok(false); - }; - let slots = { - let map = runtime.models.lock().expect("model cache lock poisoned"); - map.iter() - .filter(|(key, _)| key.model_id == model_name) - .map(|(_, slot)| slot.clone()) - .collect::>() - }; - - let mut evicted = false; - for slot in slots { - let mut state = slot.state.lock().await; - if matches!(*state, ModelSlotState::Loaded(_)) { - *state = ModelSlotState::Empty; - evicted = true; - slot.notify.notify_waiters(); - } - } - Ok(evicted) -} - -const PROVIDER_NAME: &str = "local"; -const DEFAULT_MODEL: &str = "bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M"; - -pub const LOCAL_LLM_MODEL_CONFIG_KEY: &str = "LOCAL_LLM_MODEL"; - -#[derive(Clone)] -pub(crate) struct ResolvedModelPaths { - pub model_path: PathBuf, - pub context_limit: usize, - pub settings: crate::local_model_registry::ModelSettings, - pub mmproj_path: Option, - pub backend_id: Option, - pub draft_model_path: Option, -} - -fn resolve_model_local_path(model_id: &str) -> Option { - use crate::local_model_registry::get_registry; - - get_registry() - .lock() - .ok()? - .get_model(model_id) - .map(|entry| entry.local_path.clone()) -} - -/// Resolve model path, context limit, settings, and mmproj path for a model ID from the registry. -fn resolve_model_path(model_id: &str) -> Option { - use crate::local_model_registry::{default_settings_for_model, get_registry}; - - if let Ok(registry) = get_registry().lock() { - if let Some(entry) = registry.get_model(model_id) { - let ctx = entry.settings.context_size.unwrap_or(0) as usize; - let mut settings = entry.settings.clone(); - let defaults = default_settings_for_model(model_id); - settings.vision_capable = defaults.vision_capable; - settings.mmproj_size_bytes = entry.mmproj_size_bytes; - let mmproj_path = entry.mmproj_path.as_ref().filter(|p| p.exists()).cloned(); - let backend_id = entry - .backend_id - .clone() - .or_else(|| settings.backend_id.clone()); - let draft_model = settings - .draft_model - .clone() - .or_else(|| { - config_resolver::string_param("GOOSE_LOCAL_DRAFT_MODEL") - .ok() - .flatten() - }) - .filter(|draft_model| draft_model != model_id); - let draft_model_path = draft_model.as_deref().and_then(resolve_model_local_path); - return Some(ResolvedModelPaths { - model_path: entry.local_path.clone(), - context_limit: ctx, - settings, - mmproj_path, - backend_id, - draft_model_path, - }); - } - } - - None -} - -pub fn available_inference_memory_bytes(runtime: &InferenceRuntime) -> u64 { - runtime.default_backend().available_memory_bytes() -} - -pub fn recommend_local_model(runtime: &InferenceRuntime) -> String { - use local_model_registry::{get_registry, is_featured_model, FEATURED_MODELS}; - - let available_memory = available_inference_memory_bytes(runtime); - - if let Ok(registry) = get_registry().lock() { - let mut models: Vec<_> = registry - .list_models() - .iter() - .filter(|m| is_featured_model(&m.id) && m.size_bytes > 0) - .collect(); - models.sort_by_key(|model| std::cmp::Reverse(model.size_bytes)); - - // Return largest that fits in available memory - for model in &models { - if available_memory >= model.size_bytes { - return model.id.clone(); - } - } - - // If nothing fits, return smallest - if let Some(smallest) = models.last() { - return smallest.id.clone(); - } - } - - // Fallback to first featured model - FEATURED_MODELS[0].spec.to_string() -} - -fn build_openai_messages_json( - system: &str, - messages: &[Message], - media_marker: Option<&str>, -) -> String { - use goose_provider_types::formats::openai::format_messages; - - let mut arr: Vec = vec![json!({"role": "system", "content": system})]; - arr.extend(format_messages(messages, &ImageFormat::OpenAi)); - strip_image_parts_from_messages(&mut arr); - if let Some(marker) = media_marker { - convert_text_media_markers(&mut arr, marker); - } - serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string()) -} - -fn build_openai_text_messages_json( - system: &str, - messages: &[Message], - media_marker: Option<&str>, -) -> String { - let mut arr: Vec = vec![json!({"role": "system", "content": system})]; - arr.extend(messages.iter().filter_map(|m| { - let content = extract_text_content(m); - if content.trim().is_empty() { - return None; - } - let role = match m.role { - rmcp::model::Role::User => "user", - rmcp::model::Role::Assistant => "assistant", - }; - Some(json!({"role": role, "content": content})) - })); - if let Some(marker) = media_marker { - convert_text_media_markers(&mut arr, marker); - } - serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string()) -} - -fn convert_text_media_markers(messages: &mut [Value], marker: &str) { - if marker.is_empty() { - return; - } - - for msg in messages { - let Some(content) = msg.get_mut("content") else { - continue; - }; - - if let Some(text) = content.as_str() { - if let Some(parts) = split_media_marker_text(text, marker) { - *content = json!(parts); - } - continue; - } - - let Some(content_parts) = content.as_array_mut() else { - continue; - }; - let mut updated = Vec::new(); - let mut changed = false; - for part in content_parts.iter() { - if part.get("type").and_then(|v| v.as_str()) == Some("text") { - if let Some(text) = part.get("text").and_then(|v| v.as_str()) { - if let Some(parts) = split_media_marker_text(text, marker) { - updated.extend(parts); - changed = true; - continue; - } - } - } - updated.push(part.clone()); - } - if changed { - *content_parts = updated; - } - } -} - -fn split_media_marker_text(text: &str, marker: &str) -> Option> { - let mut parts = Vec::new(); - let mut rest = text; - let mut found_marker = false; - while let Some((before, after)) = rest.split_once(marker) { - found_marker = true; - let before = before.strip_suffix('\n').unwrap_or(before); - if !before.is_empty() { - parts.push(json!({"type": "text", "text": before})); - } - parts.push(json!({"type": "media_marker", "text": marker})); - rest = after; - rest = rest.strip_prefix('\n').unwrap_or(rest); - } - if !found_marker { - return None; - } - if !rest.is_empty() { - parts.push(json!({"type": "text", "text": rest})); - } - Some(parts) -} - -/// Remove `image_url` content parts from OpenAI-format messages JSON, replacing -/// each with a text note. This prevents an FFI crash in llama.cpp which does not -/// accept `image_url` content-part types. -fn strip_image_parts_from_messages(messages: &mut [Value]) { - let mut stripped = false; - for msg in messages.iter_mut() { - if let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut()) { - for part in content.iter_mut() { - if part.get("type").and_then(|t| t.as_str()) == Some("image_url") { - *part = json!({ - "type": "text", - "text": "[Image attached โ€” image input is not supported with the currently selected model]" - }); - stripped = true; - } - } - } - } - if stripped { - tracing::warn!("Stripped image content parts from messages โ€” vision encoder not available for this model"); - } -} - -/// Convert a message into plain text for the emulator path's chat history. -/// -/// This is the emulator-path counterpart of [`format_messages`] used by the native -/// path. It reconstructs the text-based tool syntax that the emulator prompt teaches -/// the model: -/// -/// - `ToolRequest` with a `"command"` argument โ†’ `$ command` -/// - `ToolRequest` with a `"code"` argument โ†’ `` ```execute_typescript\nโ€ฆ\n``` `` -/// - `ToolResponse` โ†’ `Command output:\nโ€ฆ` -/// -/// Only `developer__shell` and `code_execution__execute_typescript` style tool calls are -/// recognized (by argument shape, not tool name). Tool calls from other extensions -/// (e.g. custom MCP tools made by a native-tool-calling model earlier in the -/// conversation) are silently dropped, since the emulator path has no syntax to -/// represent them. -fn extract_text_content(msg: &Message) -> String { - let mut parts = Vec::new(); - - for content in &msg.content { - match content { - MessageContent::Text(text) => { - let text = strip_info_messages(&text.text); - if !text.trim().is_empty() { - parts.push(text); - } - } - MessageContent::ToolRequest(req) => { - if let Ok(call) = &req.tool_call { - if let Some(cmd) = call - .arguments - .as_ref() - .and_then(|a| a.get("command")) - .and_then(|v| v.as_str()) - { - parts.push(format!("$ {}", cmd)); - } else if let Some(code) = call - .arguments - .as_ref() - .and_then(|a| a.get("code")) - .and_then(|v| v.as_str()) - { - parts.push(format!("```execute_typescript\n{}\n```", code)); - } - } - } - MessageContent::ToolResponse(response) => match &response.tool_result { - Ok(result) => { - let mut output_parts = Vec::new(); - for content_item in &result.content { - if let Some(text_content) = content_item.as_text() { - output_parts.push(text_content.text.to_string()); - } - } - if !output_parts.is_empty() { - parts.push(format!("Command output:\n{}", output_parts.join("\n"))); - } - } - Err(e) => { - parts.push(format!("Command error: {}", e)); - } - }, - MessageContent::Image(_) => { - parts.push( - "[Image attached โ€” image input is not supported with the currently selected model]" - .to_string(), - ); - } - _ => {} - } - } - - parts.join("\n") -} - -fn strip_info_messages(text: &str) -> String { - let mut remaining = text; - let mut output = String::new(); - - while let Some((before, after_start)) = remaining.split_once("") { - output.push_str(before); - if let Some((_, after_end)) = after_start.split_once("") { - remaining = after_end; - } else { - remaining = ""; - break; - } - } - - output.push_str(remaining); - output.trim().to_string() -} - -/// Build a `ProviderUsage` and write the request log entry. -fn finalize_usage( - log: &mut Option>, - model_name: String, - path_label: &str, - prompt_token_count: usize, - output_token_count: i32, - extra_log_fields: Option<(&str, &str)>, -) -> ProviderUsage { - let input_tokens = prompt_token_count as i32; - let total_tokens = input_tokens + output_token_count; - let usage = Usage::new( - Some(input_tokens), - Some(output_token_count), - Some(total_tokens), - ); - let mut log_json = serde_json::json!({ - "path": path_label, - "prompt_tokens": input_tokens, - "output_tokens": output_token_count, - }); - if let Some((key, value)) = extra_log_fields { - log_json[key] = serde_json::json!(value); - } - let _ = log.write(&log_json, Some(&usage)); - ProviderUsage::new(model_name, usage) -} - -type StreamSender = - tokio::sync::mpsc::Sender, Option), ProviderError>>; - -pub struct LocalInferenceProvider { - runtime: Arc, - name: String, -} - -impl LocalInferenceProvider { - pub async fn from_env() -> Result { - let runtime = InferenceRuntime::get_or_init()?; - Ok(Self { - runtime, - name: PROVIDER_NAME.to_string(), - }) - } -} - -impl ProviderDescriptor for LocalInferenceProvider { - fn metadata() -> ProviderMetadata - where - Self: Sized, - { - use crate::local_model_registry::{get_registry, FEATURED_MODELS}; - - let mut known_models: Vec<&str> = FEATURED_MODELS.iter().map(|m| m.spec).collect(); - - // Add any registry models not already in the featured list - let mut dynamic_models = Vec::new(); - if let Ok(registry) = get_registry().lock() { - for entry in registry.list_models() { - if !known_models.contains(&entry.id.as_str()) { - dynamic_models.push(entry.id.clone()); - } - } - } - let dynamic_refs: Vec<&str> = dynamic_models.iter().map(|s| s.as_str()).collect(); - known_models.extend(dynamic_refs); - - ProviderMetadata::new( - PROVIDER_NAME, - "Local Inference", - "Local inference using quantized GGUF models (llama.cpp)", - DEFAULT_MODEL, - known_models, - "https://github.com/utilityai/llama-cpp-rs", - vec![], - ) - } -} - -#[async_trait] -impl Provider for LocalInferenceProvider { - fn get_name(&self) -> &str { - &self.name - } - - async fn fetch_supported_models(&self) -> Result, ProviderError> { - use crate::local_model_registry::get_registry; - - let mut all_models: Vec = Vec::new(); - - if let Ok(registry) = get_registry().lock() { - for entry in registry.list_models() { - all_models.push(entry.id.clone()); - } - } - - Ok(all_models) - } - - async fn stream( - &self, - model_config: &ModelConfig, - system: &str, - messages: &[Message], - tools: &[Tool], - ) -> Result { - let resolved = resolve_model_path(&model_config.model_name).ok_or_else(|| { - ProviderError::ExecutionError(format!("Model not found: {}", model_config.model_name)) - })?; - let backend = self.runtime.backend_for_model(&resolved)?; - let model_context_limit = resolved.context_limit; - - // Allow request_params to override thinking - let mut model_settings = resolved.settings.clone(); - if let Some(false) = model_config - .request_param::("enable_thinking") - .or_else(|| { - config_resolver::bool_param("GOOSE_LOCAL_ENABLE_THINKING") - .ok() - .flatten() - }) - { - model_settings.enable_thinking = false; - } - - let cache_key = ModelCacheKey::new( - backend.id(), - model_config.model_name.clone(), - model_settings.chat_template.clone(), - ); - let model_slot = self.runtime.get_or_create_model_slot(cache_key.clone()); - let runtime = self.runtime.clone(); - - let cache_key = cache_key.clone(); - let model_arc = model_slot.clone(); - let backend = backend.clone(); - let model_name = model_config.model_name.clone(); - let temperature = model_config.temperature; - let max_tokens = model_config.max_tokens; - let context_limit = model_context_limit; - let settings = model_settings; - let resolved_model = resolved.clone(); - let system = system.to_string(); - let messages = messages.to_vec(); - let tools = tools.to_vec(); - let log_payload = serde_json::json!({ - "system": &system, - "messages": messages.iter().map(|m| { - serde_json::json!({ - "role": match m.role { rmcp::model::Role::User => "user", rmcp::model::Role::Assistant => "assistant" }, - "content": extract_text_content(m), - }) - }).collect::>(), - "tools": tools.iter().map(|t| &t.name).collect::>(), - "settings": { - "tool_calling": settings.tool_calling, - "chat_template": settings.chat_template, - "context_size": settings.context_size, - "sampling": settings.sampling, - }, - }); - - let (tx, mut rx) = tokio::sync::mpsc::channel::< - Result<(Option, Option), ProviderError>, - >(32); - let mut log = start_log(model_config, &log_payload)?; - - tokio::spawn(async move { - let mut model_load_ms = None; - - // Ensure model is loaded โ€” unload any other models first to free memory. - loop { - let state = model_slot.state.lock().await; - match &*state { - ModelSlotState::Loaded(_) => break, - ModelSlotState::Loading => { - let notified = model_slot.notify.notified(); - drop(state); - notified.await; - } - ModelSlotState::Empty => { - drop(state); - - let cold_load_guard = runtime.cold_load_lock.lock().await; - let mut state = model_slot.state.lock().await; - match &*state { - ModelSlotState::Loaded(_) => break, - ModelSlotState::Loading => { - let notified = model_slot.notify.notified(); - drop(state); - drop(cold_load_guard); - notified.await; - continue; - } - ModelSlotState::Empty => {} - } - *state = ModelSlotState::Loading; - drop(state); - - let loading_message = Message::assistant().with_system_notification( - SystemNotificationType::ProgressMessage, - format!("Loading local model {model_name}..."), - ); - if tx.send(Ok((Some(loading_message), None))).await.is_err() { - let mut state = model_slot.state.lock().await; - *state = ModelSlotState::Empty; - model_slot.notify.notify_waiters(); - return; - } - - let other_model_slots = runtime.other_model_slots(&cache_key); - for slot in other_model_slots { - let mut other = slot.state.lock().await; - if matches!(*other, ModelSlotState::Loaded(_)) { - tracing::info!("Unloading previous model to free memory"); - *other = ModelSlotState::Empty; - } - } - - let model_id = model_name.clone(); - let resolved_for_load = resolved_model.clone(); - let settings_for_load = settings.clone(); - let backend_for_load = backend.clone(); - let load_started = std::time::Instant::now(); - let loaded = match tokio::task::spawn_blocking(move || { - backend_for_load.load_model( - &model_id, - &resolved_for_load, - &settings_for_load, - ) - }) - .await - { - Ok(Ok(loaded)) => loaded, - Ok(Err(err)) => { - let mut state = model_slot.state.lock().await; - *state = ModelSlotState::Empty; - model_slot.notify.notify_waiters(); - let _ = log.error(&err); - let _ = tx.send(Err(err)).await; - return; - } - Err(err) => { - let mut state = model_slot.state.lock().await; - *state = ModelSlotState::Empty; - model_slot.notify.notify_waiters(); - let err = ProviderError::ExecutionError(err.to_string()); - let _ = log.error(&err); - let _ = tx.send(Err(err)).await; - return; - } - }; - let elapsed_ms = - u64::try_from(load_started.elapsed().as_millis()).unwrap_or(u64::MAX); - model_load_ms = Some(elapsed_ms); - tracing::info!( - backend = backend.id(), - model = %model_name, - model_load_ms = elapsed_ms, - "Loaded local inference model" - ); - let _ = log.write( - &json!({ - "path": "model_load", - "backend": backend.id(), - "model": &model_name, - "model_load_ms": elapsed_ms, - }), - None, - ); - - let mut state = model_slot.state.lock().await; - *state = ModelSlotState::Loaded(loaded); - model_slot.notify.notify_waiters(); - drop(cold_load_guard); - break; - } - } - } - - tokio::task::spawn_blocking(move || { - // Macro to log errors before sending them through the channel - macro_rules! send_err { - ($err:expr) => {{ - let err = $err; - let msg = match &err { - ProviderError::ExecutionError(s) => s.as_str(), - ProviderError::ContextLengthExceeded(s) => s.as_str(), - _ => "unknown error", - }; - let _ = log.error(msg); - let _ = tx.blocking_send(Err(err)); - return; - }}; - } - - let mut model_guard = model_arc.state.blocking_lock(); - let loaded = match &mut *model_guard { - ModelSlotState::Loaded(loaded) => loaded.as_mut(), - ModelSlotState::Empty | ModelSlotState::Loading => { - send_err!(ProviderError::ExecutionError( - "Model not loaded".to_string() - )); - } - }; - - let message_id = Uuid::new_v4().to_string(); - - let request = backend::LocalGenerationRequest { - model_name, - system: &system, - messages: &messages, - tools: &tools, - settings: &settings, - temperature, - max_tokens, - context_limit, - model_load_ms, - resolved_model: &resolved_model, - draft_model_path: resolved_model.draft_model_path.clone(), - message_id: &message_id, - tx: &tx, - log: &mut log, - }; - - let result = backend.generate(loaded, request); - - if let Err(err) = result { - let msg = match &err { - ProviderError::ExecutionError(s) => s.as_str(), - ProviderError::ContextLengthExceeded(s) => s.as_str(), - _ => "unknown error", - }; - let _ = log.error(msg); - let _ = tx.blocking_send(Err(err)); - } - }); - }); - - Ok(Box::pin(try_stream! { - while let Some(result) = rx.recv().await { - let item = result?; - yield item; - } - - })) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn converts_marker_in_string_content_to_media_marker_part() { - let mut messages = vec![json!({ - "role": "user", - "content": "look\n<__media__>\nclosely", - })]; - - convert_text_media_markers(&mut messages, "<__media__>"); - - assert_eq!( - messages[0]["content"], - json!([ - {"type": "text", "text": "look"}, - {"type": "media_marker", "text": "<__media__>"}, - {"type": "text", "text": "closely"}, - ]) - ); - } - - #[test] - fn converts_marker_inside_text_content_parts() { - let mut messages = vec![json!({ - "role": "user", - "content": [ - {"type": "text", "text": "<__media__>describe"}, - {"type": "text", "text": "next"}, - {"type": "media_marker", "text": "<__media__>"}, - ], - })]; - - convert_text_media_markers(&mut messages, "<__media__>"); - - assert_eq!( - messages[0]["content"], - json!([ - {"type": "media_marker", "text": "<__media__>"}, - {"type": "text", "text": "describe"}, - {"type": "text", "text": "next"}, - {"type": "media_marker", "text": "<__media__>"}, - ]) - ); - } -} diff --git a/crates/goose-local-inference/src/management.rs b/crates/goose-local-inference/src/management.rs deleted file mode 100644 index 31d90d53ba..0000000000 --- a/crates/goose-local-inference/src/management.rs +++ /dev/null @@ -1,860 +0,0 @@ -use super::hf_models::{ - self, register_resolved_model, resolve_local_model_selection, resolve_local_model_spec, - resolve_model_spec, HfGgufFile, HfModelInfo, HfModelVariant, -}; -use super::local_model_registry::{ - default_settings_for_model, featured_mmproj_spec, get_registry, model_id_from_repo, - ChatTemplate, LocalModelEntry, LocalModelStorage, ModelDownloadStatus, ModelSettings, - SamplingConfig, ToolCallingMode, FEATURED_MODELS, -}; -use super::{ - available_inference_memory_bytes, builtin_chat_template_names, recommend_local_model, - InferenceRuntime, -}; -use crate::download_manager::{get_download_manager, DownloadProgress, DownloadStatus}; -use crate::huggingface_auth; -use crate::paths::Paths; -use anyhow::{anyhow, Result}; -use futures::future::join_all; -use goose_sdk_types::custom_requests::{ - LocalInferenceBuiltinChatTemplatesListResponse, LocalInferenceChatTemplate, - LocalInferenceDownloadProgressDto, LocalInferenceDownloadState, LocalInferenceHfGgufFileDto, - LocalInferenceHfModelInfoDto, LocalInferenceHfModelVariantDto, - LocalInferenceHuggingFaceRepoVariantsResponse, LocalInferenceHuggingFaceSearchResponse, - LocalInferenceModelDownloadRequest, LocalInferenceModelDownloadResponse, - LocalInferenceModelDownloadStatusDto, LocalInferenceModelDto, LocalInferenceModelSettingsDto, - LocalInferenceModelSettingsReadResponse, LocalInferenceModelSettingsUpdateResponse, - LocalInferenceModelsListResponse, LocalInferenceSamplingConfig, LocalInferenceToolCallingMode, -}; -use std::collections::HashSet; -use std::path::PathBuf; -use std::sync::{Arc, OnceLock}; - -static MANAGEMENT_RUNTIME: OnceLock> = OnceLock::new(); - -#[derive(Clone)] -struct LocalModelSelection { - repo_id: String, - backend_id: String, - variant_id: Option, -} - -pub async fn list_models() -> Result { - ensure_featured_models_current().await?; - - let runtime = management_runtime()?; - let recommended_id = recommend_local_model(&runtime); - - let loaded_model_ids = crate::loaded_model_ids() - .await - .map_err(|error| anyhow!(error.to_string()))?; - let registry = get_registry() - .lock() - .map_err(|_| anyhow!("Failed to acquire registry lock"))?; - let mut models: Vec = registry - .list_models() - .iter() - .map(|entry| local_model_to_dto(entry, &recommended_id, &loaded_model_ids)) - .collect(); - - models.sort_by(|a, b| { - let a_downloaded = a.status.state == LocalInferenceDownloadState::Downloaded; - let b_downloaded = b.status.state == LocalInferenceDownloadState::Downloaded; - match (b_downloaded, a_downloaded) { - (true, false) => std::cmp::Ordering::Greater, - (false, true) => std::cmp::Ordering::Less, - _ => a.id.cmp(&b.id), - } - }); - - Ok(LocalInferenceModelsListResponse { models }) -} - -pub async fn search_huggingface_models( - query: String, - limit: Option, -) -> Result { - let limit = limit.unwrap_or(20).min(50); - let models = hf_models::search_local_models(&query, limit) - .await? - .into_iter() - .map(hf_model_info_to_dto) - .collect(); - Ok(LocalInferenceHuggingFaceSearchResponse { models }) -} - -pub async fn huggingface_repo_variants( - repo_id: String, -) -> Result { - let variants = hf_models::get_repo_local_variants(&repo_id).await?; - - let runtime = management_runtime()?; - let available_memory = available_inference_memory_bytes(&runtime); - let gguf_variants: Vec<_> = variants - .iter() - .filter(|variant| variant.backend_id == "llamacpp") - .map(|variant| hf_models::HfQuantVariant { - quantization: variant.variant_id.clone(), - size_bytes: variant.size_bytes, - filename: variant.filename.clone().unwrap_or_default(), - download_url: variant.download_url.clone().unwrap_or_default(), - description: "", - quality_rank: variant.quality_rank, - sharded: variant.sharded, - }) - .collect(); - let recommended_index = hf_models::recommend_variant(&gguf_variants, available_memory); - - let (downloaded_quants, downloaded_variants) = { - let registry = get_registry() - .lock() - .map_err(|_| anyhow!("Failed to acquire registry lock"))?; - let models: Vec<_> = registry - .list_models() - .iter() - .filter(|m| m.repo_id == repo_id && m.is_downloaded()) - .collect(); - ( - models.iter().map(|m| m.quantization.clone()).collect(), - models.iter().map(|m| m.id.clone()).collect(), - ) - }; - - Ok(LocalInferenceHuggingFaceRepoVariantsResponse { - variants: variants.into_iter().map(hf_model_variant_to_dto).collect(), - recommended_index, - available_memory_bytes: available_memory, - downloaded_quants, - downloaded_variants, - }) -} - -pub async fn download_model( - req: LocalInferenceModelDownloadRequest, -) -> Result { - let selection = explicit_model_selection(&req)?; - let model_id = local_model_id_from_request(&req, selection.as_ref()).await?; - let download_id = format!("{}-model", model_id); - let download_reserved = get_download_manager().reserve_download(DownloadProgress { - model_id: download_id, - status: DownloadStatus::Downloading, - bytes_downloaded: 0, - total_bytes: 0, - progress_percent: 0.0, - speed_bps: None, - eta_seconds: None, - error: None, - task_exited: false, - })?; - if !download_reserved { - return Ok(LocalInferenceModelDownloadResponse { model_id }); - } - - if let Err(error) = register_pending_download_model(&model_id, &req, selection.as_ref()) { - mark_download_failed(&model_id, &error); - return Err(error.context("Failed to register download")); - } - - let spec = req.spec.clone(); - let selection_for_task = selection.clone(); - let model_id_for_task = model_id.clone(); - tokio::spawn(async move { - let resolved = if let Some(selection) = selection_for_task { - resolve_local_model_selection( - &selection.repo_id, - &selection.backend_id, - selection.variant_id.as_deref(), - ) - .await - } else { - resolve_local_model_spec(&spec).await - }; - match resolved { - Ok(resolved) => { - if !model_download_completed(&model_id_for_task) { - return; - } - if let Err(error) = register_resolved_model(resolved, &spec) { - mark_download_failed(&model_id_for_task, error); - } - } - Err(error) => mark_download_failed(&model_id_for_task, error), - } - }); - - Ok(LocalInferenceModelDownloadResponse { model_id }) -} - -pub fn download_progress(model_id: &str) -> Result> { - Ok(get_download_manager() - .get_progress(&format!("{}-model", model_id)) - .map(download_progress_to_dto)) -} - -pub fn cancel_download(model_id: &str) -> Result<()> { - let manager = get_download_manager(); - manager.cancel_download(&format!("{}-model", model_id))?; - let _ = manager.cancel_download(&format!("{}-mmproj", model_id)); - Ok(()) -} - -pub fn delete_model(model_id: &str) -> Result<()> { - let mut registry = get_registry() - .lock() - .map_err(|_| anyhow!("Failed to acquire registry lock"))?; - if registry.get_model(model_id).is_none() { - anyhow::bail!("Model not found"); - } - registry.delete_model(model_id) -} - -pub fn model_exists(model_id: &str) -> Result { - let registry = get_registry() - .lock() - .map_err(|_| anyhow!("Failed to acquire registry lock"))?; - Ok(registry.get_model(model_id).is_some()) -} - -pub async fn evict_model(model_id: &str) -> Result<()> { - crate::evict_model(model_id) - .await - .map(|_| ()) - .map_err(|error| anyhow!(error.to_string())) -} - -pub fn get_model_settings(model_id: &str) -> Result { - let registry = get_registry() - .lock() - .map_err(|_| anyhow!("Failed to acquire registry lock"))?; - let settings = registry - .get_model_settings(model_id) - .ok_or_else(|| anyhow!("Model not found"))?; - Ok(LocalInferenceModelSettingsReadResponse { - settings: model_settings_to_dto(settings), - }) -} - -pub fn update_model_settings( - model_id: &str, - settings: LocalInferenceModelSettingsDto, -) -> Result { - let settings = model_settings_from_dto(settings); - let mut registry = get_registry() - .lock() - .map_err(|_| anyhow!("Failed to acquire registry lock"))?; - registry.update_model_settings(model_id, settings.clone())?; - Ok(LocalInferenceModelSettingsUpdateResponse { - settings: model_settings_to_dto(&settings), - }) -} - -pub fn list_builtin_chat_templates() -> LocalInferenceBuiltinChatTemplatesListResponse { - LocalInferenceBuiltinChatTemplatesListResponse { - templates: builtin_chat_template_names(), - } -} - -fn management_runtime() -> Result> { - if let Some(runtime) = MANAGEMENT_RUNTIME.get() { - return Ok(runtime.clone()); - } - - let runtime = InferenceRuntime::get_or_init()?; - match MANAGEMENT_RUNTIME.set(runtime.clone()) { - Ok(()) => Ok(runtime), - Err(_) => Ok(MANAGEMENT_RUNTIME - .get() - .expect("local inference management runtime initialized by another thread") - .clone()), - } -} - -pub async fn ensure_featured_models_current() -> Result<()> { - let mut mmproj_downloads_needed: Vec<(String, String, PathBuf)> = Vec::new(); - - struct PendingResolve { - spec: &'static str, - repo_id: String, - quantization: String, - model_id: String, - } - let mut to_resolve = Vec::new(); - - for featured in FEATURED_MODELS { - let (repo_id, quantization) = match hf_models::parse_model_spec(featured.spec) { - Ok(parts) => parts, - Err(_) => continue, - }; - - let model_id = model_id_from_repo(&repo_id, &quantization); - - { - let registry = get_registry() - .lock() - .map_err(|_| anyhow!("Failed to acquire registry lock"))?; - if let Some(existing) = registry.get_model(&model_id) { - let needs_backfill = existing.mmproj_path.is_none() && featured.mmproj.is_some(); - let needs_download = existing.is_downloaded() - && featured.mmproj.is_some() - && !existing.mmproj_path.as_ref().is_some_and(|p| p.exists()); - - if needs_download { - if let Some(mmproj) = featured.mmproj.as_ref() { - let path = mmproj.local_path(); - let url = format!( - "https://huggingface.co/{}/resolve/main/{}", - mmproj.repo, mmproj.filename - ); - mmproj_downloads_needed.push((model_id.clone(), url, path)); - } - } - - if !needs_backfill { - continue; - } - } - } - - to_resolve.push(PendingResolve { - spec: featured.spec, - repo_id, - quantization, - model_id, - }); - } - - let resolved: Vec<(PendingResolve, HfGgufFile)> = - join_all(to_resolve.into_iter().map(|pending| async move { - let hf_file = match resolve_model_spec(pending.spec).await { - Ok((_repo, file)) => file, - Err(_) => { - let filename = format!( - "{}-{}.gguf", - pending.repo_id.split('/').next_back().unwrap_or("model"), - pending.quantization - ); - HfGgufFile { - filename: filename.clone(), - size_bytes: 0, - quantization: pending.quantization.to_string(), - download_url: format!( - "https://huggingface.co/{}/resolve/main/{}", - pending.repo_id, filename - ), - } - } - }; - (pending, hf_file) - })) - .await; - - let entries_to_add: Vec = resolved - .into_iter() - .map(|(pending, hf_file)| { - let local_path = Paths::in_data_dir("models").join(&hf_file.filename); - let settings = default_settings_for_model(&pending.model_id); - LocalModelEntry { - id: pending.model_id, - repo_id: pending.repo_id, - filename: hf_file.filename, - quantization: pending.quantization, - local_path, - source_url: hf_file.download_url, - backend_id: settings.backend_id.clone(), - storage: LocalModelStorage::GooseManaged, - settings, - size_bytes: hf_file.size_bytes, - mmproj_path: None, - mmproj_source_url: None, - mmproj_size_bytes: 0, - mmproj_checked: false, - shard_files: vec![], - } - }) - .collect(); - - { - let mut registry = get_registry() - .lock() - .map_err(|_| anyhow!("Failed to acquire registry lock"))?; - - if !entries_to_add.is_empty() { - registry.sync_with_featured(entries_to_add); - } - - for model in registry.list_models_mut() { - model.enrich_with_featured_mmproj(); - if model.is_downloaded() { - if let Some(mmproj) = featured_mmproj_spec(&model.id) { - let path = mmproj.local_path(); - if !path.exists() { - let url = format!( - "https://huggingface.co/{}/resolve/main/{}", - mmproj.repo, mmproj.filename - ); - mmproj_downloads_needed.push((model.id.clone(), url, path)); - } - } - } - } - let _ = registry.save(); - } - - let dm = get_download_manager(); - let hf_token = huggingface_auth::resolve_token_async().await.ok().flatten(); - let mut started_paths = std::collections::HashSet::new(); - for (model_id, url, path) in mmproj_downloads_needed { - if !path.exists() && started_paths.insert(path.clone()) { - let download_id = format!("{}-mmproj", model_id); - let dominated_by_active = dm - .get_progress(&download_id) - .is_some_and(|p| p.status == DownloadStatus::Downloading); - if !dominated_by_active { - tracing::info!(model_id = %model_id, "Auto-downloading vision encoder for existing model"); - if let Err(e) = dm - .download_model_with_bearer_token( - download_id, - url, - path, - hf_token.clone(), - None, - ) - .await - { - tracing::warn!(model_id = %model_id, error = %e, "Failed to start mmproj download"); - } - } - } - } - - Ok(()) -} - -fn local_model_to_dto( - entry: &LocalModelEntry, - recommended_id: &str, - loaded_model_ids: &HashSet, -) -> LocalInferenceModelDto { - let vision_capable = entry.settings.vision_capable; - LocalInferenceModelDto { - id: entry.id.clone(), - repo_id: entry.repo_id.clone(), - filename: entry.filename.clone(), - quantization: entry.quantization.clone(), - size_bytes: entry.file_size(), - status: model_download_status_to_dto(entry.download_status()), - recommended: recommended_id == entry.id, - is_loaded: loaded_model_ids.contains(&entry.id), - settings: model_settings_to_dto(&entry.settings), - vision_capable, - mmproj_status: vision_capable - .then(|| model_download_status_to_dto(entry.mmproj_download_status())), - } -} - -fn model_download_status_to_dto( - status: ModelDownloadStatus, -) -> LocalInferenceModelDownloadStatusDto { - match status { - ModelDownloadStatus::NotDownloaded => LocalInferenceModelDownloadStatusDto { - state: LocalInferenceDownloadState::NotDownloaded, - ..Default::default() - }, - ModelDownloadStatus::Downloading { - progress_percent, - bytes_downloaded, - total_bytes, - speed_bps, - } => LocalInferenceModelDownloadStatusDto { - state: LocalInferenceDownloadState::Downloading, - progress_percent: Some(progress_percent), - bytes_downloaded: Some(bytes_downloaded), - total_bytes: Some(total_bytes), - speed_bps: Some(speed_bps), - }, - ModelDownloadStatus::Downloaded => LocalInferenceModelDownloadStatusDto { - state: LocalInferenceDownloadState::Downloaded, - ..Default::default() - }, - } -} - -fn download_progress_to_dto(progress: DownloadProgress) -> LocalInferenceDownloadProgressDto { - LocalInferenceDownloadProgressDto { - model_id: progress.model_id, - status: serde_json::to_value(progress.status) - .ok() - .and_then(|value| value.as_str().map(ToOwned::to_owned)) - .unwrap_or_else(|| "unknown".to_string()), - bytes_downloaded: progress.bytes_downloaded, - total_bytes: progress.total_bytes, - progress_percent: progress.progress_percent, - speed_bps: progress.speed_bps, - eta_seconds: progress.eta_seconds, - error: progress.error, - task_exited: progress.task_exited, - } -} - -fn hf_model_info_to_dto(model: HfModelInfo) -> LocalInferenceHfModelInfoDto { - LocalInferenceHfModelInfoDto { - repo_id: model.repo_id, - author: model.author, - model_name: model.model_name, - downloads: model.downloads, - gguf_files: model - .gguf_files - .into_iter() - .map(|file| LocalInferenceHfGgufFileDto { - filename: file.filename, - size_bytes: file.size_bytes, - quantization: file.quantization, - download_url: file.download_url, - }) - .collect(), - variants: model - .variants - .into_iter() - .map(hf_model_variant_to_dto) - .collect(), - } -} - -fn hf_model_variant_to_dto(variant: HfModelVariant) -> LocalInferenceHfModelVariantDto { - LocalInferenceHfModelVariantDto { - variant_id: variant.variant_id, - label: variant.label, - backend_id: variant.backend_id, - format: variant.format, - model_id: variant.model_id, - download_id: variant.download_id, - size_bytes: variant.size_bytes, - filename: variant.filename, - download_url: variant.download_url, - description: variant.description, - quality_rank: variant.quality_rank, - sharded: variant.sharded, - supported: variant.supported, - unsupported_reason: variant.unsupported_reason, - } -} - -pub fn model_settings_to_dto(settings: &ModelSettings) -> LocalInferenceModelSettingsDto { - LocalInferenceModelSettingsDto { - backend_id: settings.backend_id.clone(), - context_size: settings.context_size, - max_output_tokens: settings.max_output_tokens, - draft_model: settings.draft_model.clone(), - sampling: sampling_to_dto(&settings.sampling), - repeat_penalty: settings.repeat_penalty, - repeat_last_n: settings.repeat_last_n, - frequency_penalty: settings.frequency_penalty, - presence_penalty: settings.presence_penalty, - n_batch: settings.n_batch, - n_gpu_layers: settings.n_gpu_layers, - use_mlock: settings.use_mlock, - flash_attention: settings.flash_attention, - n_threads: settings.n_threads, - tool_calling: tool_calling_to_dto(settings.tool_calling), - chat_template: chat_template_to_dto(&settings.chat_template), - enable_thinking: settings.enable_thinking, - vision_capable: settings.vision_capable, - image_token_estimate: settings.image_token_estimate, - mmproj_size_bytes: settings.mmproj_size_bytes, - } -} - -pub fn model_settings_from_dto(settings: LocalInferenceModelSettingsDto) -> ModelSettings { - ModelSettings { - backend_id: settings.backend_id, - context_size: settings.context_size, - max_output_tokens: settings.max_output_tokens, - draft_model: settings.draft_model, - sampling: sampling_from_dto(settings.sampling), - repeat_penalty: settings.repeat_penalty, - repeat_last_n: settings.repeat_last_n, - frequency_penalty: settings.frequency_penalty, - presence_penalty: settings.presence_penalty, - n_batch: settings.n_batch, - n_gpu_layers: settings.n_gpu_layers, - use_mlock: settings.use_mlock, - flash_attention: settings.flash_attention, - n_threads: settings.n_threads, - tool_calling: tool_calling_from_dto(settings.tool_calling), - chat_template: chat_template_from_dto(settings.chat_template), - enable_thinking: settings.enable_thinking, - vision_capable: settings.vision_capable, - image_token_estimate: settings.image_token_estimate, - mmproj_size_bytes: settings.mmproj_size_bytes, - } -} - -fn sampling_to_dto(sampling: &SamplingConfig) -> LocalInferenceSamplingConfig { - match sampling { - SamplingConfig::Greedy => LocalInferenceSamplingConfig::Greedy, - SamplingConfig::Temperature { - temperature, - top_k, - top_p, - min_p, - seed, - } => LocalInferenceSamplingConfig::Temperature { - temperature: *temperature, - top_k: *top_k, - top_p: *top_p, - min_p: *min_p, - seed: *seed, - }, - SamplingConfig::MirostatV2 { tau, eta, seed } => LocalInferenceSamplingConfig::MirostatV2 { - tau: *tau, - eta: *eta, - seed: *seed, - }, - } -} - -fn sampling_from_dto(sampling: LocalInferenceSamplingConfig) -> SamplingConfig { - match sampling { - LocalInferenceSamplingConfig::Greedy => SamplingConfig::Greedy, - LocalInferenceSamplingConfig::Temperature { - temperature, - top_k, - top_p, - min_p, - seed, - } => SamplingConfig::Temperature { - temperature, - top_k, - top_p, - min_p, - seed, - }, - LocalInferenceSamplingConfig::MirostatV2 { tau, eta, seed } => { - SamplingConfig::MirostatV2 { tau, eta, seed } - } - } -} - -fn tool_calling_to_dto(mode: ToolCallingMode) -> LocalInferenceToolCallingMode { - match mode { - ToolCallingMode::Auto => LocalInferenceToolCallingMode::Auto, - ToolCallingMode::ForceNative => LocalInferenceToolCallingMode::ForceNative, - ToolCallingMode::ForceEmulated => LocalInferenceToolCallingMode::ForceEmulated, - } -} - -fn tool_calling_from_dto(mode: LocalInferenceToolCallingMode) -> ToolCallingMode { - match mode { - LocalInferenceToolCallingMode::Auto => ToolCallingMode::Auto, - LocalInferenceToolCallingMode::ForceNative => ToolCallingMode::ForceNative, - LocalInferenceToolCallingMode::ForceEmulated => ToolCallingMode::ForceEmulated, - } -} - -fn chat_template_to_dto(template: &ChatTemplate) -> LocalInferenceChatTemplate { - match template { - ChatTemplate::Embedded => LocalInferenceChatTemplate::Embedded, - ChatTemplate::Builtin { name } => { - LocalInferenceChatTemplate::Builtin { name: name.clone() } - } - ChatTemplate::CustomInline { template } => LocalInferenceChatTemplate::CustomInline { - template: template.clone(), - }, - } -} - -fn chat_template_from_dto(template: LocalInferenceChatTemplate) -> ChatTemplate { - match template { - LocalInferenceChatTemplate::Embedded => ChatTemplate::Embedded, - LocalInferenceChatTemplate::Builtin { name } => ChatTemplate::Builtin { name }, - LocalInferenceChatTemplate::CustomInline { template } => { - ChatTemplate::CustomInline { template } - } - } -} - -fn explicit_model_selection( - req: &LocalInferenceModelDownloadRequest, -) -> Result> { - if let Some(backend_id) = req.backend_id.as_deref() { - let (repo_id, parsed_variant_id) = hf_models::parse_model_spec(&req.spec) - .map(|(repo_id, quantization)| (repo_id, Some(quantization))) - .unwrap_or_else(|_| (req.spec.clone(), None)); - let variant_id = req.variant_id.clone().or(parsed_variant_id); - match backend_id { - "mlx" | "llamacpp" => Ok(Some(LocalModelSelection { - repo_id, - backend_id: backend_id.to_string(), - variant_id, - })), - _ => anyhow::bail!("Unknown local inference backend '{}'", backend_id), - } - } else { - Ok(None) - } -} - -async fn local_model_id_from_request( - req: &LocalInferenceModelDownloadRequest, - selection: Option<&LocalModelSelection>, -) -> Result { - if let Some(selection) = selection { - return match selection.backend_id.as_str() { - "mlx" => Ok(selection.repo_id.clone()), - "llamacpp" => { - let quantization = selection.variant_id.as_deref().ok_or_else(|| { - anyhow!( - "llama.cpp model '{}' is missing a quantization", - selection.repo_id - ) - })?; - Ok(model_id_from_repo(&selection.repo_id, quantization)) - } - _ => anyhow::bail!("Unknown local inference backend '{}'", selection.backend_id), - }; - } - - if let Ok((repo_id, quantization)) = hf_models::parse_model_spec(&req.spec) { - return Ok(model_id_from_repo(&repo_id, &quantization)); - } - - let variants = hf_models::get_repo_local_variants(&req.spec).await?; - let has_llamacpp = variants - .iter() - .any(|variant| variant.backend_id == "llamacpp"); - let mlx_variants: Vec<_> = variants - .iter() - .filter(|variant| variant.backend_id == "mlx") - .collect(); - if mlx_variants.len() == 1 && !has_llamacpp { - Ok(req.spec.clone()) - } else { - anyhow::bail!( - "Model spec '{}' is ambiguous; choose one of: {}", - req.spec, - variants - .iter() - .map(|variant| variant.download_id.as_str()) - .collect::>() - .join(", ") - ) - } -} - -fn mark_download_failed(model_id: &str, error: impl std::fmt::Display) { - let manager = get_download_manager(); - let download_id = format!("{}-model", model_id); - if manager.get_progress(&download_id).is_none() { - manager.set_progress(DownloadProgress { - model_id: download_id.clone(), - status: DownloadStatus::Failed, - bytes_downloaded: 0, - total_bytes: 0, - progress_percent: 0.0, - speed_bps: None, - eta_seconds: None, - error: Some(error.to_string()), - task_exited: true, - }); - return; - } - - manager.update_progress(&download_id, |progress| { - if progress.status != DownloadStatus::Cancelled { - progress.status = DownloadStatus::Failed; - progress.error = Some(error.to_string()); - } - progress.task_exited = true; - }); -} - -fn model_download_completed(model_id: &str) -> bool { - get_download_manager() - .get_progress(&format!("{}-model", model_id)) - .is_some_and(|progress| progress.status == DownloadStatus::Completed) -} - -fn register_pending_download_model( - model_id: &str, - req: &LocalInferenceModelDownloadRequest, - selection: Option<&LocalModelSelection>, -) -> Result<()> { - let (repo_id, backend_id, variant_id) = if let Some(selection) = selection { - ( - selection.repo_id.clone(), - selection.backend_id.clone(), - selection - .variant_id - .clone() - .unwrap_or_else(|| "default".to_string()), - ) - } else if let Ok((repo_id, quantization)) = hf_models::parse_model_spec(&req.spec) { - (repo_id, "llamacpp".to_string(), quantization) - } else { - (req.spec.clone(), "mlx".to_string(), "default".to_string()) - }; - - let mut registry = get_registry() - .lock() - .map_err(|_| anyhow!("Failed to acquire registry lock"))?; - if registry.has_model(model_id) { - return Ok(()); - } - - let mut settings = default_settings_for_model(model_id); - if backend_id != "llamacpp" { - settings.backend_id = Some(backend_id.clone()); - } - - let filename = variant_id.clone(); - registry.add_model(LocalModelEntry { - id: model_id.to_string(), - repo_id, - filename: filename.clone(), - quantization: variant_id, - local_path: Paths::in_data_dir("models").join(filename), - source_url: req.spec.clone(), - backend_id: settings.backend_id.clone(), - storage: LocalModelStorage::HuggingFaceCache, - settings, - size_bytes: 0, - mmproj_path: None, - mmproj_source_url: None, - mmproj_size_bytes: 0, - mmproj_checked: false, - shard_files: vec![], - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn settings_round_trip_preserves_defaults() { - let settings = ModelSettings::default(); - let dto = model_settings_to_dto(&settings); - let round_trip = model_settings_from_dto(dto); - assert_eq!(round_trip.repeat_penalty, settings.repeat_penalty); - assert_eq!(round_trip.repeat_last_n, settings.repeat_last_n); - assert_eq!(round_trip.enable_thinking, settings.enable_thinking); - assert_eq!( - round_trip.image_token_estimate, - settings.image_token_estimate - ); - } - - #[tokio::test] - async fn explicit_llamacpp_selection_derives_quantized_model_id() { - let req = LocalInferenceModelDownloadRequest { - spec: "test/repo".to_string(), - backend_id: Some("llamacpp".to_string()), - variant_id: Some("Q4_K_M".to_string()), - }; - let selection = explicit_model_selection(&req).unwrap(); - let model_id = local_model_id_from_request(&req, selection.as_ref()) - .await - .unwrap(); - assert_eq!(model_id, "test/repo:Q4_K_M"); - } -} diff --git a/crates/goose-local-inference/src/mlx.rs b/crates/goose-local-inference/src/mlx.rs deleted file mode 100644 index 6b6ddb0fd4..0000000000 --- a/crates/goose-local-inference/src/mlx.rs +++ /dev/null @@ -1,1044 +0,0 @@ -#[cfg(feature = "mlx")] -mod imp { - use std::any::Any; - use std::path::{Path, PathBuf}; - - use safemlx::transforms::eval; - use safemlx::{random, Array, Device, DeviceType, Stream}; - use safemlx_lm::gemma4_mtp::generate_gemma4_mtp; - use safemlx_lm::models::{gemma4_assistant::load_gemma4_assistant_model, LoadedModel, Model}; - use safemlx_lm_utils::tokenizer::{Chat, Conversation, Role, Tokenizer}; - use serde_json::json; - - use crate::backend::{BackendLoadedModel, LocalGenerationRequest, LocalInferenceBackend}; - use crate::local_model_registry::{ModelSettings, ToolCallingMode}; - use crate::native_tool_parsing::message_from_native_tool_text; - use crate::provider_utils::filter_extensions_from_system_prompt; - use crate::thinking_output::ThinkingOutputFilter; - use crate::tool_emulation::{ - build_emulator_tool_description, load_tiny_model_prompt, message_for_emulator_action, - StreamingEmulatorParser, CODE_EXECUTION_TOOL, - }; - use crate::{extract_text_content, ResolvedModelPaths}; - use goose_provider_types::conversation::message::{Message, MessageContent}; - use goose_provider_types::conversation::token_usage::{ - DraftStats, ProviderStats, ProviderUsage, Usage, - }; - use goose_provider_types::errors::ProviderError; - use goose_provider_types::formats::openai; - use goose_provider_types::images::ImageFormat; - use goose_provider_types::request_log::LoggerHandleExt; - - pub(crate) const MLX_BACKEND_ID: &str = "mlx"; - - pub(crate) struct MlxBackend; - - impl MlxBackend { - pub(crate) fn new() -> Self { - Self - } - } - - impl LocalInferenceBackend for MlxBackend { - fn id(&self) -> &'static str { - MLX_BACKEND_ID - } - - fn load_model( - &self, - model_id: &str, - resolved: &ResolvedModelPaths, - _settings: &ModelSettings, - ) -> Result, ProviderError> { - if !resolved.model_path.exists() { - return Err(ProviderError::ExecutionError(format!( - "Model not downloaded: {}. Please download it from Settings > Local Inference.", - model_id - ))); - } - - let model_dir = model_dir_from_path(&resolved.model_path)?; - let stream = Stream::new_with_device(&Device::new(DeviceType::Gpu, 0)); - let weights_stream = Stream::new_with_device(&Device::new(DeviceType::Cpu, 0)); - let model = - LoadedModel::load(&model_dir, &stream, &weights_stream).map_err(mlx_error)?; - let tokenizer = - Tokenizer::from_file(model_dir.join("tokenizer.json")).map_err(mlx_error)?; - tracing::info!( - backend = self.id(), - model_id, - model_type = model.model_type(), - "MLX model loaded successfully" - ); - let stop_token_ids = mlx_stop_token_ids(&model, &model_dir); - Ok(Box::new(MlxLoadedModel { - model, - tokenizer, - model_dir, - stop_token_ids, - })) - } - - fn generate( - &self, - loaded: &mut dyn BackendLoadedModel, - request: LocalGenerationRequest<'_>, - ) -> Result<(), ProviderError> { - let loaded = loaded - .as_any_mut() - .downcast_mut::() - .ok_or_else(|| { - ProviderError::ExecutionError("Loaded model backend mismatch".to_string()) - })?; - - let stream = Stream::new_with_device(&Device::new(DeviceType::Gpu, 0)); - let tool_mode = if request.tools.is_empty() { - ToolMode::None - } else { - match request.settings.tool_calling { - ToolCallingMode::ForceNative => ToolMode::Native, - ToolCallingMode::Auto | ToolCallingMode::ForceEmulated => ToolMode::Emulated { - code_mode_enabled: request - .tools - .iter() - .any(|t| t.name == CODE_EXECUTION_TOOL), - }, - } - }; - let prompt = build_prompt( - &mut loaded.model, - &request.model_name, - request.system, - request.messages, - request.tools, - tool_mode, - )?; - let prompt_tokens = loaded.model.encode(&prompt, false).map_err(mlx_error)?; - if prompt_tokens.len() >= request.context_limit && request.context_limit > 0 { - return Err(ProviderError::ContextLengthExceeded(format!( - "Prompt ({} tokens) exceeds context limit ({} tokens). Try reducing conversation length.", - prompt_tokens.len(), request.context_limit - ))); - } - - let prompt_array = loaded - .model - .encode_to_array(&prompt, false, &stream) - .map_err(mlx_error)?; - let max_tokens = mlx_max_tokens( - request.settings, - request.max_tokens, - request.context_limit, - prompt_tokens.len(), - ); - let (settings_temp, seed) = sampling(request.settings); - let temp = request.temperature.unwrap_or(settings_temp); - let prng_key = prng_key(temp, seed)?; - let eos_token_ids = loaded.stop_token_ids.clone(); - let generation_started = std::time::Instant::now(); - let MlxGeneration { - generated_ids, - generated_text, - draft_stats, - time_to_first_token_ms, - streamed_response, - } = if let Some(draft_model_path) = &request.draft_model_path { - if matches!(loaded.model.model_mut(), Model::Gemma4(_)) { - let weights_stream = Stream::new_with_device(&Device::new(DeviceType::Cpu, 0)); - let mut assistant = - load_gemma4_assistant_model(draft_model_path, &stream, &weights_stream) - .map_err(|error| { - mlx_error(format!("failed to load MLX draft model: {error}")) - })?; - let target = match loaded.model.model_mut() { - Model::Gemma4(target) => target, - _ => unreachable!(), - }; - let (ids, stats) = generate_gemma4_mtp( - target, - &mut assistant, - &prompt_array, - &eos_token_ids, - max_tokens, - temp, - prng_key, - &stream, - ) - .map_err(mlx_error)?; - let generated_text = loaded.tokenizer.decode(&ids, true).map_err(mlx_error)?; - MlxGeneration { - generated_ids: ids, - generated_text, - draft_stats: Some(DraftStats { - model: Some(draft_model_path.display().to_string()), - draft_tokens: stats.draft_tokens, - accepted_tokens: stats.accepted_tokens, - target_tokens: stats.target_tokens, - rounds: stats.rounds, - accept_rate: stats.accept_rate(), - }), - time_to_first_token_ms: None, - streamed_response: false, - } - } else { - generate_single_model( - &mut loaded.model, - &loaded.tokenizer, - &prompt_array, - &eos_token_ids, - max_tokens, - temp, - prng_key, - &stream, - generation_started, - MlxStreamEmitter::new( - request.message_id, - tool_mode, - request.settings.enable_thinking, - &prompt, - request.tx, - ), - )? - } - } else { - generate_single_model( - &mut loaded.model, - &loaded.tokenizer, - &prompt_array, - &eos_token_ids, - max_tokens, - temp, - prng_key, - &stream, - generation_started, - MlxStreamEmitter::new( - request.message_id, - tool_mode, - request.settings.enable_thinking, - &prompt, - request.tx, - ), - )? - }; - - if !streamed_response { - emit_generated_response( - &generated_text, - &prompt, - request.settings.enable_thinking, - request.message_id, - tool_mode, - request.tx, - )?; - } - - let output_tokens = generated_ids.len() as i32; - let input_tokens = prompt_tokens.len() as i32; - let usage = Usage::new( - Some(input_tokens), - Some(output_tokens), - Some(input_tokens + output_tokens), - ); - let log_json = serde_json::json!({ - "path": "mlx", - "model_dir": loaded.model_dir, - "prompt_tokens": input_tokens, - "output_tokens": output_tokens, - "model_load_ms": request.model_load_ms, - "time_to_first_token_ms": time_to_first_token_ms, - "elapsed_ms": generation_started.elapsed().as_millis() as u64, - "generated_text": generated_text, - "draft": draft_stats, - }); - let _ = request.log.write(&log_json, Some(&usage)); - let stats = ProviderStats { - time_to_first_token_ms, - model_load_ms: request.model_load_ms, - elapsed_ms: Some(generation_started.elapsed().as_millis() as u64), - output_tokens: Some(generated_ids.len()), - draft: draft_stats, - }; - let provider_usage = ProviderUsage::new(request.model_name, usage).with_stats(stats); - let _ = request.tx.blocking_send(Ok((None, Some(provider_usage)))); - Ok(()) - } - - fn available_memory_bytes(&self) -> u64 { - 0 - } - } - - #[derive(Clone, Copy)] - enum ToolMode { - None, - Native, - Emulated { code_mode_enabled: bool }, - } - - struct MlxGeneration { - generated_ids: Vec, - generated_text: String, - draft_stats: Option, - time_to_first_token_ms: Option, - streamed_response: bool, - } - - struct MlxLoadedModel { - model: LoadedModel, - tokenizer: Tokenizer, - model_dir: PathBuf, - stop_token_ids: Vec, - } - - impl BackendLoadedModel for MlxLoadedModel { - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - } - - fn model_dir_from_path(path: &Path) -> Result { - if path.is_dir() { - Ok(path.to_path_buf()) - } else { - path.parent() - .map(Path::to_path_buf) - .ok_or_else(|| mlx_error("MLX model path has no parent directory")) - } - } - - fn mlx_stop_token_ids(model: &LoadedModel, model_dir: &Path) -> Vec { - let mut ids = model.eos_token_ids().to_vec(); - for id in generation_config_eos_token_ids(model_dir) { - if !ids.contains(&id) { - ids.push(id); - } - } - ids - } - - fn generation_config_eos_token_ids(model_dir: &Path) -> Vec { - let Ok(config_json) = std::fs::read_to_string(model_dir.join("generation_config.json")) - else { - return Vec::new(); - }; - let Ok(config) = serde_json::from_str::(&config_json) else { - return Vec::new(); - }; - match config.get("eos_token_id") { - Some(value) => token_id_or_ids(value), - None => Vec::new(), - } - } - - fn token_id_or_ids(value: &serde_json::Value) -> Vec { - if let Some(id) = value.as_u64().and_then(|id| u32::try_from(id).ok()) { - return vec![id]; - } - value - .as_array() - .map(|ids| { - ids.iter() - .filter_map(|id| id.as_u64().and_then(|id| u32::try_from(id).ok())) - .collect() - }) - .unwrap_or_default() - } - - fn build_prompt( - model: &mut LoadedModel, - model_name: &str, - system: &str, - messages: &[Message], - tools: &[rmcp::model::Tool], - tool_mode: ToolMode, - ) -> Result { - match tool_mode { - ToolMode::Native => { - let conversations = openai_messages(system, messages); - let tool_specs = openai::format_tools(tools) - .map_err(|e| ProviderError::ExecutionError(e.to_string()))?; - if let Some(prompt) = model - .apply_chat_template_json([conversations], Some(&tool_specs), true) - .map_err(mlx_error)? - { - return Ok(prompt); - } - - Ok(render_prompt(system, messages)) - } - ToolMode::Emulated { code_mode_enabled } => { - let system_prompt = format!( - "{}{}", - load_tiny_model_prompt(), - build_emulator_tool_description(tools, code_mode_enabled) - ); - if is_gemma4(model) { - let conversations = gemma4_messages_with_system(&system_prompt, messages); - if let Some(prompt) = model - .apply_chat_template_json([conversations], None, true) - .map_err(mlx_error)? - { - return Ok(prompt); - } - } - - let conversations = chat_conversations(&system_prompt, messages); - if let Some(prompt) = model - .apply_chat_template([Chat::Owned(conversations)], None, true) - .map_err(mlx_error)? - { - return Ok(prompt); - } - - Ok(render_prompt(&system_prompt, messages)) - } - ToolMode::None => { - if is_gemma4(model) { - let conversations = gemma4_messages(model_name, system, messages); - if let Some(prompt) = model - .apply_chat_template_json([conversations], None, true) - .map_err(mlx_error)? - { - return Ok(prompt); - } - } - - let conversations = chat_conversations(system, messages); - if let Some(prompt) = model - .apply_chat_template([Chat::Owned(conversations)], None, true) - .map_err(mlx_error)? - { - return Ok(prompt); - } - - Ok(render_prompt(system, messages)) - } - } - } - - fn generate_single_model( - model: &mut LoadedModel, - tokenizer: &Tokenizer, - prompt_array: &Array, - eos_token_ids: &[u32], - max_tokens: usize, - temp: f32, - prng_key: Option, - stream: &Stream, - generation_started: std::time::Instant, - mut emitter: MlxStreamEmitter<'_>, - ) -> Result { - let mut cache = model.new_cache(); - let mut generated_ids = Vec::new(); - let mut streamed_text = String::new(); - let mut time_to_first_token_ms = None; - let stream_generation = emitter.can_stream(); - let mut decode_stream = tokenizer.decode_stream(true); - { - let generator = model - .generate_with_cache(&mut cache, temp, prompt_array, prng_key, stream) - .take(max_tokens); - for token in generator { - let token = token.map_err(mlx_error)?; - eval([&token]).map_err(mlx_error)?; - let token_id = token.item::(stream); - time_to_first_token_ms.get_or_insert_with(|| { - u64::try_from(generation_started.elapsed().as_millis()).unwrap_or(u64::MAX) - }); - if eos_token_ids.contains(&token_id) { - break; - } - generated_ids.push(token_id); - if stream_generation { - if let Some(piece) = decode_stream.step(token_id).map_err(mlx_error)? { - if !piece.is_empty() { - let should_continue = emitter.push_text(&piece)?; - streamed_text.push_str(&piece); - if !should_continue { - break; - } - } - } - } - } - } - let generated_text = tokenizer.decode(&generated_ids, true).map_err(mlx_error)?; - let streamed_response = if stream_generation { - match final_stream_suffix(&generated_text, &streamed_text)? { - Some(suffix) => { - if !suffix.is_empty() { - emitter.push_text(suffix)?; - } - true - } - None => false, - } - } else { - false - }; - if streamed_response { - emitter.finish()?; - } - Ok(MlxGeneration { - generated_ids, - generated_text, - draft_stats: None, - time_to_first_token_ms, - streamed_response, - }) - } - - fn final_stream_suffix<'a>( - generated_text: &'a str, - streamed_text: &str, - ) -> Result, ProviderError> { - if streamed_text.is_empty() { - return Ok(None); - } - - generated_text - .strip_prefix(streamed_text) - .map(Some) - .ok_or_else(|| mlx_error("streamed MLX decode did not match final tokenizer decode")) - } - - fn mlx_max_tokens( - settings: &ModelSettings, - request_max_tokens: Option, - context_limit: usize, - prompt_tokens: usize, - ) -> usize { - let configured_max = settings - .max_output_tokens - .or_else(|| request_max_tokens.and_then(|tokens| usize::try_from(tokens).ok())); - if context_limit == 0 { - return configured_max.unwrap_or(4096); - } - - let context_headroom = context_limit.saturating_sub(prompt_tokens); - configured_max - .map(|max| max.min(context_headroom)) - .unwrap_or(context_headroom) - } - - fn is_gemma4(model: &LoadedModel) -> bool { - matches!(model.model_type(), "gemma4" | "gemma4_text") - } - - fn gemma4_messages( - model_name: &str, - system: &str, - messages: &[Message], - ) -> Vec { - let system = gemma4_system_prompt(model_name, system); - gemma4_messages_with_optional_system(system.as_deref(), messages) - } - - fn gemma4_messages_with_system(system: &str, messages: &[Message]) -> Vec { - gemma4_messages_with_optional_system(Some(system), messages) - } - - fn gemma4_messages_with_optional_system( - system: Option<&str>, - messages: &[Message], - ) -> Vec { - let mut values = Vec::new(); - if let Some(system) = system.map(str::trim).filter(|system| !system.is_empty()) { - values.push(json!({ - "role": "system", - "content": system, - })); - } - - for message in messages.iter().filter(|message| message.is_agent_visible()) { - let text = extract_text_content(message); - if text.trim().is_empty() { - continue; - } - - match message.role { - rmcp::model::Role::User => values.push(json!({ - "role": "user", - "content": [{"type": "text", "text": text.trim(), "content": text.trim()}], - })), - rmcp::model::Role::Assistant => values.push(json!({ - "role": "assistant", - "content": text.trim(), - })), - } - } - - values - } - - fn gemma4_system_prompt(model_name: &str, system: &str) -> Option { - if should_use_tiny_system_prompt(model_name) { - return Some(load_tiny_model_prompt()); - } - - let filtered = filter_extensions_from_system_prompt(system); - let system = filtered.trim(); - if system.is_empty() { - None - } else { - Some(system.to_string()) - } - } - - fn should_use_tiny_system_prompt(model_name: &str) -> bool { - estimate_model_size_billions(model_name).is_some_and(|size| size <= 4.0) - } - - fn estimate_model_size_billions(model_name: &str) -> Option { - let normalized = model_name.to_ascii_lowercase().replace('-', "_"); - for part in normalized.split('_') { - if let Some(value) = part.strip_suffix('b') { - if let Ok(size) = value.parse::() { - return Some(size); - } - } - if let Some(value) = part - .strip_prefix('e') - .and_then(|value| value.strip_suffix('b')) - { - if let Ok(size) = value.parse::() { - return Some(size); - } - } - } - None - } - - fn openai_messages(system: &str, messages: &[Message]) -> Vec { - let mut values = vec![serde_json::json!({ - "role": "system", - "content": system, - })]; - values.extend(openai::format_messages(messages, &ImageFormat::OpenAi)); - values - } - - fn chat_conversations(system: &str, messages: &[Message]) -> Vec> { - let mut conversations = Vec::new(); - if !system.trim().is_empty() { - conversations.push(Conversation { - role: Role::System, - content: system.trim().to_string(), - }); - } - for message in messages.iter().filter(|message| message.is_agent_visible()) { - let role = match message.role { - rmcp::model::Role::User => Role::User, - rmcp::model::Role::Assistant => Role::Assistant, - }; - let text = extract_text_content(message); - if !text.trim().is_empty() { - conversations.push(Conversation { - role, - content: text.trim().to_string(), - }); - } - } - conversations - } - - fn emit_generated_response( - generated_text: &str, - generation_prompt: &str, - enable_thinking: bool, - message_id: &str, - tool_mode: ToolMode, - tx: &tokio::sync::mpsc::Sender< - Result<(Option, Option), ProviderError>, - >, - ) -> Result<(), ProviderError> { - if generated_text.is_empty() { - return Ok(()); - } - - let (content, thinking) = - split_generated_thinking(generated_text, generation_prompt, enable_thinking); - - match tool_mode { - ToolMode::None => { - emit_assistant_message(message_id, &thinking, &content, tx)?; - } - ToolMode::Native => { - if let Some(mut message) = message_from_native_tool_text(&content, message_id)? { - prepend_thinking(&mut message, &thinking); - tx.blocking_send(Ok((Some(message), None))).map_err(|_| { - ProviderError::ExecutionError("Failed to stream MLX response".to_string()) - })?; - } else { - emit_assistant_message(message_id, &thinking, &content, tx)?; - } - } - ToolMode::Emulated { code_mode_enabled } => { - emit_assistant_message(message_id, &thinking, "", tx)?; - let mut parser = StreamingEmulatorParser::new(code_mode_enabled); - let mut actions = parser.process_chunk(&content); - actions.extend(parser.flush()); - - for action in actions { - let (message, _) = message_for_emulator_action(&action, message_id); - tx.blocking_send(Ok((Some(message), None))).map_err(|_| { - ProviderError::ExecutionError("Failed to stream MLX response".to_string()) - })?; - } - } - } - Ok(()) - } - - struct MlxStreamEmitter<'a> { - message_id: &'a str, - tool_mode: ToolMode, - tx: &'a tokio::sync::mpsc::Sender< - Result<(Option, Option), ProviderError>, - >, - output_filter: ThinkingOutputFilter, - emulator_parser: Option, - stop_after_tool_call: bool, - } - - impl<'a> MlxStreamEmitter<'a> { - fn new( - message_id: &'a str, - tool_mode: ToolMode, - enable_thinking: bool, - generation_prompt: &str, - tx: &'a tokio::sync::mpsc::Sender< - Result<(Option, Option), ProviderError>, - >, - ) -> Self { - let emulator_parser = match tool_mode { - ToolMode::Emulated { code_mode_enabled } => { - Some(StreamingEmulatorParser::new(code_mode_enabled)) - } - ToolMode::None | ToolMode::Native => None, - }; - Self { - message_id, - tool_mode, - tx, - output_filter: ThinkingOutputFilter::new(enable_thinking, generation_prompt), - emulator_parser, - stop_after_tool_call: false, - } - } - - fn can_stream(&self) -> bool { - !matches!(self.tool_mode, ToolMode::Native) - } - - fn push_text(&mut self, text: &str) -> Result { - let filtered = self.output_filter.push_text(text); - if !filtered.content.is_empty() { - self.emit_content(&filtered.content)?; - } - Ok(!self.stop_after_tool_call) - } - - fn finish(&mut self) -> Result<(), ProviderError> { - self.flush_filtered_output()?; - let actions = self - .emulator_parser - .as_mut() - .map(StreamingEmulatorParser::flush) - .unwrap_or_default(); - for action in actions { - let (message, is_tool) = message_for_emulator_action(&action, self.message_id); - if is_tool { - self.flush_filtered_output()?; - } - self.send(message)?; - self.stop_after_tool_call |= is_tool; - if is_tool { - break; - } - } - Ok(()) - } - - fn flush_filtered_output(&mut self) -> Result<(), ProviderError> { - let filtered = self.output_filter.finish(); - if !filtered.thinking.is_empty() { - let mut message = Message::assistant().with_thinking(filtered.thinking, ""); - message.id = Some(self.message_id.to_string()); - self.send(message)?; - } - if !filtered.content.is_empty() { - self.emit_content(&filtered.content)?; - } - Ok(()) - } - - fn emit_content(&mut self, content: &str) -> Result<(), ProviderError> { - match self.tool_mode { - ToolMode::None => { - let mut message = Message::assistant().with_text(content); - message.id = Some(self.message_id.to_string()); - self.send(message) - } - ToolMode::Emulated { .. } => { - let actions = self - .emulator_parser - .as_mut() - .map(|parser| parser.process_chunk(content)) - .unwrap_or_default(); - for action in actions { - let (message, is_tool) = - message_for_emulator_action(&action, self.message_id); - if is_tool { - self.flush_filtered_output()?; - } - self.send(message)?; - self.stop_after_tool_call |= is_tool; - if is_tool { - break; - } - } - Ok(()) - } - ToolMode::Native => Ok(()), - } - } - - fn send(&self, message: Message) -> Result<(), ProviderError> { - self.tx - .blocking_send(Ok((Some(message), None))) - .map_err(|_| { - ProviderError::ExecutionError("Failed to stream MLX response".to_string()) - }) - } - } - - fn split_generated_thinking( - generated_text: &str, - generation_prompt: &str, - enable_thinking: bool, - ) -> (String, String) { - let mut filter = ThinkingOutputFilter::new(enable_thinking, generation_prompt); - let mut filtered = filter.push_text(generated_text); - let final_filtered = filter.finish(); - filtered.content.push_str(&final_filtered.content); - filtered.thinking.push_str(&final_filtered.thinking); - (filtered.content, filtered.thinking) - } - - fn emit_assistant_message( - message_id: &str, - thinking: &str, - content: &str, - tx: &tokio::sync::mpsc::Sender< - Result<(Option, Option), ProviderError>, - >, - ) -> Result<(), ProviderError> { - if thinking.is_empty() && content.is_empty() { - return Ok(()); - } - - let mut message = Message::assistant(); - if !thinking.is_empty() { - message = message.with_thinking(thinking, ""); - } - if !content.is_empty() { - message = message.with_text(content); - } - message.id = Some(message_id.to_string()); - tx.blocking_send(Ok((Some(message), None))) - .map_err(|_| ProviderError::ExecutionError("Failed to stream MLX response".to_string())) - } - - fn prepend_thinking(message: &mut Message, thinking: &str) { - if !thinking.is_empty() { - message - .content - .insert(0, MessageContent::thinking(thinking, "")); - } - } - - fn sampling(settings: &ModelSettings) -> (f32, Option) { - match &settings.sampling { - crate::local_model_registry::SamplingConfig::Greedy => (0.0, None), - crate::local_model_registry::SamplingConfig::Temperature { - temperature, seed, .. - } => (*temperature, *seed), - crate::local_model_registry::SamplingConfig::MirostatV2 { seed, .. } => (0.0, *seed), - } - } - - fn prng_key(temp: f32, seed: Option) -> Result, ProviderError> { - if temp == 0.0 { - return Ok(None); - } - random::key(seed.unwrap_or(0) as u64) - .map(Some) - .map_err(mlx_error) - } - - fn render_prompt(system: &str, messages: &[Message]) -> String { - let mut prompt = String::new(); - if !system.trim().is_empty() { - prompt.push_str("System: "); - prompt.push_str(system.trim()); - prompt.push('\n'); - } - for message in messages.iter().filter(|message| message.is_agent_visible()) { - let role = match message.role { - rmcp::model::Role::User => "User", - rmcp::model::Role::Assistant => "Assistant", - }; - let text = extract_text_content(message); - if !text.trim().is_empty() { - prompt.push_str(role); - prompt.push_str(": "); - prompt.push_str(text.trim()); - prompt.push('\n'); - } - } - prompt.push_str("Assistant: "); - prompt - } - - fn mlx_error(error: impl std::fmt::Display) -> ProviderError { - ProviderError::ExecutionError(format!("MLX backend error: {}", error)) - } - - #[cfg(test)] - mod tests { - use super::{ - final_stream_suffix, mlx_max_tokens, split_generated_thinking, token_id_or_ids, - }; - use crate::local_model_registry::ModelSettings; - use serde_json::json; - - #[test] - fn extracts_thinking_started_by_generation_prompt() { - let (content, thinking) = split_generated_thinking( - "hidden reasoningvisible answer", - "<|im_start|>assistant\n\n", - true, - ); - - assert_eq!(thinking.trim(), "hidden reasoning"); - assert_eq!(content, "visible answer"); - } - - #[test] - fn leaves_think_tags_as_content_when_thinking_disabled() { - let generated = "hidden reasoningvisible answer"; - let (content, thinking) = - split_generated_thinking(generated, "<|im_start|>assistant\n\n", false); - - assert!(thinking.is_empty()); - assert_eq!(content, generated); - } - - #[test] - fn parses_single_and_multiple_eos_token_ids() { - assert_eq!(token_id_or_ids(&json!(248044)), vec![248044]); - assert_eq!( - token_id_or_ids(&json!([248046, 248044])), - vec![248046, 248044] - ); - } - - #[test] - fn final_stream_suffix_flushes_append_only_suffix() { - assert_eq!( - final_stream_suffix("hello world", "hello").unwrap(), - Some(" world") - ); - } - - #[test] - fn final_stream_suffix_does_not_replay_fully_streamed_text() { - assert_eq!( - final_stream_suffix("run tool", "run tool").unwrap(), - Some("") - ); - } - - #[test] - fn final_stream_suffix_allows_unstreamed_fallback() { - assert_eq!(final_stream_suffix("hello world", "").unwrap(), None); - } - - #[test] - fn final_stream_suffix_rejects_rewritten_streamed_prefix() { - assert!(final_stream_suffix("corrected response", "stale prefix").is_err()); - } - - #[test] - fn max_tokens_defaults_to_context_headroom() { - let settings = ModelSettings::default(); - - assert_eq!(mlx_max_tokens(&settings, None, 128_000, 1_752), 126_248); - } - - #[test] - fn max_tokens_respects_configured_caps() { - let mut settings = ModelSettings::default(); - settings.max_output_tokens = Some(2048); - - assert_eq!(mlx_max_tokens(&settings, None, 128_000, 1_752), 2048); - - let settings = ModelSettings::default(); - assert_eq!(mlx_max_tokens(&settings, Some(1024), 128_000, 1_752), 1024); - } - } -} - -#[cfg(not(feature = "mlx"))] -mod imp { - use crate::backend::{BackendLoadedModel, LocalGenerationRequest, LocalInferenceBackend}; - use crate::local_model_registry::ModelSettings; - use crate::ResolvedModelPaths; - use goose_provider_types::errors::ProviderError; - - pub(crate) const MLX_BACKEND_ID: &str = "mlx"; - - pub(crate) struct MlxBackend; - - impl MlxBackend { - pub(crate) fn new() -> Self { - Self - } - } - - impl LocalInferenceBackend for MlxBackend { - fn id(&self) -> &'static str { - MLX_BACKEND_ID - } - - fn load_model( - &self, - _model_id: &str, - _resolved: &ResolvedModelPaths, - _settings: &ModelSettings, - ) -> Result, ProviderError> { - Err(ProviderError::ExecutionError( - "MLX backend support was not compiled in. Rebuild with the `mlx` feature." - .to_string(), - )) - } - - fn generate( - &self, - _loaded: &mut dyn BackendLoadedModel, - _request: LocalGenerationRequest<'_>, - ) -> Result<(), ProviderError> { - Err(ProviderError::ExecutionError( - "MLX backend support was not compiled in. Rebuild with the `mlx` feature." - .to_string(), - )) - } - - fn available_memory_bytes(&self) -> u64 { - 0 - } - } -} - -pub(crate) use imp::{MlxBackend, MLX_BACKEND_ID}; diff --git a/crates/goose-local-inference/src/native_tool_parsing.rs b/crates/goose-local-inference/src/native_tool_parsing.rs deleted file mode 100644 index 3773c6b95f..0000000000 --- a/crates/goose-local-inference/src/native_tool_parsing.rs +++ /dev/null @@ -1,323 +0,0 @@ -use goose_provider_types::conversation::message::{Message, MessageContent}; -use goose_provider_types::errors::ProviderError; -use goose_provider_types::formats::openai::is_valid_function_name; -use goose_provider_types::json::safely_parse_json; -use rmcp::model::{object, CallToolRequestParams, ErrorCode, ErrorData}; -use serde_json::{json, Value}; -use std::borrow::Cow; -use uuid::Uuid; - -pub(crate) fn message_from_native_tool_text( - generated_text: &str, - message_id: &str, -) -> Result, ProviderError> { - let mut content = Vec::new(); - - if let Some(message) = parse_openai_message_json(generated_text) { - append_text(&mut content, message.get("content")); - append_tool_calls(&mut content, message.get("tool_calls")); - } else if let Some(tool_calls) = parse_tool_calls_json(generated_text) { - append_tool_calls(&mut content, Some(&tool_calls)); - } else if generated_text.contains(" Option { - json_candidates(generated_text) - .into_iter() - .find(|value| value.get("tool_calls").is_some_and(is_tool_call_array)) -} - -fn parse_tool_calls_json(generated_text: &str) -> Option { - for value in json_candidates(generated_text) { - if is_tool_call_array(&value) { - return Some(value); - } - if let Some(tool_calls) = value - .get("tool_calls") - .filter(|value| is_tool_call_array(value)) - { - return Some(tool_calls.clone()); - } - if is_tool_call_value(&value) { - return Some(Value::Array(vec![value])); - } - } - None -} - -fn is_tool_call_array(value: &Value) -> bool { - value - .as_array() - .is_some_and(|items| !items.is_empty() && items.iter().all(is_tool_call_value)) -} - -fn is_tool_call_value(value: &Value) -> bool { - let direct_name = value.get("name").and_then(|name| name.as_str()).is_some(); - let direct_arguments = value.get("arguments").is_some(); - let function_name = value - .get("function") - .and_then(|function| function.get("name")) - .and_then(|name| name.as_str()) - .is_some(); - - (direct_name && direct_arguments) || function_name -} - -fn json_candidates(text: &str) -> Vec { - let mut candidates = Vec::new(); - let trimmed = text.trim(); - if let Ok(value) = serde_json::from_str::(trimmed) { - candidates.push(value); - } - - for (open, close) in [('{', '}'), ('[', ']')] { - let starts = text.match_indices(open).map(|(idx, _)| idx); - for start in starts { - let mut depth = 0i32; - let mut in_string = false; - let mut escaped = false; - for (offset, ch) in text[start..].char_indices() { - if escaped { - escaped = false; - continue; - } - if ch == '\\' && in_string { - escaped = true; - continue; - } - if ch == '"' { - in_string = !in_string; - continue; - } - if in_string { - continue; - } - if ch == open { - depth += 1; - } else if ch == close { - depth -= 1; - if depth == 0 { - let end = start + offset + ch.len_utf8(); - if let Ok(value) = serde_json::from_str::(&text[start..end]) { - candidates.push(value); - } - break; - } - } - } - } - } - - candidates -} - -fn append_text(content: &mut Vec, value: Option<&Value>) { - if let Some(text) = value.and_then(|value| value.as_str()) { - if !text.is_empty() { - content.push(MessageContent::text(text)); - } - } -} - -fn append_tool_calls(content: &mut Vec, value: Option<&Value>) { - let Some(tool_calls) = value.and_then(|value| value.as_array()) else { - return; - }; - - for tool_call in tool_calls { - content.push(tool_call_content(tool_call)); - } -} - -fn tool_call_content(tool_call: &Value) -> MessageContent { - let id = tool_call - .get("id") - .and_then(|id| id.as_str()) - .map(ToString::to_string) - .unwrap_or_else(|| Uuid::new_v4().to_string()); - - let function = tool_call.get("function").unwrap_or(tool_call); - let name = function - .get("name") - .and_then(|name| name.as_str()) - .unwrap_or_default() - .to_string(); - - if !is_valid_function_name(&name) { - return MessageContent::tool_request( - id, - Err(ErrorData { - code: ErrorCode::INVALID_REQUEST, - message: Cow::from(format!( - "The provided function name '{}' had invalid characters, it must match this regex [a-zA-Z0-9_-]+", - name - )), - data: None, - }), - ); - } - - let arguments = function - .get("arguments") - .or_else(|| tool_call.get("arguments")) - .cloned() - .unwrap_or_else(|| json!({})); - - let raw_arguments = arguments.to_string(); - let parsed = match arguments { - Value::String(arguments) if arguments.trim().is_empty() => Ok(json!({})), - Value::String(arguments) => safely_parse_json(&arguments), - Value::Object(_) => Ok(arguments), - Value::Null => Ok(json!({})), - other => Ok(other), - }; - - match parsed { - Ok(params) => MessageContent::tool_request( - id, - Ok(CallToolRequestParams::new(name).with_arguments(object(params))), - ), - Err(error) => { - let message = format!( - "Could not interpret tool use parameters for id {}: {}. Raw arguments: '{}'", - id, error, raw_arguments - ); - MessageContent::tool_request( - id, - Err(ErrorData { - code: ErrorCode::INVALID_PARAMS, - message: Cow::from(message), - data: None, - }), - ) - } - } -} - -fn parse_xml_tool_calls(content: &str) -> (Option, Vec) { - let function_re = regex::Regex::new(r"]+)>([\s\S]*?)").unwrap(); - let param_re = regex::Regex::new(r"]+)>([\s\S]*?)").unwrap(); - - let prefix = content - .find(" usize { - message - .content - .iter() - .filter(|content| matches!(content, MessageContent::ToolRequest(_))) - .count() - } - - #[test] - fn parses_openai_message_tool_calls() { - let text = r#"{"content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"developer__shell","arguments":"{\"command\":\"pwd\"}"}}]}"#; - let message = message_from_native_tool_text(text, "msg").unwrap().unwrap(); - assert_eq!(tool_count(&message), 1); - } - - #[test] - fn parses_top_level_tool_calls() { - let text = r#"{"tool_calls":[{"name":"developer__shell","arguments":{"command":"pwd"}}]}"#; - let message = message_from_native_tool_text(text, "msg").unwrap().unwrap(); - assert_eq!(tool_count(&message), 1); - } - - #[test] - fn parses_top_level_tool_call_array() { - let text = r#"[{"name":"developer__shell","arguments":{"command":"pwd"}}]"#; - let message = message_from_native_tool_text(text, "msg").unwrap().unwrap(); - assert_eq!(tool_count(&message), 1); - } - - #[test] - fn parses_top_level_tool_call_object_with_arguments() { - let text = r#"{"name":"developer__shell","arguments":{"command":"pwd"}}"#; - let message = message_from_native_tool_text(text, "msg").unwrap().unwrap(); - assert_eq!(tool_count(&message), 1); - } - - #[test] - fn ignores_plain_json_objects_with_name_fields() { - let text = r#"{"name":"Alice","age":30}"#; - assert!(message_from_native_tool_text(text, "msg") - .unwrap() - .is_none()); - } - - #[test] - fn ignores_plain_json_arrays() { - let text = r#"["a","b"]"#; - assert!(message_from_native_tool_text(text, "msg") - .unwrap() - .is_none()); - } - - #[test] - fn ignores_non_tool_call_arrays_in_tool_calls_field() { - let text = r#"{"tool_calls":["a","b"]}"#; - assert!(message_from_native_tool_text(text, "msg") - .unwrap() - .is_none()); - } - - #[test] - fn parses_xml_tool_calls() { - let text = r#"pwd"#; - let message = message_from_native_tool_text(text, "msg").unwrap().unwrap(); - assert_eq!(tool_count(&message), 1); - } -} diff --git a/crates/goose-local-inference/src/paths.rs b/crates/goose-local-inference/src/paths.rs deleted file mode 100644 index 4a79cbf306..0000000000 --- a/crates/goose-local-inference/src/paths.rs +++ /dev/null @@ -1,88 +0,0 @@ -use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; -use std::path::PathBuf; - -pub struct Paths; - -impl Paths { - fn get_dir(dir_type: DirType) -> PathBuf { - if let Ok(test_root) = std::env::var("GOOSE_PATH_ROOT") { - let base = PathBuf::from(test_root); - match dir_type { - DirType::Config => base.join("config"), - DirType::Data => base.join("data"), - DirType::State => base.join("state"), - DirType::Plugins => base.join(".agents").join("plugins"), - DirType::Agents => base.join(".agents").join("agents"), - DirType::AgentsHome => base.join(".agents"), - } - } else { - // NOTE: "Block" is kept here for backwards compatibility with existing - // user config/data directories (e.g. ~/Library/Application Support/Block/goose/). - // Changing this would orphan existing installations. - let strategy = choose_app_strategy(AppStrategyArgs { - top_level_domain: "Block".to_string(), - author: "Block".to_string(), - app_name: "goose".to_string(), - }) - .expect("goose requires a home dir"); - - match dir_type { - DirType::Config => strategy.config_dir(), - DirType::Data => strategy.data_dir(), - DirType::State => strategy.state_dir().unwrap_or(strategy.data_dir()), - DirType::Plugins => strategy.home_dir().join(".agents").join("plugins"), - DirType::Agents => strategy.home_dir().join(".agents").join("agents"), - DirType::AgentsHome => strategy.home_dir().join(".agents"), - } - } - } - - pub fn config_dir() -> PathBuf { - Self::get_dir(DirType::Config) - } - - pub fn data_dir() -> PathBuf { - Self::get_dir(DirType::Data) - } - - pub fn state_dir() -> PathBuf { - Self::get_dir(DirType::State) - } - - pub fn plugins_dir() -> PathBuf { - Self::get_dir(DirType::Plugins) - } - - pub fn agents_dir() -> PathBuf { - Self::get_dir(DirType::Agents) - } - - pub fn agents_home_dir() -> PathBuf { - Self::get_dir(DirType::AgentsHome) - } - - pub fn in_agents_home_dir(subpath: &str) -> PathBuf { - Self::agents_home_dir().join(subpath) - } - - pub fn in_state_dir(subpath: &str) -> PathBuf { - Self::state_dir().join(subpath) - } - - pub fn in_config_dir(subpath: &str) -> PathBuf { - Self::config_dir().join(subpath) - } - - pub fn in_data_dir(subpath: &str) -> PathBuf { - Self::data_dir().join(subpath) - } -} - -enum DirType { - Config, - Data, - State, - Plugins, - Agents, - AgentsHome, -} diff --git a/crates/goose-local-inference/src/prompt_template.rs b/crates/goose-local-inference/src/prompt_template.rs deleted file mode 100644 index 046fc2402f..0000000000 --- a/crates/goose-local-inference/src/prompt_template.rs +++ /dev/null @@ -1,43 +0,0 @@ -use include_dir::{include_dir, Dir}; -use minijinja::{Environment, Error as MiniJinjaError, Value as MJValue}; -use serde::Serialize; - -use crate::paths::Paths; - -static CORE_PROMPTS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/prompts"); - -pub fn render_string( - template_str: &str, - context: &T, -) -> Result { - let mut env = Environment::new(); - env.set_trim_blocks(true); - env.set_lstrip_blocks(true); - env.add_template("template", template_str)?; - let tmpl = env.get_template("template")?; - let ctx = MJValue::from_serialize(context); - let rendered = tmpl.render(ctx)?; - Ok(rendered.trim().to_string()) -} - -pub fn render_template(name: &str, context: &T) -> Result { - let user_path = Paths::config_dir().join("prompts").join(name); - let template_str = if user_path.exists() { - std::fs::read_to_string(&user_path).map_err(|e| { - MiniJinjaError::new( - minijinja::ErrorKind::InvalidOperation, - format!("Failed to read user template: {}", e), - ) - })? - } else { - let file = CORE_PROMPTS_DIR.get_file(name).ok_or_else(|| { - MiniJinjaError::new( - minijinja::ErrorKind::TemplateNotFound, - format!("Built-in template '{}' not found", name), - ) - })?; - String::from_utf8_lossy(file.contents()).to_string() - }; - - render_string(&template_str, context) -} diff --git a/crates/goose-local-inference/src/prompts/tiny_model_system.md b/crates/goose-local-inference/src/prompts/tiny_model_system.md deleted file mode 100644 index 2a05d84160..0000000000 --- a/crates/goose-local-inference/src/prompts/tiny_model_system.md +++ /dev/null @@ -1,22 +0,0 @@ -You are goose, an autonomous AI agent created by AAIF (Agentic AI Foundation). You act on the user's -behalf โ€” you do not explain how to do things, you DO them directly. - -The OS is {{os}}, the shell is {{shell}}, and the working directory is {{working_directory}} - -When the user asks you to do something, take action immediately. Do not describe -what you would do or give instructions โ€” execute the commands yourself. - -To run a shell command, start a new line with $: - -$ ls - -Keep your responses brief. State what you are doing, then do it. For example: - -User: how many files are in /tmp? -You: Let me check. -$ ls -1 /tmp | wc -l - -After a command runs, you will see its output. Use the output to answer the user -or take the next step. Do not repeat commands you have already run. - -Do not use shell commands if you already know the answer. \ No newline at end of file diff --git a/crates/goose-local-inference/src/provider_utils.rs b/crates/goose-local-inference/src/provider_utils.rs deleted file mode 100644 index 1300c1f669..0000000000 --- a/crates/goose-local-inference/src/provider_utils.rs +++ /dev/null @@ -1,24 +0,0 @@ -pub fn filter_extensions_from_system_prompt(system: &str) -> String { - let Some(extensions_start) = system.find("# Extensions") else { - return system.to_string(); - }; - - let Some(after_extensions) = system.get(extensions_start + 1..) else { - return system.to_string(); - }; - - if let Some(next_section_pos) = after_extensions.find("\n# ") { - let Some(before) = system.get(..extensions_start) else { - return system.to_string(); - }; - let Some(after) = system.get(extensions_start + next_section_pos + 1..) else { - return system.to_string(); - }; - format!("{}{}", before.trim_end(), after) - } else { - system - .get(..extensions_start) - .map(|s| s.trim_end().to_string()) - .unwrap_or_else(|| system.to_string()) - } -} diff --git a/crates/goose-local-inference/src/thinking_output.rs b/crates/goose-local-inference/src/thinking_output.rs deleted file mode 100644 index b47d23bc9f..0000000000 --- a/crates/goose-local-inference/src/thinking_output.rs +++ /dev/null @@ -1,81 +0,0 @@ -use goose_provider_types::thinking::{FilterOut, ThinkFilter}; - -pub(crate) struct ThinkingOutputFilter { - enabled: bool, - saw_structured_reasoning: bool, - think_filter: ThinkFilter, - pending_inline_thinking: String, - accumulated_thinking: String, -} - -impl ThinkingOutputFilter { - pub(crate) fn new(enable_thinking: bool, generation_prompt: &str) -> Self { - let mut think_filter = ThinkFilter::new(); - if enable_thinking && !generation_prompt.is_empty() { - let _ = think_filter.push(generation_prompt); - } - - Self { - enabled: enable_thinking, - saw_structured_reasoning: false, - think_filter, - pending_inline_thinking: String::new(), - accumulated_thinking: String::new(), - } - } - - pub(crate) fn push_structured_reasoning(&mut self, reasoning: &str) -> Option { - if reasoning.is_empty() { - return None; - } - - self.saw_structured_reasoning = true; - self.pending_inline_thinking.clear(); - self.think_filter = ThinkFilter::new(); - self.accumulated_thinking.push_str(reasoning); - Some(reasoning.to_string()) - } - - pub(crate) fn push_text(&mut self, text: &str) -> FilterOut { - if !self.enabled { - return FilterOut { - content: text.to_string(), - thinking: String::new(), - }; - } - - let mut filtered = self.think_filter.push(text); - if self.saw_structured_reasoning { - filtered.thinking.clear(); - } else if !filtered.thinking.is_empty() { - self.pending_inline_thinking.push_str(&filtered.thinking); - filtered.thinking.clear(); - } - filtered - } - - pub(crate) fn finish(&mut self) -> FilterOut { - let mut filtered = if self.enabled && !self.saw_structured_reasoning { - std::mem::take(&mut self.think_filter).finish() - } else { - FilterOut::default() - }; - - if !self.saw_structured_reasoning { - let mut thinking = std::mem::take(&mut self.pending_inline_thinking); - thinking.push_str(&filtered.thinking); - if !thinking.is_empty() { - self.accumulated_thinking.push_str(&thinking); - } - filtered.thinking = thinking; - } else { - filtered.thinking.clear(); - } - - filtered - } - - pub(crate) fn accumulated_thinking(&self) -> &str { - &self.accumulated_thinking - } -} diff --git a/crates/goose-local-inference/src/tool_emulation.rs b/crates/goose-local-inference/src/tool_emulation.rs deleted file mode 100644 index ac528be232..0000000000 --- a/crates/goose-local-inference/src/tool_emulation.rs +++ /dev/null @@ -1,555 +0,0 @@ -//! Shared text-based tool call emulation for local inference backends. -//! -//! Models that do not have native tool-calling support are prompted to emit shell commands -//! as `$ command` on a new line and code blocks as ```execute_typescript fenced blocks. -//! The parser converts those patterns into Goose tool-call messages. - -use goose_provider_types::conversation::message::{Message, MessageContent}; -use rmcp::model::{CallToolRequestParams, Tool}; -use serde_json::json; -use std::borrow::Cow; -use uuid::Uuid; - -pub(crate) const SHELL_TOOL: &str = "developer__shell"; -pub(crate) const CODE_EXECUTION_TOOL: &str = "code_execution__execute_typescript"; - -const HOLD_BACK_CODE_MODE: usize = " ```execute_typescript\n".len(); -const HOLD_BACK_SHELL_ONLY: usize = "\n$".len(); - -pub(crate) fn load_tiny_model_prompt() -> String { - use std::env; - - let os = if cfg!(target_os = "macos") { - "macos" - } else if cfg!(target_os = "linux") { - "linux" - } else if cfg!(target_os = "windows") { - "windows" - } else { - "unknown" - }; - - let working_directory = env::current_dir() - .map(|p| p.display().to_string()) - .unwrap_or_else(|_| "unknown".to_string()); - - let shell = env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); - - let context = json!({ - "os": os, - "working_directory": working_directory, - "shell": shell, - }); - - crate::prompt_template::render_template("tiny_model_system.md", &context).unwrap_or_else(|e| { - tracing::warn!("Failed to load tiny_model_system.md: {:?}", e); - "You are Goose, an AI assistant. You can execute shell commands by starting lines with $." - .to_string() - }) -} - -pub(crate) fn build_emulator_tool_description(tools: &[Tool], code_mode_enabled: bool) -> String { - let mut tool_desc = String::new(); - - if code_mode_enabled { - tool_desc.push_str("\n\n# Running Code\n\n"); - tool_desc.push_str( - "You can call tools by writing code in a ```execute_typescript block. \ - The code runs immediately โ€” do not explain it, just run it.\n\n", - ); - tool_desc.push_str("Example โ€” counting files in /tmp:\n\n"); - tool_desc.push_str("```execute_typescript\nasync function run() {\n"); - tool_desc.push_str( - " const result = await Developer.shell({ command: \"ls -1 /tmp | wc -l\" });\n", - ); - tool_desc.push_str(" return result;\n}\n```\n\n"); - tool_desc.push_str("Rules:\n"); - tool_desc.push_str("- Code MUST define async function run() and return a result\n"); - tool_desc.push_str("- All function calls are async โ€” use await\n"); - tool_desc.push_str( - "- Use ```execute_typescript for tool calls, $ for simple shell one-liners\n\n", - ); - tool_desc.push_str("Available functions:\n\n"); - - for tool in tools { - if tool.name.starts_with("code_execution__") { - continue; - } - let parts: Vec<&str> = tool.name.splitn(2, "__").collect(); - if parts.len() == 2 { - let namespace = { - let mut c = parts[0].chars(); - match c.next() { - None => String::new(), - Some(first) => first.to_uppercase().chain(c).collect::(), - } - }; - let camel_name: String = parts[1] - .split('_') - .enumerate() - .map(|(i, part)| { - if i == 0 { - part.to_string() - } else { - let mut c = part.chars(); - match c.next() { - None => String::new(), - Some(first) => first.to_uppercase().chain(c).collect(), - } - } - }) - .collect(); - let desc = tool.description.as_ref().map(|d| d.as_ref()).unwrap_or(""); - tool_desc.push_str(&format!("- {namespace}.{camel_name}(): {desc}\n")); - } - } - } else { - tool_desc.push_str("\n\n# Tools\n\nYou have access to the following tools:\n\n"); - for tool in tools { - let desc = tool - .description - .as_ref() - .map(|d| d.as_ref()) - .unwrap_or("No description"); - tool_desc.push_str(&format!("- {}: {}\n", tool.name, desc)); - } - } - - tool_desc -} - -pub(crate) enum EmulatorAction { - Text(String), - ShellCommand(String), - ExecuteCode(String), -} - -enum ParserState { - Normal, - InCommand, - InExecuteBlock, -} - -pub(crate) struct StreamingEmulatorParser { - buffer: String, - state: ParserState, - code_mode_enabled: bool, -} - -impl StreamingEmulatorParser { - pub(crate) fn new(code_mode_enabled: bool) -> Self { - Self { - buffer: String::new(), - state: ParserState::Normal, - code_mode_enabled, - } - } - - pub(crate) fn process_chunk(&mut self, chunk: &str) -> Vec { - self.buffer.push_str(chunk); - let mut results = Vec::new(); - - loop { - match self.state { - ParserState::InCommand => { - if let Some((command_line, rest)) = self.buffer.split_once('\n') { - if let Some(command) = command_line.strip_prefix('$') { - let command = command.trim(); - if !command.is_empty() { - results.push(EmulatorAction::ShellCommand(command.to_string())); - } - } - self.buffer = rest.to_string(); - self.state = ParserState::Normal; - } else { - break; - } - } - ParserState::InExecuteBlock => { - if let Some(end_idx) = self.buffer.find("\n```") { - #[allow(clippy::string_slice)] - let code = self.buffer[..end_idx].to_string(); - #[allow(clippy::string_slice)] - let rest = &self.buffer[end_idx + 4..]; - let rest = rest.strip_prefix('\n').unwrap_or(rest); - self.buffer = rest.to_string(); - self.state = ParserState::Normal; - if !code.trim().is_empty() { - results.push(EmulatorAction::ExecuteCode(code)); - } - } else { - break; - } - } - ParserState::Normal => { - if self.code_mode_enabled { - if let Some((before, after)) = - self.buffer.split_once("```execute_typescript\n") - { - if !before.trim().is_empty() { - results.push(EmulatorAction::Text(before.to_string())); - } - self.buffer = after.to_string(); - self.state = ParserState::InExecuteBlock; - continue; - } - if self.buffer.ends_with("```execute_typescript") { - let before = self.buffer.trim_end_matches("```execute_typescript"); - if !before.trim().is_empty() { - results.push(EmulatorAction::Text(before.to_string())); - } - self.buffer.clear(); - self.state = ParserState::InExecuteBlock; - continue; - } - } - - if let Some((before_dollar, from_dollar)) = self.buffer.split_once("\n$") { - let text = format!("{}\n", before_dollar); - if !text.trim().is_empty() { - results.push(EmulatorAction::Text(text)); - } - self.buffer = format!("${}", from_dollar); - self.state = ParserState::InCommand; - } else if self.buffer.starts_with('$') && self.buffer.len() == chunk.len() { - self.state = ParserState::InCommand; - } else { - let hold_back = if self.code_mode_enabled { - HOLD_BACK_CODE_MODE - } else { - HOLD_BACK_SHELL_ONLY - }; - let char_count = self.buffer.chars().count(); - if char_count > hold_back && !self.buffer.ends_with('\n') { - let mut chars = self.buffer.chars(); - let emit_count = char_count - hold_back; - let emit_text: String = chars.by_ref().take(emit_count).collect(); - let keep_text: String = chars.collect(); - if !emit_text.is_empty() { - results.push(EmulatorAction::Text(emit_text)); - } - self.buffer = keep_text; - } - break; - } - } - } - } - - results - } - - pub(crate) fn flush(&mut self) -> Vec { - let mut results = Vec::new(); - - if !self.buffer.is_empty() { - match self.state { - ParserState::InCommand => { - let command_line = self.buffer.trim(); - if let Some(command) = command_line.strip_prefix('$') { - let command = command.trim(); - if !command.is_empty() { - results.push(EmulatorAction::ShellCommand(command.to_string())); - } - } else if !command_line.is_empty() { - results.push(EmulatorAction::Text(self.buffer.clone())); - } - } - ParserState::InExecuteBlock => { - let code = self.buffer.trim(); - if !code.is_empty() { - results.push(EmulatorAction::ExecuteCode(code.to_string())); - } - } - ParserState::Normal => { - results.push(EmulatorAction::Text(self.buffer.clone())); - } - } - self.buffer.clear(); - self.state = ParserState::Normal; - } - - results - } -} - -pub(crate) fn message_for_emulator_action( - action: &EmulatorAction, - message_id: &str, -) -> (Message, bool) { - match action { - EmulatorAction::Text(text) => { - let mut message = Message::assistant().with_text(text); - message.id = Some(message_id.to_string()); - (message, false) - } - EmulatorAction::ShellCommand(command) => { - let tool_id = Uuid::new_v4().to_string(); - let mut args = serde_json::Map::new(); - args.insert("command".to_string(), json!(command)); - let tool_call = - CallToolRequestParams::new(Cow::Borrowed(SHELL_TOOL)).with_arguments(args); - let mut message = Message::assistant(); - message - .content - .push(MessageContent::tool_request(tool_id, Ok(tool_call))); - message.id = Some(message_id.to_string()); - (message, true) - } - EmulatorAction::ExecuteCode(code) => { - let tool_id = Uuid::new_v4().to_string(); - let wrapped = if code.contains("async function run()") { - code.clone() - } else { - format!("async function run() {{\n{}\n}}", code) - }; - let mut args = serde_json::Map::new(); - args.insert("code".to_string(), json!(wrapped)); - let tool_call = - CallToolRequestParams::new(Cow::Borrowed(CODE_EXECUTION_TOOL)).with_arguments(args); - let mut message = Message::assistant(); - message - .content - .push(MessageContent::tool_request(tool_id, Ok(tool_call))); - message.id = Some(message_id.to_string()); - (message, true) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn parse_chunks(chunks: &[&str], code_mode: bool) -> Vec { - let mut parser = StreamingEmulatorParser::new(code_mode); - let mut actions = Vec::new(); - for chunk in chunks { - actions.extend(parser.process_chunk(chunk)); - } - actions.extend(parser.flush()); - actions - } - - fn parse_all(input: &str, code_mode: bool) -> Vec { - parse_chunks(&[input], code_mode) - } - - fn assert_text(action: &EmulatorAction, expected: &str) { - match action { - EmulatorAction::Text(t) => assert_eq!(t.trim(), expected.trim(), "text mismatch"), - other => panic!("expected Text, got {:?}", action_label(other)), - } - } - - fn assert_shell(action: &EmulatorAction, expected: &str) { - match action { - EmulatorAction::ShellCommand(cmd) => { - assert_eq!(cmd, expected, "shell command mismatch") - } - other => panic!("expected ShellCommand, got {:?}", action_label(other)), - } - } - - fn assert_execute(action: &EmulatorAction, expected: &str) { - match action { - EmulatorAction::ExecuteCode(code) => { - assert_eq!(code.trim(), expected.trim(), "execute code mismatch") - } - other => panic!("expected ExecuteCode, got {:?}", action_label(other)), - } - } - - fn action_label(a: &EmulatorAction) -> &'static str { - match a { - EmulatorAction::Text(_) => "Text", - EmulatorAction::ShellCommand(_) => "ShellCommand", - EmulatorAction::ExecuteCode(_) => "ExecuteCode", - } - } - - #[test] - fn plain_text_no_tools() { - let actions = parse_all("Hello, world!", false); - let all_text: String = actions - .iter() - .map(|a| match a { - EmulatorAction::Text(t) => t.as_str(), - _ => panic!("expected only Text actions"), - }) - .collect(); - assert_eq!(all_text.trim(), "Hello, world!"); - } - - #[test] - fn single_shell_command() { - let actions = parse_all("$ ls -la\n", false); - assert_eq!(actions.len(), 1); - assert_shell(&actions[0], "ls -la"); - } - - #[test] - fn text_then_shell_command() { - let actions = parse_all("Let me check:\n$ ls -la\n", false); - assert!(actions.len() >= 2); - assert_text(&actions[0], "Let me check:"); - assert_shell(&actions[actions.len() - 1], "ls -la"); - } - - #[test] - fn shell_command_at_start_of_output() { - let actions = parse_all("$ whoami\n", false); - assert_eq!(actions.len(), 1); - assert_shell(&actions[0], "whoami"); - } - - #[test] - fn shell_command_without_trailing_newline() { - let actions = parse_all("$ whoami", false); - assert_eq!(actions.len(), 1); - assert_shell(&actions[0], "whoami"); - } - - #[test] - fn dollar_sign_mid_sentence_is_not_command() { - let actions = parse_all("It costs $50 per month", false); - for action in &actions { - assert!(matches!(action, EmulatorAction::Text(_))); - } - let all_text: String = actions - .iter() - .filter_map(|a| match a { - EmulatorAction::Text(t) => Some(t.as_str()), - _ => None, - }) - .collect(); - assert_eq!(all_text.trim(), "It costs $50 per month"); - } - - #[test] - fn execute_block() { - let input = "Here's the code:\n```execute_typescript\nconsole.log('hi');\n```\n"; - let actions = parse_all(input, true); - assert!(actions.len() >= 2); - assert_text(&actions[0], "Here's the code:"); - assert_execute(&actions[actions.len() - 1], "console.log('hi');"); - } - - #[test] - fn tool_description_uses_parser_execute_fence() { - let description = build_emulator_tool_description(&[], true); - - assert!(description.contains("```execute_typescript")); - assert!(!description.contains("```execute block")); - assert!(!description.contains("Use ```execute for tool calls")); - } - - #[test] - fn execute_block_not_detected_without_code_mode() { - let input = "```execute_typescript\nconsole.log('hi');\n```\n"; - let actions = parse_all(input, false); - for action in &actions { - assert!(matches!(action, EmulatorAction::Text(_))); - } - } - - #[test] - fn dollar_split_across_chunks() { - let actions = parse_chunks(&["Let me check\n", "$ ls -la\n"], false); - let shells: Vec<_> = actions - .iter() - .filter(|a| matches!(a, EmulatorAction::ShellCommand(_))) - .collect(); - assert_eq!(shells.len(), 1); - assert_shell(shells[0], "ls -la"); - } - - #[test] - fn execute_fence_split_across_chunks() { - let actions = parse_chunks( - &["Here:\n```ex", "ecute_typescript\nlet x = 1;\n", "```\n"], - true, - ); - let executes: Vec<_> = actions - .iter() - .filter(|a| matches!(a, EmulatorAction::ExecuteCode(_))) - .collect(); - assert_eq!(executes.len(), 1); - assert_execute(executes[0], "let x = 1;"); - } - - #[test] - fn multiple_commands_on_separate_lines() { - let actions = parse_chunks(&["Here:\n$ cd /tmp\n", "Done.\n$ ls\n"], false); - let shells: Vec<_> = actions - .iter() - .filter(|a| matches!(a, EmulatorAction::ShellCommand(_))) - .collect(); - assert_eq!(shells.len(), 2); - assert_shell(shells[0], "cd /tmp"); - assert_shell(shells[1], "ls"); - } - - #[test] - fn regular_code_fence_not_treated_as_execute() { - let input = "```python\nprint('hi')\n```\n"; - let actions = parse_all(input, true); - for action in &actions { - assert!(matches!(action, EmulatorAction::Text(_))); - } - } - - #[test] - fn empty_command_ignored() { - let actions = parse_all("$\n", false); - let shells: Vec<_> = actions - .iter() - .filter(|a| matches!(a, EmulatorAction::ShellCommand(_))) - .collect(); - assert_eq!(shells.len(), 0); - } - - #[test] - fn token_by_token_streaming() { - let input = "$ echo hello\n"; - let chars: Vec = input.chars().map(|c| c.to_string()).collect(); - let chunks: Vec<&str> = chars.iter().map(|s| s.as_str()).collect(); - let actions = parse_chunks(&chunks, false); - let shells: Vec<_> = actions - .iter() - .filter(|a| matches!(a, EmulatorAction::ShellCommand(_))) - .collect(); - assert_eq!(shells.len(), 1); - assert_shell(shells[0], "echo hello"); - } - - #[test] - fn execute_block_with_multiline_code() { - let input = "```execute_typescript\nasync function run() {\n const r = await Developer.shell({ command: \"ls\" });\n return r;\n}\n```\n"; - let actions = parse_all(input, true); - let executes: Vec<_> = actions - .iter() - .filter(|a| matches!(a, EmulatorAction::ExecuteCode(_))) - .collect(); - assert_eq!(executes.len(), 1); - match executes[0] { - EmulatorAction::ExecuteCode(code) => { - assert!(code.contains("async function run()")); - assert!(code.contains("Developer.shell")); - } - _ => unreachable!(), - } - } - - #[test] - fn unclosed_execute_block_flushed() { - let input = "```execute_typescript\nlet x = 1;"; - let actions = parse_all(input, true); - let executes: Vec<_> = actions - .iter() - .filter(|a| matches!(a, EmulatorAction::ExecuteCode(_))) - .collect(); - assert_eq!(executes.len(), 1); - assert_execute(executes[0], "let x = 1;"); - } -} diff --git a/crates/goose-mcp/Cargo.toml b/crates/goose-mcp/Cargo.toml index 7aa7c2631d..610e9986b6 100644 --- a/crates/goose-mcp/Cargo.toml +++ b/crates/goose-mcp/Cargo.toml @@ -35,9 +35,9 @@ etcetera = { workspace = true } tempfile = { workspace = true } include_dir = { workspace = true } once_cell = { workspace = true } -lopdf = { version = "0.42", default-features = false } +lopdf = { version = "0.40", default-features = false } docx-rs = { version = "0.4.18", default-features = false, features = ["image"] } image = { version = "0.24.4", default-features = false, features = ["bmp", "dds", "dxt", "farbfeld", "gif", "hdr", "ico", "jpeg", "jpeg_rayon", "openexr", "png", "pnm", "tga", "tiff", "webp"] } -umya-spreadsheet = { version = "3", default-features = false } +umya-spreadsheet = { version = "2", default-features = false } shell-words = { workspace = true } process-wrap = { version = "9", default-features = false, features = ["std"] } diff --git a/crates/goose-mcp/src/autovisualiser/mod.rs b/crates/goose-mcp/src/autovisualiser/mod.rs index 1b78ff67d9..a2906bcce3 100644 --- a/crates/goose-mcp/src/autovisualiser/mod.rs +++ b/crates/goose-mcp/src/autovisualiser/mod.rs @@ -116,25 +116,6 @@ fn validation_err(msg: impl Into) -> ErrorData { ErrorData::new(ErrorCode::INVALID_PARAMS, msg.into(), None) } -/// Accepts `data` either as a JSON value or as a JSON-encoded string, -/// since models sometimes emit complex tool parameters double-encoded. -fn lenient_data<'de, D, T>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, - T: serde::de::DeserializeOwned, -{ - let value = Value::deserialize(deserializer)?; - let value = match value { - Value::String(s) => serde_json::from_str(&s).map_err(|e| { - serde::de::Error::custom(format!( - "the 'data' parameter was a JSON-encoded string that could not be parsed as JSON ({e}); provide 'data' as a JSON object, not a string" - )) - })?, - other => other, - }; - T::deserialize(value).map_err(serde::de::Error::custom) -} - /// Sankey node structure #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct SankeyNode { @@ -203,7 +184,6 @@ impl SankeyData { #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct RenderSankeyParams { /// The data for the Sankey diagram - #[serde(deserialize_with = "lenient_data")] pub data: SankeyData, } @@ -252,7 +232,6 @@ impl RadarData { #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct RenderRadarParams { /// The data for the radar chart - #[serde(deserialize_with = "lenient_data")] pub data: RadarData, } @@ -330,7 +309,6 @@ fn validate_donut_charts(charts: &[SingleDonutChart]) -> Result<(), ErrorData> { #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct RenderDonutParams { /// The chart data as an array of chart objects. Use a single-element array for one chart. - #[serde(deserialize_with = "lenient_data")] pub data: Vec, } @@ -372,7 +350,6 @@ impl TreemapNode { #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct RenderTreemapParams { /// The hierarchical data for the treemap - #[serde(deserialize_with = "lenient_data")] pub data: TreemapNode, } @@ -416,7 +393,6 @@ impl ChordData { #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct RenderChordParams { /// The data for the chord diagram - #[serde(deserialize_with = "lenient_data")] pub data: ChordData, } @@ -517,7 +493,6 @@ impl MapData { #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct RenderMapParams { /// The data for the map visualization - #[serde(deserialize_with = "lenient_data")] pub data: MapData, } @@ -633,7 +608,6 @@ impl ChartData { #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct ShowChartParams { /// The data for the chart - #[serde(deserialize_with = "lenient_data")] pub data: ChartData, } @@ -2111,102 +2085,3 @@ mod validation_tests { assert!(err.message.contains("longitude")); } } - -#[cfg(test)] -mod lenient_data_tests { - use super::*; - use serde_json::json; - - #[test] - fn show_chart_accepts_string_encoded_data() { - let input = json!({ - "data": "{\"type\": \"bar\", \"labels\": [\"A\", \"B\"], \"datasets\": [{\"label\": \"S\", \"data\": [1, 2]}]}" - }); - let parsed: ShowChartParams = serde_json::from_value(input).unwrap(); - assert_eq!(parsed.data.datasets.len(), 1); - } - - #[test] - fn show_chart_still_accepts_object_data() { - let input = json!({ - "data": {"type": "bar", "labels": ["A", "B"], "datasets": [{"label": "S", "data": [1, 2]}]} - }); - let parsed: ShowChartParams = serde_json::from_value(input).unwrap(); - assert_eq!(parsed.data.datasets.len(), 1); - } - - #[test] - fn donut_accepts_string_encoded_array_data() { - let input = json!({ - "data": "[{\"values\": [10, 20], \"labels\": [\"A\", \"B\"]}]" - }); - let parsed: RenderDonutParams = serde_json::from_value(input).unwrap(); - assert_eq!(parsed.data.len(), 1); - } - - #[test] - fn sankey_accepts_string_encoded_data() { - let input = json!({ - "data": "{\"nodes\": [{\"name\": \"A\"}, {\"name\": \"B\"}], \"links\": [{\"source\": \"A\", \"target\": \"B\", \"value\": 1.0}]}" - }); - let parsed: RenderSankeyParams = serde_json::from_value(input).unwrap(); - assert_eq!(parsed.data.nodes.len(), 2); - } - - #[test] - fn radar_accepts_string_encoded_data() { - let input = json!({ - "data": "{\"labels\": [\"X\", \"Y\"], \"datasets\": [{\"label\": \"S\", \"data\": [1.0, 2.0]}]}" - }); - let parsed: RenderRadarParams = serde_json::from_value(input).unwrap(); - assert_eq!(parsed.data.labels.len(), 2); - } - - #[test] - fn treemap_accepts_string_encoded_data() { - let input = json!({ - "data": "{\"name\": \"root\", \"children\": [{\"name\": \"leaf\", \"value\": 5.0}]}" - }); - let parsed: RenderTreemapParams = serde_json::from_value(input).unwrap(); - assert_eq!(parsed.data.name, "root"); - } - - #[test] - fn chord_accepts_string_encoded_data() { - let input = json!({ - "data": "{\"labels\": [\"A\", \"B\"], \"matrix\": [[0.0, 1.0], [1.0, 0.0]]}" - }); - let parsed: RenderChordParams = serde_json::from_value(input).unwrap(); - assert_eq!(parsed.data.matrix.len(), 2); - } - - #[test] - fn map_accepts_string_encoded_data() { - let input = json!({ - "data": "{\"markers\": [{\"lat\": 1.0, \"lng\": 2.0}]}" - }); - let parsed: RenderMapParams = serde_json::from_value(input).unwrap(); - assert_eq!(parsed.data.markers.len(), 1); - } - - #[test] - fn invalid_json_string_gives_clear_error() { - let input = json!({"data": "not valid json"}); - let err = serde_json::from_value::(input).unwrap_err(); - assert!(err.to_string().contains("could not be parsed as JSON")); - } - - #[test] - fn string_encoded_data_with_wrong_shape_reports_field_error() { - let input = json!({"data": "{\"nope\": 1}"}); - let err = serde_json::from_value::(input).unwrap_err(); - assert!(err.to_string().contains("type")); - } - - #[test] - fn schema_still_reflects_struct_type() { - let schema = serde_json::to_value(rmcp::schemars::schema_for!(ShowChartParams)).unwrap(); - let data_schema = &schema["properties"]["data"]; - assert_ne!(data_schema["type"], json!("string")); - } -} diff --git a/crates/goose-mcp/src/computercontroller/pdf_tool.rs b/crates/goose-mcp/src/computercontroller/pdf_tool.rs index 6d92cc96d1..defbd5fdcb 100644 --- a/crates/goose-mcp/src/computercontroller/pdf_tool.rs +++ b/crates/goose-mcp/src/computercontroller/pdf_tool.rs @@ -84,11 +84,11 @@ pub async fn pdf_tool( last_was_text = false; } } - Object::Real(offset) - if *offset < -100.0 => - { - text.push(' '); - last_was_text = false; + Object::Real(offset) => { + if *offset < -100.0 { + text.push(' '); + last_was_text = false; + } } _ => {} } diff --git a/crates/goose-mcp/src/computercontroller/xlsx_tool.rs b/crates/goose-mcp/src/computercontroller/xlsx_tool.rs index 4e7b794155..f588dee07f 100644 --- a/crates/goose-mcp/src/computercontroller/xlsx_tool.rs +++ b/crates/goose-mcp/src/computercontroller/xlsx_tool.rs @@ -1,7 +1,7 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::Path; -use umya_spreadsheet::{structs::Workbook, Worksheet}; +use umya_spreadsheet::{Spreadsheet, Worksheet}; #[derive(Debug, Serialize, Deserialize)] pub struct WorksheetInfo { @@ -28,7 +28,7 @@ pub struct RangeData { } pub struct XlsxTool { - workbook: Workbook, + workbook: Spreadsheet, } impl XlsxTool { @@ -40,10 +40,10 @@ impl XlsxTool { pub fn list_worksheets(&self) -> Result> { let mut worksheets = Vec::new(); - for (index, worksheet) in self.workbook.sheet_collection().iter().enumerate() { + for (index, worksheet) in self.workbook.get_sheet_collection().iter().enumerate() { let (column_count, row_count) = self.get_worksheet_dimensions(worksheet)?; worksheets.push(WorksheetInfo { - name: worksheet.name().to_string(), + name: worksheet.get_name().to_string(), index, column_count, row_count, @@ -54,13 +54,13 @@ impl XlsxTool { pub fn get_worksheet_by_name(&self, name: &str) -> Result<&Worksheet> { self.workbook - .sheet_by_name(name) + .get_sheet_by_name(name) .context("Worksheet not found") } pub fn get_worksheet_by_index(&self, index: usize) -> Result<&Worksheet> { self.workbook - .sheet_collection() + .get_sheet_collection() .get(index) .context("Worksheet index out of bounds") } @@ -71,12 +71,12 @@ impl XlsxTool { let mut max_row = 0; // Iterate through all rows - for row_num in 1..=worksheet.highest_row() { - for col_num in 1..=worksheet.highest_column() { - if let Some(cell) = worksheet.cell((col_num, row_num)) { - let coord = cell.coordinate(); - max_col = max_col.max(coord.col_num() as usize); - max_row = max_row.max(coord.row_num() as usize); + for row_num in 1..=worksheet.get_highest_row() { + for col_num in 1..=worksheet.get_highest_column() { + if let Some(cell) = worksheet.get_cell((col_num, row_num)) { + let coord = cell.get_coordinate(); + max_col = max_col.max(*coord.get_col_num() as usize); + max_row = max_row.max(*coord.get_row_num() as usize); } } } @@ -86,9 +86,9 @@ impl XlsxTool { pub fn get_column_names(&self, worksheet: &Worksheet) -> Result> { let mut names = Vec::new(); - for col_num in 1..=worksheet.highest_column() { - if let Some(cell) = worksheet.cell((col_num, 1)) { - names.push(cell.value().into_owned()); + for col_num in 1..=worksheet.get_highest_column() { + if let Some(cell) = worksheet.get_cell((col_num, 1)) { + names.push(cell.get_value().into_owned()); } else { names.push(String::new()); } @@ -104,13 +104,13 @@ impl XlsxTool { for row_idx in start_row..=end_row { let mut row_values = Vec::new(); for col_idx in start_col..=end_col { - let cell_value = if let Some(cell) = worksheet.cell((col_idx, row_idx)) { + let cell_value = if let Some(cell) = worksheet.get_cell((col_idx, row_idx)) { CellValue { - value: cell.value().into_owned(), - formula: if cell.formula().is_empty() { + value: cell.get_value().into_owned(), + formula: if cell.get_formula().is_empty() { None } else { - Some(cell.formula().to_string()) + Some(cell.get_formula().to_string()) }, } } else { @@ -142,10 +142,12 @@ impl XlsxTool { ) -> Result<()> { let worksheet = self .workbook - .sheet_by_name_mut(worksheet_name) + .get_sheet_by_name_mut(worksheet_name) .context("Worksheet not found")?; - worksheet.cell_mut((col, row)).set_value(value.to_string()); + worksheet + .get_cell_mut((col, row)) + .set_value(value.to_string()); Ok(()) } @@ -169,18 +171,18 @@ impl XlsxTool { search_text.to_string() }; - for row_num in 1..=worksheet.highest_row() { - for col_num in 1..=worksheet.highest_column() { - if let Some(cell) = worksheet.cell((col_num, row_num)) { + for row_num in 1..=worksheet.get_highest_row() { + for col_num in 1..=worksheet.get_highest_column() { + if let Some(cell) = worksheet.get_cell((col_num, row_num)) { let cell_value = if !case_sensitive { - cell.value().to_lowercase() + cell.get_value().to_lowercase() } else { - cell.value().to_string() + cell.get_value().to_string() }; if cell_value.contains(&search_text) { - let coord = cell.coordinate(); - matches.push((coord.row_num(), coord.col_num())); + let coord = cell.get_coordinate(); + matches.push((*coord.get_row_num(), *coord.get_col_num())); } } } @@ -190,14 +192,14 @@ impl XlsxTool { } pub fn get_cell_value(&self, worksheet: &Worksheet, row: u32, col: u32) -> Result { - let cell = worksheet.cell((col, row)).context("Cell not found")?; + let cell = worksheet.get_cell((col, row)).context("Cell not found")?; Ok(CellValue { - value: cell.value().into_owned(), - formula: if cell.formula().is_empty() { + value: cell.get_value().into_owned(), + formula: if cell.get_formula().is_empty() { None } else { - Some(cell.formula().to_string()) + Some(cell.get_formula().to_string()) }, }) } diff --git a/crates/goose-mcp/src/memory/mod.rs b/crates/goose-mcp/src/memory/mod.rs index b0f086874a..715a9229f5 100644 --- a/crates/goose-mcp/src/memory/mod.rs +++ b/crates/goose-mcp/src/memory/mod.rs @@ -194,7 +194,7 @@ impl MemoryServer { let category_memories = self.retrieve(&category, is_global, working_dir)?; memories.insert( category, - category_memories.into_values().flatten().collect(), + category_memories.into_iter().flat_map(|(_, v)| v).collect(), ); } } diff --git a/crates/goose-provider-types/Cargo.toml b/crates/goose-provider-types/Cargo.toml deleted file mode 100644 index 0e651492b2..0000000000 --- a/crates/goose-provider-types/Cargo.toml +++ /dev/null @@ -1,41 +0,0 @@ -[package] -name = "goose-provider-types" -version = "0.1.0-alpha.0" -edition.workspace = true -rust-version.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -description.workspace = true - -[lints] -workspace = true - -[dependencies] -anyhow = { workspace = true } -async-stream = { workspace = true } -async-trait = { workspace = true } -base64 = { workspace = true } -chrono = { workspace = true } -futures = { workspace = true } -once_cell = { workspace = true } -rand = { workspace = true } -regex = { workspace = true, features = ["unicode"] } -reqwest = { workspace = true } -rmcp = { workspace = true, features = ["server", "macros"] } -serde = { workspace = true } -serde_json = { workspace = true } -strum = { workspace = true } -thiserror = { workspace = true } -tokio = { workspace = true, features = ["time"] } -tracing = { workspace = true } -unicode-normalization = { version = "0.1.22", default-features = false, features = ["std"] } -utoipa = { workspace = true, features = ["chrono"] } -uuid = { workspace = true, features = ["v4", "std"] } - -[dev-dependencies] -env-lock = { workspace = true } -tempfile = { workspace = true } -test-case = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread"] } -tokio-stream = { workspace = true } diff --git a/crates/goose-provider-types/src/base.rs b/crates/goose-provider-types/src/base.rs deleted file mode 100644 index 357215fdaa..0000000000 --- a/crates/goose-provider-types/src/base.rs +++ /dev/null @@ -1,717 +0,0 @@ -use async_trait::async_trait; -use futures::Stream; -use rmcp::model::Tool; -use serde::{Deserialize, Serialize}; -use std::pin::Pin; -use utoipa::ToSchema; - -use crate::{ - canonical::{map_to_canonical_model, CanonicalModelRegistry}, - conversation::{ - message::{Message, MessageContent}, - token_usage::{ProviderUsage, Usage}, - }, - errors::ProviderError, - goose_mode::GooseMode, - model::ModelConfig, - permission::PermissionConfirmation, - retry::RetryConfig, -}; - -/// Metadata about a provider's configuration requirements and capabilities -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct ProviderMetadata { - /// The unique identifier for this provider - pub name: String, - /// Display name for the provider in UIs - pub display_name: String, - /// Description of the provider's capabilities - pub description: String, - /// The default/recommended model for this provider - pub default_model: String, - /// A list of currently known models with their capabilities - pub known_models: Vec, - /// Link to the docs where models can be found - pub model_doc_link: String, - /// Required configuration keys - pub config_keys: Vec, - /// 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, - /// The name of a fast/cheap model to use for lightweight tasks (e.g. session naming, - /// compaction). When set, fast-path callers prefer this model over the main model. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fast_model: Option, -} - -impl ProviderMetadata { - pub fn new( - name: &str, - display_name: &str, - description: &str, - default_model: &str, - model_names: Vec<&str>, - model_doc_link: &str, - config_keys: Vec, - ) -> Self { - Self { - name: name.to_string(), - display_name: display_name.to_string(), - description: description.to_string(), - default_model: default_model.to_string(), - known_models: model_names - .iter() - .map(|&model_name| model_info_for_provider_model(name, model_name)) - .collect(), - model_doc_link: model_doc_link.to_string(), - config_keys, - setup_steps: vec![], - model_selection_hint: None, - fast_model: None, - } - } - - pub fn with_models( - name: &str, - display_name: &str, - description: &str, - default_model: &str, - models: Vec, - model_doc_link: &str, - config_keys: Vec, - ) -> Self { - Self { - name: name.to_string(), - display_name: display_name.to_string(), - description: description.to_string(), - default_model: default_model.to_string(), - known_models: models, - model_doc_link: model_doc_link.to_string(), - config_keys, - setup_steps: vec![], - model_selection_hint: None, - fast_model: None, - } - } - - pub fn empty() -> Self { - Self { - name: "".to_string(), - display_name: "".to_string(), - description: "".to_string(), - default_model: "".to_string(), - known_models: vec![], - model_doc_link: "".to_string(), - config_keys: vec![], - setup_steps: vec![], - model_selection_hint: None, - fast_model: None, - } - } - - pub fn with_setup_steps(mut self, steps: Vec<&str>) -> Self { - 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 - } - - pub fn with_fast_model(mut self, fast_model: &str) -> Self { - self.fast_model = Some(fast_model.to_string()); - self - } -} - -/// Configuration key metadata for provider setup -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct ConfigKey { - /// The name of the configuration key (e.g., "API_KEY") - pub name: String, - /// Whether this key is required for the provider to function - pub required: bool, - /// Whether this key should be stored securely (e.g., in keychain) - pub secret: bool, - /// Optional default value for the key - pub default: Option, - /// Whether this key should be configured using an OAuth flow - /// When true, the provider's configure_oauth() method will be called instead of prompting for manual input - pub oauth_flow: bool, - /// Whether this OAuth flow uses the device code grant (RFC 8628) - /// When true, the user must enter a verification code in the browser - #[serde(default)] - pub device_code_flow: bool, - /// Whether this key should be shown prominently during provider setup - /// (onboarding, settings modal, CLI configure) - #[serde(default)] - pub primary: bool, -} - -impl ConfigKey { - /// Create a new ConfigKey - pub fn new( - name: &str, - required: bool, - secret: bool, - default: Option<&str>, - primary: bool, - ) -> Self { - Self { - name: name.to_string(), - required, - secret, - default: default.map(|s| s.to_string()), - oauth_flow: false, - device_code_flow: false, - primary, - } - } - - /// Create a new ConfigKey that uses an OAuth flow for configuration - /// - /// This is used for providers that support OAuth authentication instead of manual API key entry. - /// When oauth_flow is true, the configuration system will call the provider's configure_oauth() method. - pub fn new_oauth( - name: &str, - required: bool, - secret: bool, - default: Option<&str>, - primary: bool, - ) -> Self { - Self { - name: name.to_string(), - required, - secret, - default: default.map(|s| s.to_string()), - oauth_flow: true, - device_code_flow: false, - primary, - } - } - - /// Create a new ConfigKey that uses OAuth device code flow (RFC 8628) for configuration - /// - /// Similar to new_oauth, but indicates the provider uses the device code grant where the user - /// must enter a verification code in the browser. - pub fn new_oauth_device_code( - name: &str, - required: bool, - secret: bool, - default: Option<&str>, - primary: bool, - ) -> Self { - Self { - name: name.to_string(), - required, - secret, - default: default.map(|s| s.to_string()), - oauth_flow: true, - device_code_flow: true, - primary, - } - } -} - -/// Information about a model's capabilities -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)] -pub struct ModelInfo { - /// The name of the model - pub name: String, - /// The underlying model resolved from provider metadata, when the configured model is an alias or endpoint. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub resolved_model: Option, - /// The maximum context length this model supports - pub context_limit: usize, - /// Cost per token for input in USD (optional) - pub input_token_cost: Option, - /// Cost per token for output in USD (optional) - pub output_token_cost: Option, - /// Currency for the costs (default: "$") - pub currency: Option, - /// Whether this model supports cache control - pub supports_cache_control: Option, - /// Whether this model supports reasoning/thinking controls - #[serde(default)] - pub reasoning: bool, -} - -impl ModelInfo { - /// Create a new ModelInfo with just name and context limit - pub fn new(name: impl Into, context_limit: usize) -> Self { - Self { - name: name.into(), - resolved_model: None, - context_limit, - input_token_cost: None, - output_token_cost: None, - currency: None, - supports_cache_control: None, - reasoning: false, - } - } - - /// Create a new ModelInfo with cost information (per token) - pub fn with_cost( - name: impl Into, - context_limit: usize, - input_cost: f64, - output_cost: f64, - ) -> Self { - Self { - name: name.into(), - resolved_model: None, - context_limit, - input_token_cost: Some(input_cost), - output_token_cost: Some(output_cost), - currency: Some("$".to_string()), - supports_cache_control: None, - reasoning: false, - } - } -} - -pub trait ProviderDescriptor { - fn metadata() -> ProviderMetadata; -} - -/// A message stream yields partial text content but complete tool calls, all within the Message object -/// So a message with text will contain potentially just a word of a longer response, but tool calls -/// messages will only be yielded once concatenated. -pub type MessageStream = Pin< - Box, Option), ProviderError>> + Send>, ->; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum PermissionRouting { - ActionRequired, - Noop, -} - -pub fn model_info_for_provider_model(provider_name: &str, model_name: &str) -> ModelInfo { - let registry = CanonicalModelRegistry::bundled().ok(); - let canonical = registry.as_ref().and_then(|registry| { - let canonical_id = map_to_canonical_model(provider_name, model_name, registry)?; - let (provider, model) = canonical_id.split_once('/')?; - registry.get(provider, model) - }); - - let reasoning = canonical - .as_ref() - .and_then(|model| model.reasoning) - .unwrap_or_else(|| ModelConfig::new(model_name).is_reasoning_model()); - - ModelInfo { - name: model_name.to_string(), - resolved_model: None, - context_limit: ModelConfig::new(model_name) - .with_canonical_limits(provider_name) - .context_limit(), - input_token_cost: None, - output_token_cost: None, - currency: None, - supports_cache_control: None, - reasoning, - } -} - -/// Collect all chunks from a MessageStream into a single Message and ProviderUsage -pub async fn collect_stream( - mut stream: MessageStream, -) -> Result<(Message, ProviderUsage), ProviderError> { - use futures::StreamExt; - - let mut final_message: Option = None; - let mut final_usage: Option = None; - - while let Some(result) = stream.next().await { - let (msg_opt, usage_opt) = result?; - - if let Some(msg) = msg_opt { - final_message = Some(match final_message { - Some(mut prev) => { - for new_content in msg.content { - match (&mut prev.content.last_mut(), &new_content) { - // Coalesce consecutive text blocks - ( - Some(MessageContent::Text(last_text)), - MessageContent::Text(new_text), - ) => { - last_text.text.push_str(&new_text.text); - } - _ => { - prev.content.push(new_content); - } - } - } - prev - } - None => msg, - }); - } - - if let Some(usage) = usage_opt { - final_usage = Some(usage); - } - } - - match final_message { - Some(msg) => { - let usage = final_usage - .unwrap_or_else(|| ProviderUsage::new("unknown".to_string(), Usage::default())); - Ok((msg, usage)) - } - None => Err(ProviderError::ExecutionError( - "Stream yielded no message".to_string(), - )), - } -} - -pub fn stream_from_single_message(message: Message, usage: ProviderUsage) -> MessageStream { - let stream = futures::stream::once(async move { Ok((Some(message), Some(usage))) }); - Box::pin(stream) -} - -/// Base trait for AI providers (OpenAI, Anthropic, etc) -#[async_trait] -pub trait Provider: Send + Sync { - /// Get the name of this provider instance - fn get_name(&self) -> &str; - - /// Primary streaming method that all providers must implement. - async fn stream( - &self, - model_config: &ModelConfig, - system: &str, - messages: &[Message], - tools: &[Tool], - ) -> Result; - - async fn complete( - &self, - model_config: &ModelConfig, - system: &str, - messages: &[Message], - tools: &[Tool], - ) -> Result<(Message, ProviderUsage), ProviderError> { - let stream = self.stream(model_config, system, messages, tools).await?; - collect_stream(stream).await - } - - /// Resolve the effective context limit for a model config. - /// - /// Providers may override this to enrich the limit with provider-specific - /// metadata (e.g. cached model info or a value captured from a remote - /// session). The default returns the limit derived from the model config. - async fn get_context_limit(&self, model_config: &ModelConfig) -> Result { - Ok(model_config.context_limit()) - } - - fn retry_config(&self) -> RetryConfig { - RetryConfig::default() - } - - async fn fetch_supported_models(&self) -> Result, ProviderError> { - Ok(vec![]) - } - - async fn fetch_supported_model_info(&self) -> Result, ProviderError> { - Ok(self - .fetch_supported_models() - .await? - .iter() - .map(|model_name| model_info_for_provider_model(self.get_name(), model_name)) - .collect()) - } - - async fn fetch_model_info(&self, model_name: &str) -> Result { - Ok(model_info_for_provider_model(self.get_name(), model_name)) - } - - fn skip_canonical_filtering(&self) -> bool { - false - } - - /// Fetch inventory models filtered by canonical registry and usability. - /// - /// When `toolshim` is true, models that lack native tool-call support are - /// retained because the toolshim layer emulates tool calling. - async fn fetch_recommended_models(&self, toolshim: bool) -> Result, ProviderError> { - let all_models = self.fetch_supported_models().await?; - - if self.skip_canonical_filtering() { - return Ok(all_models); - } - - let registry = CanonicalModelRegistry::bundled().map_err(|e| { - ProviderError::ExecutionError(format!("Failed to load canonical registry: {}", e)) - })?; - - let provider_name = self.get_name(); - - // Get all text-capable models with their release dates - let mut models_with_dates: Vec<(String, Option)> = all_models - .iter() - .filter_map(|model| { - let canonical_id = map_to_canonical_model(provider_name, model, registry)?; - - let (provider, model_name) = canonical_id.split_once('/')?; - let canonical_model = registry.get(provider, model_name)?; - - if !canonical_model - .modalities - .input - .contains(&crate::canonical::Modality::Text) - { - return None; - } - - if !canonical_model.tool_call && !toolshim { - return None; - } - - let release_date = canonical_model.release_date.clone(); - - Some((model.clone(), release_date)) - }) - .collect(); - - // Sort by release date (most recent first), then alphabetically for models without dates - models_with_dates.sort_by(|a, b| match (&a.1, &b.1) { - (Some(date_a), Some(date_b)) => date_b.cmp(date_a), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => a.0.cmp(&b.0), - }); - - let inventory_models: Vec = models_with_dates - .into_iter() - .map(|(name, _)| name) - .collect(); - - if inventory_models.is_empty() { - Ok(all_models) - } else { - Ok(inventory_models) - } - } - - async fn fetch_recommended_model_info( - &self, - toolshim: bool, - ) -> Result, ProviderError> { - Ok(self - .fetch_recommended_models(toolshim) - .await? - .iter() - .map(|model_name| model_info_for_provider_model(self.get_name(), model_name)) - .collect()) - } - - async fn map_to_canonical_model( - &self, - provider_model: &str, - ) -> Result, ProviderError> { - let registry = CanonicalModelRegistry::bundled().map_err(|e| { - ProviderError::ExecutionError(format!("Failed to load canonical registry: {}", e)) - })?; - - Ok(map_to_canonical_model( - self.get_name(), - provider_model, - registry, - )) - } - - /// Whether the provider manages its own conversation context (e.g. CLI - /// wrappers like Claude Code or Gemini CLI). When true, goose-side - /// context management such as tool-pair summarization is skipped because - /// the provider's internal state is the source of truth. - fn manages_own_context(&self) -> bool { - false - } - - /// Configure OAuth authentication for this provider - /// - /// This method is called when a provider has configuration keys marked with oauth_flow = true. - /// Providers that support OAuth should override this method to implement their specific OAuth flow. - /// - /// # Returns - /// * `Ok(())` if OAuth configuration succeeds and credentials are saved - /// * `Err(ProviderError)` if OAuth fails or is not supported by this provider - /// - /// # Default Implementation - /// The default implementation returns an error indicating OAuth is not supported. - async fn configure_oauth(&self) -> Result<(), ProviderError> { - Err(ProviderError::ExecutionError( - "OAuth configuration not supported by this provider".to_string(), - )) - } - - async fn refresh_credentials(&self) -> Result<(), ProviderError> { - Err(ProviderError::NotImplemented( - "credential refresh not supported by this provider".to_string(), - )) - } - - async fn update_mode(&self, _session_id: &str, _mode: GooseMode) -> Result<(), ProviderError> { - Ok(()) - } - - fn permission_routing(&self) -> PermissionRouting { - PermissionRouting::Noop - } - - async fn handle_permission_confirmation( - &self, - _request_id: &str, - _confirmation: &PermissionConfirmation, - ) -> bool { - false - } -} - -#[cfg(test)] -mod tests { - use super::*; - use test_case::test_case; - - fn content_from_str(s: String) -> MessageContent { - if let Some(img_data) = s.strip_prefix("*img:") { - MessageContent::image(format!("http://example.com/{}", img_data), "image/png") - } else if let Some(tool_name) = s.strip_prefix("*tool:") { - let tool_call = Ok( - rmcp::model::CallToolRequestParams::new(tool_name.to_string()) - .with_arguments(serde_json::Map::new()), - ); - MessageContent::tool_request(format!("tool_{}", tool_name), tool_call) - } else { - MessageContent::text(s) - } - } - - fn create_test_stream( - items: Vec, - ) -> impl Stream, Option), ProviderError>> { - use futures::stream; - stream::iter(items.into_iter().map(|item| { - let content = content_from_str(item); - let message = Message::new( - rmcp::model::Role::Assistant, - chrono::Utc::now().timestamp(), - vec![content], - ); - Ok((Some(message), None)) - })) - } - - fn content_to_strings(msg: &Message) -> Vec { - msg.content - .iter() - .map(|c| match c { - MessageContent::Text(t) => t.text.clone(), - MessageContent::Image(_) => "*img".to_string(), - MessageContent::ToolRequest(tr) => { - if let Ok(call) = &tr.tool_call { - format!("*tool:{}", call.name) - } else { - "*tool:error".to_string() - } - } - _ => "*other".to_string(), - }) - .collect() - } - - #[test_case( - vec!["Hello", " ", "world"], - vec!["Hello world"] - ; "consecutive text coalesces" - )] - #[test_case( - vec!["Hello", "*img:pic1", "world"], - vec!["Hello", "*img", "world"] - ; "non-text breaks coalescing" - )] - #[test_case( - vec!["A", "B", "*img:pic1", "C", "D", "*tool:read", "E", "F"], - vec!["AB", "*img", "CD", "*tool:read", "EF"] - ; "multiple text groups" - )] - #[test_case( - vec!["Text1", "*img:pic", "Text2"], - vec!["Text1", "*img", "Text2"] - ; "mixed content in chunk" - )] - #[tokio::test] - async fn test_collect_stream_coalescing(input_items: Vec<&str>, expected: Vec<&str>) { - let items: Vec = input_items.into_iter().map(|s| s.to_string()).collect(); - let stream = create_test_stream(items); - let (msg, _) = collect_stream(Box::pin(stream)).await.unwrap(); - assert_eq!(content_to_strings(&msg), expected); - } - - #[tokio::test] - async fn test_collect_stream_defaults_usage() { - let stream = create_test_stream(vec!["Hello".to_string()]); - let (msg, usage) = collect_stream(Box::pin(stream)).await.unwrap(); - assert_eq!(content_to_strings(&msg), vec!["Hello"]); - assert_eq!(usage.model, "unknown"); - } - - #[test] - fn test_model_info_creation() { - // Test direct ModelInfo creation - let info = ModelInfo { - name: "test-model".to_string(), - resolved_model: None, - context_limit: 1000, - input_token_cost: None, - output_token_cost: None, - currency: None, - supports_cache_control: None, - reasoning: false, - }; - assert_eq!(info.context_limit, 1000); - - // Test equality - let info2 = ModelInfo { - name: "test-model".to_string(), - resolved_model: None, - context_limit: 1000, - input_token_cost: None, - output_token_cost: None, - currency: None, - supports_cache_control: None, - reasoning: false, - }; - assert_eq!(info, info2); - - // Test inequality - let info3 = ModelInfo { - name: "test-model".to_string(), - resolved_model: None, - context_limit: 2000, - input_token_cost: None, - output_token_cost: None, - currency: None, - supports_cache_control: None, - reasoning: false, - }; - assert_ne!(info, info3); - } - - #[test] - fn test_model_info_with_cost() { - let info = ModelInfo::with_cost("gpt-4o", 128000, 0.0000025, 0.00001); - assert_eq!(info.name, "gpt-4o"); - assert_eq!(info.context_limit, 128000); - assert_eq!(info.input_token_cost, Some(0.0000025)); - assert_eq!(info.output_token_cost, Some(0.00001)); - assert_eq!(info.currency, Some("$".to_string())); - } -} diff --git a/crates/goose-provider-types/src/conversation/token_usage.rs b/crates/goose-provider-types/src/conversation/token_usage.rs deleted file mode 100644 index 4fa2db5c52..0000000000 --- a/crates/goose-provider-types/src/conversation/token_usage.rs +++ /dev/null @@ -1,227 +0,0 @@ -use std::ops::{Add, AddAssign}; - -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProviderUsage { - pub model: String, - pub usage: Usage, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub stats: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cost: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cost_source: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "snake_case")] -pub enum CostSource { - ProviderReported, - Estimated, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ProviderStats { - pub time_to_first_token_ms: Option, - pub model_load_ms: Option, - pub elapsed_ms: Option, - pub output_tokens: Option, - pub draft: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct DraftStats { - pub model: Option, - pub draft_tokens: usize, - pub accepted_tokens: usize, - pub target_tokens: usize, - pub rounds: usize, - pub accept_rate: f64, -} - -impl ProviderUsage { - pub fn new(model: String, usage: Usage) -> Self { - Self { - model, - usage, - stats: None, - cost: None, - cost_source: None, - } - } - - pub fn with_stats(mut self, stats: ProviderStats) -> Self { - self.stats = Some(stats); - self - } - - pub fn with_cost(mut self, cost: f64, source: CostSource) -> Self { - self.cost = Some(cost); - self.cost_source = Some(source); - self - } -} - -/// `input_tokens` is the total input including cache read/write tokens; -/// the cache fields are breakdown subsets of it. Parsers for providers -/// that report cache tokens separately from input (e.g. Anthropic, -/// Bedrock) must fold them into `input_tokens`. -#[derive(Debug, Clone, Serialize, Deserialize, Default, Copy, PartialEq, Eq, ToSchema)] -pub struct Usage { - /// All prompt tokens, including any served from or written to cache. - /// `cache_read_input_tokens` and `cache_write_input_tokens` are subsets of this. - pub input_tokens: Option, - pub output_tokens: Option, - pub total_tokens: Option, - pub cache_read_input_tokens: Option, - pub cache_write_input_tokens: Option, -} - -fn sum_optionals(a: Option, b: Option) -> Option -where - T: Add, -{ - match (a, b) { - (Some(x), Some(y)) => Some(x + y), - (Some(x), None) => Some(x), - (None, Some(y)) => Some(y), - (None, None) => None, - } -} - -impl Add for Usage { - type Output = Self; - - fn add(self, other: Self) -> Self { - Self::new( - sum_optionals(self.input_tokens, other.input_tokens), - sum_optionals(self.output_tokens, other.output_tokens), - sum_optionals(self.total_tokens, other.total_tokens), - ) - .with_cache_tokens( - sum_optionals(self.cache_read_input_tokens, other.cache_read_input_tokens), - sum_optionals( - self.cache_write_input_tokens, - other.cache_write_input_tokens, - ), - ) - } -} - -impl AddAssign for Usage { - fn add_assign(&mut self, rhs: Self) { - *self = *self + rhs; - } -} - -impl Usage { - pub fn new( - input_tokens: Option, - output_tokens: Option, - total_tokens: Option, - ) -> Self { - let calculated_total = if total_tokens.is_none() { - match (input_tokens, output_tokens) { - (Some(input), Some(output)) => Some(input.saturating_add(output)), - (Some(input), None) => Some(input), - (None, Some(output)) => Some(output), - (None, None) => None, - } - } else { - total_tokens - }; - - Self { - input_tokens, - output_tokens, - total_tokens: calculated_total, - cache_read_input_tokens: None, - cache_write_input_tokens: None, - } - } - - pub fn with_cache_tokens( - mut self, - cache_read_input_tokens: Option, - cache_write_input_tokens: Option, - ) -> Self { - self.cache_read_input_tokens = cache_read_input_tokens; - self.cache_write_input_tokens = cache_write_input_tokens; - self - } - - /// For providers whose reported `input_tokens`/`total_tokens` exclude - /// cache tokens (e.g. Anthropic, Bedrock): folds the cache breakdown in. - pub fn from_cache_exclusive_input( - input_tokens: Option, - output_tokens: Option, - total_tokens: Option, - cache_read_input_tokens: Option, - cache_write_input_tokens: Option, - ) -> Self { - let cache_tokens = cache_read_input_tokens - .unwrap_or(0) - .saturating_add(cache_write_input_tokens.unwrap_or(0)); - Self::new( - input_tokens.map(|v| v.saturating_add(cache_tokens)), - output_tokens, - total_tokens.map(|v| v.saturating_add(cache_tokens)), - ) - .with_cache_tokens(cache_read_input_tokens, cache_write_input_tokens) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use anyhow::Result; - use serde_json::json; - - #[test] - fn test_usage_serialization() -> Result<()> { - let usage = Usage::new(Some(10), Some(20), Some(30)); - let serialized = serde_json::to_string(&usage)?; - let deserialized: Usage = serde_json::from_str(&serialized)?; - - assert_eq!(usage.input_tokens, deserialized.input_tokens); - assert_eq!(usage.output_tokens, deserialized.output_tokens); - assert_eq!(usage.total_tokens, deserialized.total_tokens); - - // Test JSON structure - let json_value: serde_json::Value = serde_json::from_str(&serialized)?; - assert_eq!(json_value["input_tokens"], json!(10)); - assert_eq!(json_value["output_tokens"], json!(20)); - assert_eq!(json_value["total_tokens"], json!(30)); - - Ok(()) - } - - #[test] - fn test_from_cache_exclusive_input_folds_cache_into_input_and_total() { - let usage = - Usage::from_cache_exclusive_input(Some(10), Some(50), Some(60), Some(5000), Some(1000)); - - assert_eq!(usage.input_tokens, Some(6010)); - assert_eq!(usage.output_tokens, Some(50)); - assert_eq!(usage.total_tokens, Some(6060)); - assert_eq!(usage.cache_read_input_tokens, Some(5000)); - assert_eq!(usage.cache_write_input_tokens, Some(1000)); - } - - #[test] - fn test_usage_addition_includes_cached_tokens() { - let usage_a = - Usage::new(Some(100), Some(20), Some(120)).with_cache_tokens(Some(10), Some(5)); - let usage_b = Usage::new(Some(50), Some(8), Some(58)).with_cache_tokens(Some(4), Some(1)); - - let combined = usage_a + usage_b; - - assert_eq!(combined.input_tokens, Some(150)); - assert_eq!(combined.output_tokens, Some(28)); - assert_eq!(combined.total_tokens, Some(178)); - assert_eq!(combined.cache_read_input_tokens, Some(14)); - assert_eq!(combined.cache_write_input_tokens, Some(6)); - } -} diff --git a/crates/goose-provider-types/src/formats.rs b/crates/goose-provider-types/src/formats.rs deleted file mode 100644 index 03f7590961..0000000000 --- a/crates/goose-provider-types/src/formats.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub mod anthropic; -pub mod databricks; -pub mod google; -pub mod ollama; -pub mod openai; -pub mod openai_responses; - -pub mod snowflake; diff --git a/crates/goose-provider-types/src/images.rs b/crates/goose-provider-types/src/images.rs deleted file mode 100644 index 5c27bd710b..0000000000 --- a/crates/goose-provider-types/src/images.rs +++ /dev/null @@ -1,512 +0,0 @@ -use std::{borrow::Cow, io::Read as _, path::Path}; - -use base64::Engine as _; -use rmcp::model::{AnnotateAble as _, ImageContent, RawImageContent}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; - -use crate::errors::ProviderError; - -#[derive(Debug, Copy, Clone, Serialize, Deserialize)] -pub enum ImageFormat { - OpenAi, - Anthropic, -} - -/// Convert an image content into an image json based on format -pub fn convert_image(image: &ImageContent, image_format: &ImageFormat) -> Value { - match image_format { - ImageFormat::OpenAi => json!({ - "type": "image_url", - "image_url": { - "url": format!("data:{};base64,{}", image.mime_type, image.data) - } - }), - ImageFormat::Anthropic => json!({ - "type": "image", - "source": { - "type": "base64", - "media_type": image.mime_type, - "data": image.data, - } - }), - } -} - -pub fn detect_image_path(text: &str) -> Option> { - const EXTENSIONS: [&str; 3] = [".png", ".jpg", ".jpeg"]; - const MAX_PATH_LEN: usize = 4096; - - let mut best: Option<(usize, Cow<'_, str>)> = None; - let mut from = 0; - while from < text.len() { - let Some(end) = EXTENSIONS - .iter() - .filter_map(|ext| find_ascii_ci(text, ext, from).map(|i| i + ext.len())) - .min() - else { - break; - }; - - let terminator = text.get(end..).and_then(|rest| rest.chars().next()); - let terminated = terminator.is_none_or(is_path_terminator); - - if terminated { - let mut floor = end.saturating_sub(MAX_PATH_LEN); - while floor < end && !text.is_char_boundary(floor) { - floor += 1; - } - if let Some(window) = text.get(floor..end) { - for (rel, _) in window.match_indices('/') { - let start = floor + rel; - let preceded_by_boundary = text - .get(..start) - .and_then(|prefix| prefix.chars().next_back()) - .is_none_or(is_path_leading_boundary); - if !preceded_by_boundary { - continue; - } - let Some(candidate) = text.get(start..end) else { - continue; - }; - if let Some(candidate_path) = image_path_candidate(candidate) { - // Keep the first referenced path, but allow a longer - // match anchored at the same start to extend it (a - // whitespace-terminated extension may be a prefix of a - // spaced filename ending in a later extension). - match best { - Some((best_start, _)) if start == best_start => { - best = Some((start, candidate_path)); - } - None => best = Some((start, candidate_path)), - Some(_) => {} - } - break; - } - } - } - } - from = end; - } - best.map(|(_, candidate)| candidate) -} - -fn clean_path(path: &str) -> Cow<'_, str> { - if !path.contains('\\') { - return Cow::Borrowed(path); - } - - let mut cleaned = String::with_capacity(path.len()); - let mut chars = path.chars().peekable(); - let mut changed = false; - - while let Some(c) = chars.next() { - if c == '\\' { - if let Some(&next) = chars.peek() { - if !next.is_alphanumeric() { - cleaned.push(next); - chars.next(); - changed = true; - continue; - } - } - } - cleaned.push(c); - } - - if changed { - Cow::Owned(cleaned) - } else { - Cow::Borrowed(path) - } -} - -fn image_path_candidate(candidate: &str) -> Option> { - if is_existing_image_path(candidate) { - return Some(Cow::Borrowed(candidate)); - } - - let cleaned = clean_path(candidate); - if cleaned.as_ref() != candidate && is_existing_image_path(cleaned.as_ref()) { - return Some(cleaned); - } - - None -} - -fn is_existing_image_path(candidate: &str) -> bool { - let path = Path::new(candidate); - path.is_absolute() && path.is_file() && is_image_file(path) -} - -fn is_path_leading_boundary(c: char) -> bool { - c.is_whitespace() - || matches!( - c, - '"' | '\'' | '\u{00AB}' | '\u{00BB}' | '\u{2018}' - ..='\u{201F}' | '\u{2039}' | '\u{203A}' - ) -} - -fn is_path_terminator(c: char) -> bool { - c == '/' - || c.is_whitespace() - || matches!( - c, - '"' | '\'' | '\u{00AB}' | '\u{00BB}' | '\u{2013}' - ..='\u{201F}' | '\u{2026}' | '\u{2039}' | '\u{203A}' - ) - || ('\u{2300}'..='\u{23FF}').contains(&c) - || ('\u{2600}'..='\u{27BF}').contains(&c) - || ('\u{2B00}'..='\u{2BFF}').contains(&c) - || ('\u{1F1E6}'..='\u{1F1FF}').contains(&c) - || ('\u{1F300}'..='\u{1FAFF}').contains(&c) -} - -/// Case-insensitive ASCII substring search returning a byte index into -/// `haystack` (no allocation, so the index stays valid for slicing). -fn find_ascii_ci(haystack: &str, needle: &str, from: usize) -> Option { - let (hb, nb) = (haystack.as_bytes(), needle.as_bytes()); - if nb.is_empty() || hb.len() < nb.len() || from > hb.len() - nb.len() { - return None; - } - (from..=hb.len() - nb.len()).find(|&i| { - hb[i..i + nb.len()] - .iter() - .zip(nb) - .all(|(a, b)| a.eq_ignore_ascii_case(b)) - }) -} - -/// Check if a file is actually an image by examining its magic bytes -fn is_image_file(path: &Path) -> bool { - if let Ok(mut file) = std::fs::File::open(path) { - let mut buffer = [0u8; 8]; // Large enough for most image magic numbers - if file.read(&mut buffer).is_ok() { - // Check magic numbers for common image formats - return match &buffer[0..4] { - // PNG: 89 50 4E 47 - [0x89, 0x50, 0x4E, 0x47] => true, - // JPEG: FF D8 FF - [0xFF, 0xD8, 0xFF, _] => true, - // GIF: 47 49 46 38 - [0x47, 0x49, 0x46, 0x38] => true, - _ => false, - }; - } - } - false -} - -/// Convert a local image file to base64 encoded ImageContent -pub fn load_image_file(path: &str) -> Result { - let path = Path::new(path); - - // Verify it's an image before proceeding - if !is_image_file(path) { - return Err(ProviderError::RequestFailed( - "File is not a valid image".to_string(), - )); - } - - // Read the file - let bytes = std::fs::read(path) - .map_err(|e| ProviderError::RequestFailed(format!("Failed to read image file: {}", e)))?; - - // Detect mime type from extension - let mime_type = match path.extension().and_then(|e| e.to_str()) { - Some(ext) => match ext.to_lowercase().as_str() { - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - _ => { - return Err(ProviderError::RequestFailed( - "Unsupported image format".to_string(), - )) - } - }, - None => { - return Err(ProviderError::RequestFailed( - "Unknown image format".to_string(), - )) - } - }; - - // Convert to base64 - let data = base64::prelude::BASE64_STANDARD.encode(&bytes); - - Ok(RawImageContent { - mime_type: mime_type.to_string(), - data, - meta: None, - } - .no_annotation()) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile; - - #[test] - fn test_detect_image_path() { - // Create a temporary PNG file with valid PNG magic numbers - let temp_dir = tempfile::tempdir().unwrap(); - let png_path = temp_dir.path().join("test.png"); - let png_data = [ - 0x89, 0x50, 0x4E, 0x47, // PNG magic number - 0x0D, 0x0A, 0x1A, 0x0A, // PNG header - 0x00, 0x00, 0x00, 0x0D, // Rest of fake PNG data - ]; - std::fs::write(&png_path, png_data).unwrap(); - let png_path_str = png_path.to_str().unwrap(); - - // Create a fake PNG (wrong magic numbers) - let fake_png_path = temp_dir.path().join("fake.png"); - std::fs::write(&fake_png_path, b"not a real png").unwrap(); - - // Test with valid PNG file using absolute path - let text = format!("Here is an image {}", png_path_str); - assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); - - // Test with non-image file that has .png extension - let text = format!("Here is a fake image {}", fake_png_path.to_str().unwrap()); - assert_eq!(detect_image_path(&text).as_deref(), None); - - // Test with nonexistent file - let text = "Here is a fake.png that doesn't exist"; - assert_eq!(detect_image_path(text).as_deref(), None); - - // Test with non-image file - let text = "Here is a file.txt"; - assert_eq!(detect_image_path(text).as_deref(), None); - - // Test with relative path (should not match) - let text = "Here is a relative/path/image.png"; - assert_eq!(detect_image_path(text).as_deref(), None); - } - - #[test] - fn test_detect_image_path_with_spaces() { - // Absolute path containing spaces (macOS screenshot style). - let temp_dir = tempfile::tempdir().unwrap(); - let png_path = temp_dir.path().join("Screen Shot 2026.png"); - let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; - std::fs::write(&png_path, png_data).unwrap(); - let png_path_str = png_path.to_str().unwrap(); - - let text = format!("please describe {} for me", png_path_str); - assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); - - // Case-insensitive extension also matches. - let upper = temp_dir.path().join("Another Shot.PNG"); - std::fs::write(&upper, png_data).unwrap(); - let upper_str = upper.to_str().unwrap(); - let text = format!("see {}", upper_str); - assert_eq!(detect_image_path(&text).as_deref(), Some(upper_str)); - - // Quoted path with spaces: the closing quote terminates the candidate. - let text = format!("describe \"{}\" please", png_path_str); - assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); - let text = format!("describe '{}'", png_path_str); - assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); - let text = format!("describe โ€œ{}โ€ please", png_path_str); - assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); - let text = format!("describe ยซ{}ยป please", png_path_str); - assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); - - // A stray closing quote in prose must not act as a terminator for an - // unquoted path. - let text = format!("here {}\" trailing", png_path_str); - assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); - - // When a spaced filename contains an earlier image extension, prefer - // the longer existing candidate over the embedded prefix. - let edited = temp_dir.path().join("Screen Shot.png edited.jpg"); - std::fs::write(&edited, png_data).unwrap(); - let edited_str = edited.to_str().unwrap(); - let prefix = temp_dir.path().join("Screen Shot.png"); - std::fs::write(&prefix, png_data).unwrap(); - let text = format!("look at {}", edited_str); - assert_eq!(detect_image_path(&text).as_deref(), Some(edited_str)); - - // With multiple distinct images, the first referenced one wins even if - // a later one has a longer path. - let a = temp_dir.path().join("a.png"); - std::fs::write(&a, png_data).unwrap(); - let longer = temp_dir.path().join("much-longer.png"); - std::fs::write(&longer, png_data).unwrap(); - let text = format!( - "compare {} with {}", - a.to_str().unwrap(), - longer.to_str().unwrap() - ); - assert_eq!( - detect_image_path(&text).as_deref(), - Some(a.to_str().unwrap()) - ); - } - - #[test] - fn test_detect_image_path_with_shell_escaped_metacharacters() { - let temp_dir = tempfile::tempdir().unwrap(); - let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; - let png_path = temp_dir - .path() - .join("Bob's Project (v2) & $draft [final].png"); - std::fs::write(&png_path, png_data).unwrap(); - let png_path_str = png_path.to_str().unwrap(); - - let escaped_path = png_path_str - .replace(' ', "\\ ") - .replace('(', "\\(") - .replace(')', "\\)") - .replace('&', "\\&") - .replace('$', "\\$") - .replace('\'', "\\'") - .replace('[', "\\[") - .replace(']', "\\]"); - let text = format!("please describe {}", escaped_path); - - assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); - } - - #[test] - fn test_detect_image_path_prefers_existing_literal_backslash_path() { - let temp_dir = tempfile::tempdir().unwrap(); - let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; - let png_path = temp_dir.path().join("literal\\&name.png"); - std::fs::write(&png_path, png_data).unwrap(); - let png_path_str = png_path.to_str().unwrap(); - let text = format!("please describe {}", png_path_str); - - assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); - } - - #[test] - fn test_detect_image_path_with_unicode_separators() { - let temp_dir = tempfile::tempdir().unwrap(); - let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; - let png_path = temp_dir.path().join("photo.png"); - std::fs::write(&png_path, png_data).unwrap(); - let png_path_str = png_path.to_str().unwrap(); - - assert_eq!( - detect_image_path(&format!("{png_path_str}๐Ÿ™‚")).as_deref(), - Some(png_path_str) - ); - assert_eq!( - detect_image_path(&format!("{png_path_str}๐Ÿ‡บ๐Ÿ‡ธ")).as_deref(), - Some(png_path_str) - ); - assert_eq!( - detect_image_path(&format!("{png_path_str}โŒš")).as_deref(), - Some(png_path_str) - ); - assert_eq!( - detect_image_path(&format!("{png_path_str}โญ")).as_deref(), - Some(png_path_str) - ); - assert_eq!( - detect_image_path(&format!("{png_path_str}โ€ฆ more text")).as_deref(), - Some(png_path_str) - ); - assert_eq!( - detect_image_path(&format!("{png_path_str}\u{2014}more text")).as_deref(), - Some(png_path_str) - ); - - assert_eq!( - detect_image_path(&format!("{png_path_str}\u{200B}.backup")).as_deref(), - None - ); - assert_eq!( - detect_image_path(&format!("{png_path_str}\u{0301}")).as_deref(), - None - ); - assert_eq!( - detect_image_path(&format!("file:{png_path_str}")).as_deref(), - None - ); - } - - #[test] - fn test_detect_image_path_ignores_urls_and_longer_extensions() { - let temp_dir = tempfile::tempdir().unwrap(); - let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; - - // A real image whose path is a suffix of a URL must not be extracted - // from that URL via the `://` separator. - let dir = temp_dir.path().to_str().unwrap().trim_start_matches('/'); - let png_path = temp_dir.path().join("photo.png"); - std::fs::write(&png_path, png_data).unwrap(); - let url = format!("https:/{}/photo.png", dir); - assert_eq!(detect_image_path(&url).as_deref(), None); - - // A backup file sharing the image extension prefix must not be - // truncated to the bare image path. - let real = temp_dir.path().join("shot.png"); - std::fs::write(&real, png_data).unwrap(); - let backup = format!("{}.backup", real.to_str().unwrap()); - assert_eq!(detect_image_path(&backup).as_deref(), None); - } - - #[test] - fn test_detect_image_path_ignores_extension_flood() { - // Many extension-like tokens but no real absolute path: must scan - // cheaply (bounded) and find nothing. - let text = "see foo.png and bar.jpg and baz.jpeg ".repeat(500); - assert_eq!(detect_image_path(&text).as_deref(), None); - } - - #[test] - fn test_load_image_file() { - // Create a temporary PNG file with valid PNG magic numbers - let temp_dir = tempfile::tempdir().unwrap(); - let png_path = temp_dir.path().join("test.png"); - let png_data = [ - 0x89, 0x50, 0x4E, 0x47, // PNG magic number - 0x0D, 0x0A, 0x1A, 0x0A, // PNG header - 0x00, 0x00, 0x00, 0x0D, // Rest of fake PNG data - ]; - std::fs::write(&png_path, png_data).unwrap(); - let png_path_str = png_path.to_str().unwrap(); - - // Create a fake PNG (wrong magic numbers) - let fake_png_path = temp_dir.path().join("fake.png"); - std::fs::write(&fake_png_path, b"not a real png").unwrap(); - let fake_png_path_str = fake_png_path.to_str().unwrap(); - - // Test loading valid PNG file - let result = load_image_file(png_path_str); - assert!(result.is_ok()); - let image = result.unwrap(); - assert_eq!(image.mime_type, "image/png"); - - // Test loading fake PNG file - let result = load_image_file(fake_png_path_str); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("not a valid image")); - - // Test nonexistent file - let result = load_image_file("nonexistent.png"); - assert!(result.is_err()); - - // Create a GIF file with valid header bytes - let gif_path = temp_dir.path().join("test.gif"); - // Minimal GIF89a header - let gif_data = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]; - std::fs::write(&gif_path, gif_data).unwrap(); - let gif_path_str = gif_path.to_str().unwrap(); - - // Test loading unsupported GIF format - let result = load_image_file(gif_path_str); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Unsupported image format")); - } -} diff --git a/crates/goose-provider-types/src/json.rs b/crates/goose-provider-types/src/json.rs deleted file mode 100644 index 93ba4cc31d..0000000000 --- a/crates/goose-provider-types/src/json.rs +++ /dev/null @@ -1,559 +0,0 @@ -/// Safely parse a JSON string that may contain doubly-encoded or malformed JSON. -/// This function first attempts to parse the input string as-is. If that fails, -/// it applies control character escaping and truncated JSON repair and tries again. -/// -/// This approach preserves valid JSON like `{"key1": "value1",\n"key2": "value"}` -/// (which contains a literal \n but is perfectly valid JSON) while still fixing -/// broken JSON like `{"key1": "value1\n","key2": "value"}` (which contains an -/// unescaped newline character). -pub fn safely_parse_json(s: &str) -> Result { - // First, try parsing the string as-is - match serde_json::from_str(s) { - Ok(value) => Ok(value), - Err(_) => { - for candidate in [ - repair_truncated_json(s), - json_escape_control_chars_in_string(s), - ] { - if let Ok(value) = serde_json::from_str(&candidate) { - return Ok(value); - } - } - - let repaired = repair_truncated_json(&json_escape_control_chars_in_string(s)); - serde_json::from_str(&repaired) - } - } -} - -fn repair_truncated_json(s: &str) -> String { - let mut repaired = String::with_capacity(s.len() + 8); - let mut in_string = false; - let mut escape_next = false; - let mut closers = Vec::new(); - - for c in s.chars() { - repaired.push(c); - - if in_string { - if escape_next { - escape_next = false; - continue; - } - - match c { - '\\' => escape_next = true, - '"' => in_string = false, - _ => {} - } - continue; - } - - match c { - '"' => in_string = true, - '{' => closers.push('}'), - '[' => closers.push(']'), - '}' | ']' if closers.last() == Some(&c) => { - closers.pop(); - } - _ => {} - } - } - - if in_string { - if escape_next { - repaired.push('\\'); - } - repaired.push('"'); - } - - while let Some(closer) = closers.pop() { - repaired.push(closer); - } - - repaired -} - -/// Helper to escape control characters in a string that is supposed to be a JSON document. -/// This function iterates through the input string `s` and replaces any literal -/// control characters (U+0000 to U+001F) with their JSON-escaped equivalents -/// (e.g., '\n' becomes "\\n", '\u0001' becomes "\\u0001"). -/// -/// It does NOT escape quotes (") or backslashes (\) because it assumes `s` is a -/// full JSON document, and these characters might be structural (e.g., object delimiters, -/// existing valid escape sequences). The goal is to fix common LLM errors where -/// control characters are emitted raw into what should be JSON string values, -/// making the overall JSON structure unparsable. -/// -/// If the input string `s` has other JSON syntax errors (e.g., an unescaped quote -/// *within* a string value like `{"key": "string with " quote"}`), this function -/// will not fix them. It specifically targets unescaped control characters. -pub fn json_escape_control_chars_in_string(s: &str) -> String { - let mut r = String::with_capacity(s.len()); // Pre-allocate for efficiency - for c in s.chars() { - match c { - // ASCII Control characters (U+0000 to U+001F) - '\u{0000}'..='\u{001F}' => { - match c { - '\u{0008}' => r.push_str("\\b"), // Backspace - '\u{000C}' => r.push_str("\\f"), // Form feed - '\n' => r.push_str("\\n"), // Line feed - '\r' => r.push_str("\\r"), // Carriage return - '\t' => r.push_str("\\t"), // Tab - // Other control characters (e.g., NUL, SOH, VT, etc.) - // that don't have a specific short escape sequence. - _ => { - r.push_str(&format!("\\u{:04x}", c as u32)); - } - } - } - // Other characters are passed through. - // This includes quotes (") and backslashes (\). If these are part of the - // JSON structure (e.g. {"key": "value"}) or part of an already correctly - // escaped sequence within a string value (e.g. "string with \\\" quote"), - // they are preserved as is. This function does not attempt to fix - // malformed quote or backslash usage *within* string values if the LLM - // generates them incorrectly (e.g. {"key": "unescaped " quote in string"}). - _ => r.push(c), - } - } - r -} - -/// Detect whether a raw tool-arguments string looks truncated (the model hit -/// its output-token limit mid-JSON). Returns true when the string has -/// unbalanced or unclosed structural delimiters โ€” whether the cut-off happened -/// mid-value (e.g. `{"path":"/a` with no closing quote) or after a nested -/// closer but before the outer object closed (e.g. `{"items":[1,2]` where the -/// outer `{` is still open). -pub fn looks_truncated(args: &str) -> bool { - let trimmed = args.trim_end(); - if trimmed.is_empty() { - return false; - } - - let mut in_string = false; - let mut escape_next = false; - let mut depth = Vec::new(); - - for c in trimmed.chars() { - if in_string { - if escape_next { - escape_next = false; - } else if c == '\\' { - escape_next = true; - } else if c == '"' { - in_string = false; - } - continue; - } - - match c { - '"' => in_string = true, - '{' => depth.push('}'), - '[' => depth.push(']'), - '}' | ']' => { - if depth.last() == Some(&c) { - depth.pop(); - } else { - return true; - } - } - _ => {} - } - } - - in_string || escape_next || !depth.is_empty() -} - -/// Build an actionable error message for tool arguments that could not be -/// parsed. `args` is the raw, accumulated arguments string from the provider. -/// -/// The message distinguishes truncation (likely from the output token limit) -/// from other malformation, and includes a snippet of where parsing broke. -pub fn truncation_error_message(args: &str) -> Option { - if args.is_empty() { - return None; - } - - if serde_json::from_str::(args).is_ok() { - return None; - } - - let trimmed = args.trim_end(); - let is_truncated = looks_truncated(trimmed); - - let snippet = { - let len = trimmed.chars().count(); - if len > 80 { - let s: String = trimmed - .chars() - .rev() - .take(80) - .collect::>() - .into_iter() - .rev() - .collect(); - format!("โ€ฆ{s}") - } else { - trimmed.to_string() - } - }; - - let guidance = if is_truncated { - "The model's response was truncated โ€” it hit the output token limit while generating this tool call. \ - Try increasing max_tokens for this provider or breaking the task into smaller steps." - } else { - "The model produced malformed tool arguments. Try resending your message or breaking the task into smaller steps." - }; - - Some(format!( - "{guidance}\nReceived {} characters; cut off at: {snippet}", - trimmed.chars().count() - )) -} - -fn strip_json_wrapper(args: &str) -> Option<&str> { - let trimmed = args.trim(); - - if let Some(rest) = trimmed.strip_prefix("```") { - let body = rest.strip_suffix("```")?.trim_start(); - let body = body - .strip_prefix("json") - .or_else(|| body.strip_prefix("JSON")) - .unwrap_or(body); - return Some(body.trim()); - } - - if trimmed.starts_with('<') && !trimmed.starts_with("')?; - let tag_name = trimmed.get(1..open_end)?.split_whitespace().next()?; - if tag_name.is_empty() - || !tag_name - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') - { - return None; - } - let closing = format!(""); - let body = trimmed - .get(open_end + 1..)? - .trim_end() - .strip_suffix(&closing)?; - return Some(body.trim()); - } - - None -} - -fn unwrap_double_encoded_object(value: serde_json::Value) -> serde_json::Value { - let serde_json::Value::String(s) = &value else { - return value; - }; - - let mut current = s.trim().to_string(); - for _ in 0..3 { - match serde_json::from_str::(¤t) { - Ok(inner @ serde_json::Value::Object(_)) => return inner, - Ok(serde_json::Value::String(inner)) => current = inner.trim().to_string(), - _ => break, - } - } - - value -} - -/// Parse tool-call arguments, returning `None` when the input looks truncated -/// so callers can surface an actionable error rather than invoking a tool with -/// incomplete arguments. Non-truncated malformation (e.g. unescaped control -/// characters some models emit) is still repaired via [`safely_parse_json`]. -pub fn parse_tool_arguments(args: &str) -> Option { - if args.is_empty() { - return Some(serde_json::Value::Object(serde_json::Map::new())); - } - - if let Ok(value) = serde_json::from_str::(args) { - return Some(unwrap_double_encoded_object(value)); - } - - if let Some(inner) = strip_json_wrapper(args) { - if let Ok(value) = serde_json::from_str::(inner) { - return Some(unwrap_double_encoded_object(value)); - } - if !looks_truncated(inner) { - if let Ok(value) = safely_parse_json(inner) { - return Some(unwrap_double_encoded_object(value)); - } - } - } - - if !looks_truncated(args) { - if let Ok(value) = safely_parse_json(args) { - return Some(unwrap_double_encoded_object(value)); - } - } - - None -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn test_safely_parse_json() { - // Test valid JSON that should parse without escaping (contains proper escape sequence) - let valid_json = r#"{"key1": "value1","key2": "value2"}"#; - let result = safely_parse_json(valid_json).unwrap(); - assert_eq!(result["key1"], "value1"); - assert_eq!(result["key2"], "value2"); - - // Test JSON with actual unescaped newlines that needs escaping - let invalid_json = "{\"key1\": \"value1\n\",\"key2\": \"value2\"}"; - let result = safely_parse_json(invalid_json).unwrap(); - assert_eq!(result["key1"], "value1\n"); - assert_eq!(result["key2"], "value2"); - - // Test already valid JSON - should parse on first try - let good_json = r#"{"test": "value"}"#; - let result = safely_parse_json(good_json).unwrap(); - assert_eq!(result["test"], "value"); - - // Test truncated JSON with unclosed string, object, and array - let truncated_json = r#"{"key": "unclosed_string","nested": {"items": [1, 2, 3"#; - let result = safely_parse_json(truncated_json).unwrap(); - assert_eq!(result["key"], "unclosed_string"); - assert_eq!(result["nested"]["items"], json!([1, 2, 3])); - - // Test dangling backslash at end of a truncated string - let dangling_escape_json = String::from(r#"{"path":"abc\"#); - let result = safely_parse_json(&dangling_escape_json).unwrap(); - assert_eq!(result["path"], "abc\\"); - - // Test empty object - let empty_json = "{}"; - let result = safely_parse_json(empty_json).unwrap(); - assert!(result.as_object().unwrap().is_empty()); - - // Test JSON with escaped newlines (valid JSON) - should parse on first try - let escaped_json = r#"{"key": "value with\nnewline"}"#; - let result = safely_parse_json(escaped_json).unwrap(); - assert_eq!(result["key"], "value with\nnewline"); - } - - #[test] - fn test_json_escape_control_chars_in_string() { - // Test basic control character escaping - assert_eq!( - json_escape_control_chars_in_string("Hello\nWorld"), - "Hello\\nWorld" - ); - assert_eq!( - json_escape_control_chars_in_string("Hello\tWorld"), - "Hello\\tWorld" - ); - assert_eq!( - json_escape_control_chars_in_string("Hello\rWorld"), - "Hello\\rWorld" - ); - - // Test multiple control characters - assert_eq!( - json_escape_control_chars_in_string("Hello\n\tWorld\r"), - "Hello\\n\\tWorld\\r" - ); - - // Test that quotes and backslashes are preserved (not escaped) - assert_eq!( - json_escape_control_chars_in_string("Hello \"World\""), - "Hello \"World\"" - ); - assert_eq!( - json_escape_control_chars_in_string("Hello\\World"), - "Hello\\World" - ); - - // Test JSON-like string with control characters - assert_eq!( - json_escape_control_chars_in_string("{\"message\": \"Hello\nWorld\"}"), - "{\"message\": \"Hello\\nWorld\"}" - ); - - // Test no changes for normal strings - assert_eq!( - json_escape_control_chars_in_string("Hello World"), - "Hello World" - ); - - // Test other control characters get unicode escapes - assert_eq!( - json_escape_control_chars_in_string("Hello\u{0001}World"), - "Hello\\u0001World" - ); - } - - #[test] - fn test_truncation_error_message_valid_json() { - assert!(truncation_error_message(r#"{"key":"value"}"#).is_none()); - assert!(truncation_error_message(r#"{}"#).is_none()); - assert!(truncation_error_message(r#"{"a":[1,2],"b":{"c":3}}"#).is_none()); - assert!(truncation_error_message(r#"[1,2,3]"#).is_none()); - assert!(truncation_error_message(r#"{"a":{"b":"c"}}"#).is_none()); - assert!(truncation_error_message("").is_none()); - } - - #[test] - fn test_looks_truncated_nested_closers() { - // Truncated after inner array closes, but outer object still open. - assert!(looks_truncated(r#"{"items":[1,2]"#)); - // Truncated after inner object closes, but outer object still open. - assert!(looks_truncated(r#"{"patch":{"path":"x"}"#)); - // Truncated mid-string. - assert!(looks_truncated( - r##"{"path":"/report.md","content":"# cut"## - )); - // Truncated mid-key. - assert!(looks_truncated(r#"{"key":"val"#)); - - // Well-formed JSON is NOT truncated. - assert!(!looks_truncated(r#"{"key":"value"}"#)); - assert!(!looks_truncated(r#"{"a":[1,2],"b":{"c":3}}"#)); - assert!(!looks_truncated(r#"[1,2,3]"#)); - assert!(!looks_truncated(r#"{"a":{"b":"c"}}"#)); - assert!(!looks_truncated(r#"{}"#)); - assert!(!looks_truncated("")); - } - - #[test] - fn test_parse_tool_arguments_nested_closers_truncated() { - // These end with ] or } so the old check passed, but the outer object - // is still open โ€” silently repairing these would invoke tools with - // incomplete arguments. - let case1 = r#"{"items":[1,2]"#; - assert!(parse_tool_arguments(case1).is_none()); - - let case2 = r#"{"patch":{"path":"x"}"#; - assert!(parse_tool_arguments(case2).is_none()); - } - - #[test] - fn test_parse_tool_arguments_control_char_recovery() { - // Unescaped control chars (raw newline) inside a string value should - // still parse successfully via safely_parse_json fallback. - let args = "{\"key\": \"value\nwith newline\"}"; - let parsed = parse_tool_arguments(args).expect("control-char JSON should parse"); - assert_eq!(parsed["key"], "value\nwith newline"); - } - - #[test] - fn test_parse_tool_arguments_truncated_fails() { - let truncated = r##"{"path":"/report.md","content":"# Big report that got cut"##; - assert!( - parse_tool_arguments(truncated).is_none(), - "truncated JSON should NOT parse (would silently invoke tool with truncated content)" - ); - } - - #[test] - fn test_parse_tool_arguments_strict_json() { - let valid = r#"{"key":"value"}"#; - assert!(parse_tool_arguments(valid).is_some()); - assert!(parse_tool_arguments("").is_some()); - } - - #[test] - fn test_truncation_error_message_truncated() { - let truncated = r##"{"path":"/report.md","content":"# Big report that got cut"##; - let msg = - truncation_error_message(truncated).expect("truncated args should produce an error"); - assert!(msg.contains("truncated"), "msg: {msg}"); - assert!( - msg.contains("max_tokens") || msg.contains("smaller steps"), - "msg: {msg}" - ); - assert!(msg.contains("cut off at:"), "msg: {msg}"); - } - - #[test] - fn test_parse_tool_arguments_markdown_fenced() { - let fenced = "```json\n{\"command\": \"ls\"}\n```"; - let parsed = parse_tool_arguments(fenced).expect("fenced JSON should parse"); - assert_eq!(parsed["command"], "ls"); - - let fenced_no_lang = "```\n{\"command\": \"ls\"}\n```"; - let parsed = parse_tool_arguments(fenced_no_lang).expect("fenced JSON should parse"); - assert_eq!(parsed["command"], "ls"); - - let fenced_inline = "```json{\"command\": \"ls\"}```"; - let parsed = parse_tool_arguments(fenced_inline).expect("fenced JSON should parse"); - assert_eq!(parsed["command"], "ls"); - } - - #[test] - fn test_parse_tool_arguments_double_encoded() { - let double_encoded = r#""{\"command\": \"ls\"}""#; - let parsed = parse_tool_arguments(double_encoded).expect("double-encoded should parse"); - assert!(parsed.is_object(), "expected object, got {parsed:?}"); - assert_eq!(parsed["command"], "ls"); - - let twice = serde_json::to_string(&serde_json::json!(r#"{"command": "ls"}"#)).unwrap(); - let twice = serde_json::to_string(&serde_json::json!(twice)).unwrap(); - let parsed = parse_tool_arguments(&twice).expect("twice-encoded should parse"); - assert!(parsed.is_object(), "expected object, got {parsed:?}"); - assert_eq!(parsed["command"], "ls"); - } - - #[test] - fn test_parse_tool_arguments_xml_wrapped() { - let wrapped = r#"{"command": "ls"}"#; - let parsed = parse_tool_arguments(wrapped).expect("xml-wrapped JSON should parse"); - assert_eq!(parsed["command"], "ls"); - - let with_attrs = r#"{"command": "ls"}"#; - let parsed = parse_tool_arguments(with_attrs).expect("xml-wrapped JSON should parse"); - assert_eq!(parsed["command"], "ls"); - } - - #[test] - fn test_parse_tool_arguments_unrecoverable_wrappers_still_fail() { - assert!(parse_tool_arguments(r#"{"x": 1}"#).is_none()); - assert!(parse_tool_arguments("```json\n{\"x\": 1}\n``` extra").is_none()); - assert!(parse_tool_arguments(r#"{"x": 1}"#).is_none()); - assert!(parse_tool_arguments(r#""#).is_none()); - assert!(parse_tool_arguments("```").is_none()); - } - - #[test] - fn test_parse_tool_arguments_non_object_json_unchanged() { - assert_eq!(parse_tool_arguments("null"), Some(serde_json::Value::Null)); - assert_eq!(parse_tool_arguments("42"), Some(serde_json::json!(42))); - assert_eq!( - parse_tool_arguments("[1, 2]"), - Some(serde_json::json!([1, 2])) - ); - } - - #[test] - fn test_parse_tool_arguments_plain_string_preserved() { - let plain = r#""hello""#; - let parsed = parse_tool_arguments(plain).expect("valid JSON string should parse"); - assert_eq!(parsed, serde_json::json!("hello")); - } - - #[test] - fn test_parse_tool_arguments_fenced_truncated_still_fails() { - let truncated = "```json\n{\"path\": \"/report.md\", \"content\": \"# cut"; - assert!(parse_tool_arguments(truncated).is_none()); - } - - #[test] - fn test_truncation_error_message_malformed() { - // Malformed JSON that ends with } (not truncated, just broken). - // safely_parse_json should fail too, so truncation_error_message fires. - let malformed = r##"{"key": }"##; - let msg = - truncation_error_message(malformed).expect("malformed args should produce an error"); - assert!(msg.contains("malformed"), "msg: {msg}"); - } -} diff --git a/crates/goose-provider-types/src/lib.rs b/crates/goose-provider-types/src/lib.rs deleted file mode 100644 index 3ab49ef87e..0000000000 --- a/crates/goose-provider-types/src/lib.rs +++ /dev/null @@ -1,15 +0,0 @@ -pub mod base; -pub mod canonical; -pub mod conversation; -pub mod errors; -pub mod formats; -pub mod goose_mode; -pub mod images; -pub mod json; -pub(crate) mod mcp_utils; -pub mod model; -pub mod permission; -pub mod request_log; -pub mod retry; -pub mod thinking; -pub mod utils; diff --git a/crates/goose-provider-types/src/mcp_utils.rs b/crates/goose-provider-types/src/mcp_utils.rs deleted file mode 100644 index ad70764222..0000000000 --- a/crates/goose-provider-types/src/mcp_utils.rs +++ /dev/null @@ -1,105 +0,0 @@ -use base64::Engine; -use rmcp::model::ResourceContents; - -pub fn extract_text_from_resource(resource: &ResourceContents) -> String { - match resource { - ResourceContents::TextResourceContents { text, .. } => text.clone(), - ResourceContents::BlobResourceContents { - blob, mime_type, .. - } => match base64::engine::general_purpose::STANDARD.decode(blob) { - Ok(bytes) => { - let byte_len = bytes.len(); - match String::from_utf8(bytes) { - Ok(text) => text, - Err(_) => { - let mime = mime_type - .as_ref() - .map(|m| m.as_str()) - .unwrap_or("application/octet-stream"); - format!("[Binary content ({}) - {} bytes]", mime, byte_len) - } - } - } - Err(_) => blob.clone(), - }, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use test_case::test_case; - - #[test_case("Hello, World!", "Hello, World!" ; "simple text")] - #[test_case("Hello from GitHub!", "Hello from GitHub!" ; "github content")] - #[test_case("", "" ; "empty text")] - fn test_extract_text_from_text_resource(input: &str, expected: &str) { - let resource = ResourceContents::TextResourceContents { - uri: "file:///test.txt".to_string(), - mime_type: Some("text/plain".to_string()), - text: input.to_string(), - meta: None, - }; - assert_eq!(extract_text_from_resource(&resource), expected); - } - - #[test_case("Hello from GitHub!", "Hello from GitHub!" ; "utf8 markdown")] - #[test_case("Simple text", "Simple text" ; "utf8 plain")] - fn test_extract_text_from_blob_utf8(input: &str, expected: &str) { - let blob = base64::engine::general_purpose::STANDARD.encode(input.as_bytes()); - let resource = ResourceContents::BlobResourceContents { - uri: "github://repo/file.md".to_string(), - mime_type: Some("text/markdown".to_string()), - blob, - meta: None, - }; - assert_eq!(extract_text_from_resource(&resource), expected); - } - - #[test] - fn test_extract_text_from_blob_binary() { - let binary_data: Vec = vec![0xFF, 0xFE, 0x00, 0x01, 0x89, 0x50, 0x4E, 0x47]; - let blob = base64::engine::general_purpose::STANDARD.encode(&binary_data); - - let resource = ResourceContents::BlobResourceContents { - uri: "file:///image.png".to_string(), - mime_type: Some("image/png".to_string()), - blob, - meta: None, - }; - - assert_eq!( - extract_text_from_resource(&resource), - "[Binary content (image/png) - 8 bytes]" - ); - } - - #[test] - fn test_extract_text_from_blob_binary_no_mime_type() { - let binary_data: Vec = vec![0xFF, 0xFE]; - let blob = base64::engine::general_purpose::STANDARD.encode(&binary_data); - - let resource = ResourceContents::BlobResourceContents { - uri: "file:///unknown".to_string(), - mime_type: None, - blob, - meta: None, - }; - - assert_eq!( - extract_text_from_resource(&resource), - "[Binary content (application/octet-stream) - 2 bytes]" - ); - } - - #[test] - fn test_extract_text_from_blob_invalid_base64() { - let resource = ResourceContents::BlobResourceContents { - uri: "file:///test.txt".to_string(), - mime_type: Some("text/plain".to_string()), - blob: "not valid base64!!!".to_string(), - meta: None, - }; - assert_eq!(extract_text_from_resource(&resource), "not valid base64!!!"); - } -} diff --git a/crates/goose-provider-types/src/model.rs b/crates/goose-provider-types/src/model.rs deleted file mode 100644 index 341beaea99..0000000000 --- a/crates/goose-provider-types/src/model.rs +++ /dev/null @@ -1,714 +0,0 @@ -use crate::formats::openai::{extract_reasoning_effort, is_openai_responses_model}; -use crate::thinking::ThinkingEffort; -use serde::de::Deserializer; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::HashMap; -use utoipa::ToSchema; - -pub const DEFAULT_CONTEXT_LIMIT: usize = 128_000; - -/// Request param keys that describe model-family-agnostic reasoning behavior and -/// are therefore safe to carry across a model switch or subagent delegation. -/// Provider-specific keys (e.g. `anthropic_beta`) are deliberately excluded so -/// they can't bleed into a request targeting a different model family. -const INHERITED_SESSION_PARAM_KEYS: &[&str] = &[ - "thinking_effort", - "thinking_budget", - "budget_tokens", - "enable_thinking", - "preserve_thinking_context", - "preserve_unsigned_thinking", -]; - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct ModelConfig { - pub model_name: String, - pub context_limit: Option, - pub temperature: Option, - pub max_tokens: Option, - pub toolshim: bool, - pub toolshim_model: Option, - /// Provider-specific request parameters (e.g., anthropic_beta headers) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub request_params: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning: Option, -} - -impl<'de> Deserialize<'de> for ModelConfig { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - struct RawModelConfig { - model_name: String, - context_limit: Option, - temperature: Option, - max_tokens: Option, - toolshim: bool, - toolshim_model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - request_params: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - reasoning: Option, - } - - let raw = RawModelConfig::deserialize(deserializer)?; - let mut config = Self { - model_name: raw.model_name, - context_limit: raw.context_limit, - temperature: raw.temperature, - max_tokens: raw.max_tokens, - toolshim: raw.toolshim, - toolshim_model: raw.toolshim_model, - request_params: raw.request_params, - reasoning: raw.reasoning, - }; - config.normalize_effort_suffix(); - Ok(config) - } -} - -impl ModelConfig { - pub fn new(model_name: impl AsRef) -> Self { - let mut config = Self { - model_name: model_name.as_ref().to_string(), - context_limit: None, - temperature: None, - max_tokens: None, - toolshim: false, - toolshim_model: None, - request_params: None, - reasoning: None, - }; - config.normalize_effort_suffix(); - config - } - - pub fn with_canonical_limits(mut self, provider_name: &str) -> Self { - // 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::canonical::maybe_get_canonical_model(provider_name, &self.model_name).or_else( - || { - let (base, _effort) = extract_reasoning_effort(&self.model_name); - if base != self.model_name { - crate::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 - .filter(|&output| output < canonical.limit.context) - .map(|output| output as i32); - } - if self.reasoning.is_none() { - self.reasoning = canonical.reasoning; - } - } - - self - } - - pub fn with_context_limit(mut self, limit: Option) -> Self { - if limit.is_some() { - self.context_limit = limit; - } - self - } - - pub fn with_temperature(mut self, temp: Option) -> Self { - self.temperature = temp; - self - } - - pub fn with_max_tokens(mut self, tokens: Option) -> Self { - self.max_tokens = tokens; - self - } - - pub fn with_default_context_limit(mut self, limit: Option) -> Self { - if self.context_limit.is_none() { - self.context_limit = limit; - } - self - } - - pub fn with_default_max_tokens(mut self, tokens: Option) -> Self { - if self.max_tokens.is_none() { - self.max_tokens = tokens; - } - self - } - - pub fn with_toolshim(mut self, toolshim: bool) -> Self { - self.toolshim = toolshim; - self - } - - pub fn with_toolshim_model(mut self, model: Option) -> Self { - self.toolshim_model = model; - self - } - - pub fn with_merged_request_params(mut self, params: HashMap) -> Self { - match self.request_params.as_mut() { - Some(existing) => { - for (k, v) in params { - existing.insert(k, v); - } - } - None => { - self.request_params = Some(params); - } - } - self - } - - pub fn with_thinking_effort(mut self, effort: ThinkingEffort) -> Self { - let params = self.request_params.get_or_insert_with(HashMap::new); - params.insert( - "thinking_effort".to_string(), - serde_json::json!(effort.to_string()), - ); - self - } - - pub fn with_default_thinking_effort(mut self, effort: Option) -> Self { - if self.thinking_effort().is_none() { - if let Some(effort) = effort { - self = self.with_thinking_effort(effort); - } - } - self - } - - pub fn with_inherited_session_settings_from( - mut self, - previous: Option<&ModelConfig>, - request_params: Option>, - ) -> Self { - if let Some(previous_params) = previous.and_then(|p| p.request_params.as_ref()) { - for key in INHERITED_SESSION_PARAM_KEYS { - if let Some(value) = previous_params.get(*key) { - self.request_params - .get_or_insert_with(HashMap::new) - .entry(key.to_string()) - .or_insert_with(|| value.clone()); - } - } - } - - if let Some(request_params) = request_params { - self = self.with_merged_request_params(request_params); - } - - self - } - - pub fn context_limit(&self) -> usize { - self.context_limit.unwrap_or(DEFAULT_CONTEXT_LIMIT) - } - - pub fn is_openai_reasoning_model(&self) -> bool { - is_openai_responses_model(&self.model_name) - } - - pub fn is_reasoning_model(&self) -> bool { - if let Some(reasoning) = self.reasoning { - return reasoning; - } - - self.is_openai_reasoning_model() - || self.model_name.to_lowercase().contains("claude") - || Self::is_gemini3_reasoning_model_name(&self.model_name) - } - - fn is_gemini3_reasoning_model_name(model_name: &str) -> bool { - let lower = model_name.to_lowercase(); - lower.starts_with("gemini-3") || lower.contains("/gemini-3") || lower.contains("-gemini-3") - } - - pub fn max_output_tokens(&self) -> i32 { - if let Some(tokens) = self.max_tokens { - return tokens; - } - - 4_096 - } - - pub fn normalize_effort_suffix(&mut self) { - if !self.is_openai_reasoning_model() { - return; - } - let parts: Vec<&str> = self.model_name.split('-').collect(); - let last = match parts.last() { - Some(l) => *l, - None => return, - }; - let effort = match last { - "none" => ThinkingEffort::Off, - "low" => ThinkingEffort::Low, - "medium" => ThinkingEffort::Medium, - "high" => ThinkingEffort::High, - "xhigh" => ThinkingEffort::Max, - _ => return, - }; - self.model_name = parts[..parts.len() - 1].join("-"); - let has_explicit_effort = self - .request_params - .as_ref() - .and_then(|p| p.get("thinking_effort")) - .is_some(); - if !has_explicit_effort { - let params = self.request_params.get_or_insert_with(HashMap::new); - params.insert( - "thinking_effort".to_string(), - serde_json::json!(effort.to_string()), - ); - } - } - - pub fn thinking_effort(&self) -> Option { - self.request_param::("thinking_effort") - .and_then(|s| s.parse::().ok()) - } - - pub fn request_param serde::Deserialize<'de>>( - &self, - request_key: &str, - ) -> Option { - self.request_params - .as_ref() - .and_then(|params| params.get(request_key)) - .and_then(|v| serde_json::from_value(v.clone()).ok()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - mod thinking_effort_tests { - use super::*; - - fn config_with_params(model_name: &str, params: HashMap) -> ModelConfig { - ModelConfig::new(model_name).with_merged_request_params(params) - } - - #[test] - fn from_request_params() { - let mut params = HashMap::new(); - params.insert("thinking_effort".to_string(), serde_json::json!("medium")); - let config = config_with_params("test", params); - assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Medium)); - } - - #[test] - fn with_thinking_effort_sets_request_param() { - let config = ModelConfig::new("test").with_thinking_effort(ThinkingEffort::High); - - assert_eq!( - config - .request_params - .as_ref() - .and_then(|params| params.get("thinking_effort")), - Some(&serde_json::json!("high")) - ); - } - - #[test] - fn preserves_explicit_thinking_effort() { - let previous = config_with_params( - "previous", - HashMap::from([("thinking_effort".to_string(), serde_json::json!("high"))]), - ); - let config = ModelConfig::new("next") - .with_inherited_session_settings_from(Some(&previous), None); - - assert_eq!( - config - .request_params - .as_ref() - .and_then(|params| params.get("thinking_effort")), - Some(&serde_json::json!("high")) - ); - } - - #[test] - fn does_not_override_existing_thinking_effort() { - let previous = config_with_params( - "previous", - HashMap::from([("thinking_effort".to_string(), serde_json::json!("high"))]), - ); - let config = config_with_params( - "next", - HashMap::from([("thinking_effort".to_string(), serde_json::json!("low"))]), - ) - .with_inherited_session_settings_from(Some(&previous), None); - - assert_eq!( - config - .request_params - .as_ref() - .and_then(|params| params.get("thinking_effort")), - Some(&serde_json::json!("low")) - ); - } - - #[test] - fn inherits_reasoning_controls_but_not_provider_specific_params() { - let previous = config_with_params( - "previous", - HashMap::from([ - ("budget_tokens".to_string(), serde_json::json!(8192)), - ( - "preserve_thinking_context".to_string(), - serde_json::json!(true), - ), - ("anthropic_beta".to_string(), serde_json::json!("beta")), - ]), - ); - let config = ModelConfig::new("next") - .with_inherited_session_settings_from(Some(&previous), None); - - let params = config.request_params.expect("reasoning controls inherited"); - assert_eq!(params.get("budget_tokens"), Some(&serde_json::json!(8192))); - assert_eq!( - params.get("preserve_thinking_context"), - Some(&serde_json::json!(true)) - ); - assert_eq!(params.get("anthropic_beta"), None); - } - - #[test] - fn explicit_request_params_override_preserved_session_settings() { - let previous = config_with_params( - "previous", - HashMap::from([("thinking_effort".to_string(), serde_json::json!("high"))]), - ); - let config = ModelConfig::new("next").with_inherited_session_settings_from( - Some(&previous), - Some(HashMap::from([( - "thinking_effort".to_string(), - serde_json::json!("low"), - )])), - ); - - assert_eq!( - config - .request_params - .as_ref() - .and_then(|params| params.get("thinking_effort")), - Some(&serde_json::json!("low")) - ); - } - - #[test] - fn effort_suffix_stripped_from_model_name() { - let _guard = env_lock::lock_env([ - ("GOOSE_THINKING_EFFORT", None::<&str>), - ("GOOSE_MAX_TOKENS", None::<&str>), - ("GOOSE_TEMPERATURE", None::<&str>), - ("GOOSE_CONTEXT_LIMIT", None::<&str>), - ("GOOSE_TOOLSHIM", None::<&str>), - ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), - ]); - let config = ModelConfig::new("o3-mini-high"); - assert_eq!(config.model_name, "o3-mini"); - assert_eq!(config.thinking_effort(), Some(ThinkingEffort::High)); - } - - #[test] - fn none_suffix_stripped_from_model_name() { - let _guard = env_lock::lock_env([ - ("GOOSE_THINKING_EFFORT", Some("high")), - ("GOOSE_MAX_TOKENS", None::<&str>), - ("GOOSE_TEMPERATURE", None::<&str>), - ("GOOSE_CONTEXT_LIMIT", None::<&str>), - ("GOOSE_TOOLSHIM", None::<&str>), - ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), - ]); - let config = ModelConfig::new("o3-mini-none"); - assert_eq!(config.model_name, "o3-mini"); - assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Off)); - } - - #[test] - fn xhigh_suffix_stripped_from_model_name() { - let _guard = env_lock::lock_env([ - ("GOOSE_THINKING_EFFORT", Some("low")), - ("GOOSE_MAX_TOKENS", None::<&str>), - ("GOOSE_TEMPERATURE", None::<&str>), - ("GOOSE_CONTEXT_LIMIT", None::<&str>), - ("GOOSE_TOOLSHIM", None::<&str>), - ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), - ]); - let config = ModelConfig::new("gpt-5.4-xhigh"); - assert_eq!(config.model_name, "gpt-5.4"); - assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Max)); - } - - #[test] - fn effort_suffix_not_stripped_when_thinking_effort_set() { - let _guard = env_lock::lock_env([ - ("GOOSE_THINKING_EFFORT", None::<&str>), - ("GOOSE_MAX_TOKENS", None::<&str>), - ("GOOSE_TEMPERATURE", None::<&str>), - ("GOOSE_CONTEXT_LIMIT", None::<&str>), - ("GOOSE_TOOLSHIM", None::<&str>), - ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), - ]); - let mut params = HashMap::new(); - params.insert("thinking_effort".to_string(), serde_json::json!("low")); - let mut config = ModelConfig::new("o3-mini-high"); - // Suffix was already normalized during new(), but if request_params - // were set before construction, the suffix would not be stripped. - // Verify the normalized state: - assert_eq!(config.model_name, "o3-mini"); - - // Now simulate setting explicit effort after construction - config.request_params = Some(params); - assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Low)); - } - - #[test] - fn no_suffix_no_change() { - let _guard = env_lock::lock_env([ - ("GOOSE_THINKING_EFFORT", None::<&str>), - ("GOOSE_MAX_TOKENS", None::<&str>), - ("GOOSE_TEMPERATURE", None::<&str>), - ("GOOSE_CONTEXT_LIMIT", None::<&str>), - ("GOOSE_TOOLSHIM", None::<&str>), - ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), - ]); - let config = ModelConfig::new("o3-mini"); - assert_eq!(config.model_name, "o3-mini"); - } - - #[test] - fn non_reasoning_model_suffix_not_stripped() { - let _guard = env_lock::lock_env([ - ("GOOSE_THINKING_EFFORT", None::<&str>), - ("GOOSE_MAX_TOKENS", None::<&str>), - ("GOOSE_TEMPERATURE", None::<&str>), - ("GOOSE_CONTEXT_LIMIT", None::<&str>), - ("GOOSE_TOOLSHIM", None::<&str>), - ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), - ]); - let config = ModelConfig::new("claude-sonnet-4-high"); - assert_eq!(config.model_name, "claude-sonnet-4-high"); - } - - #[test] - fn parse_aliases() { - assert_eq!("off".parse::(), Ok(ThinkingEffort::Off)); - assert_eq!( - "disabled".parse::(), - Ok(ThinkingEffort::Off) - ); - assert_eq!("med".parse::(), Ok(ThinkingEffort::Medium)); - assert_eq!("max".parse::(), Ok(ThinkingEffort::Max)); - assert_eq!("xhigh".parse::(), Ok(ThinkingEffort::Max)); - assert!("invalid".parse::().is_err()); - } - } - - mod with_canonical_limits { - use super::*; - - #[test] - fn sets_limits_from_canonical_model() { - let _guard = env_lock::lock_env([ - ("GOOSE_MAX_TOKENS", None::<&str>), - ("GOOSE_CONTEXT_LIMIT", None::<&str>), - ]); - let config = ModelConfig::new("gpt-4o").with_canonical_limits("openai"); - - assert_eq!(config.context_limit, Some(128_000)); - assert_eq!(config.max_tokens, Some(16_384)); - assert_eq!(config.reasoning, Some(false)); - } - - #[test] - fn does_not_override_existing_context_limit() { - let _guard = env_lock::lock_env([ - ("GOOSE_MAX_TOKENS", None::<&str>), - ("GOOSE_CONTEXT_LIMIT", None::<&str>), - ]); - let mut config = ModelConfig::new("gpt-4o"); - config.context_limit = Some(64_000); - let config = config.with_canonical_limits("openai"); - - assert_eq!(config.context_limit, Some(64_000)); - } - - #[test] - fn does_not_override_existing_max_tokens() { - let _guard = env_lock::lock_env([ - ("GOOSE_MAX_TOKENS", None::<&str>), - ("GOOSE_CONTEXT_LIMIT", None::<&str>), - ]); - let mut config = ModelConfig::new("gpt-4o"); - config.max_tokens = Some(1_000); - let config = config.with_canonical_limits("openai"); - - 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("moonshotai/kimi-k2.6").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 resolves_claude_sonnet_5_on_aws_bedrock() { - let _guard = env_lock::lock_env([ - ("GOOSE_MAX_TOKENS", None::<&str>), - ("GOOSE_CONTEXT_LIMIT", None::<&str>), - ]); - let config = ModelConfig::new("global.anthropic.claude-sonnet-5") - .with_canonical_limits("aws_bedrock"); - - assert_eq!(config.context_limit, Some(1_000_000)); - assert_eq!(config.max_tokens, Some(128_000)); - assert_eq!(config.reasoning, Some(true)); - } - - #[test] - fn unknown_model_leaves_fields_none() { - let _guard = env_lock::lock_env([ - ("GOOSE_MAX_TOKENS", None::<&str>), - ("GOOSE_CONTEXT_LIMIT", None::<&str>), - ]); - let config = ModelConfig::new("totally-unknown-model").with_canonical_limits("openai"); - - assert_eq!(config.context_limit, None); - 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("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("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("gpt-5.4-nano-low").with_canonical_limits("openai"); - assert_eq!(config.context_limit, Some(400_000)); - } - } - - mod is_openai_reasoning_model { - use super::*; - - const ENV_LOCK_KEYS: [(&str, Option<&str>); 5] = [ - ("GOOSE_MAX_TOKENS", None), - ("GOOSE_TEMPERATURE", None), - ("GOOSE_CONTEXT_LIMIT", None), - ("GOOSE_TOOLSHIM", None), - ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None), - ]; - - #[test] - fn bare_reasoning_models() { - let _guard = env_lock::lock_env(ENV_LOCK_KEYS); - assert!(ModelConfig::new("o1").is_openai_reasoning_model()); - assert!(ModelConfig::new("o1-preview").is_openai_reasoning_model()); - assert!(ModelConfig::new("o3").is_openai_reasoning_model()); - assert!(ModelConfig::new("o3-mini").is_openai_reasoning_model()); - assert!(ModelConfig::new("o4-mini").is_openai_reasoning_model()); - assert!(ModelConfig::new("gpt-5").is_openai_reasoning_model()); - assert!(ModelConfig::new("gpt-5-3-codex").is_openai_reasoning_model()); - } - - #[test] - fn goose_prefixed_reasoning_models() { - let _guard = env_lock::lock_env(ENV_LOCK_KEYS); - assert!(ModelConfig::new("goose-o3-mini").is_openai_reasoning_model()); - assert!(ModelConfig::new("goose-o4-mini").is_openai_reasoning_model()); - assert!(ModelConfig::new("goose-gpt-5").is_openai_reasoning_model()); - } - - #[test] - fn databricks_prefixed_reasoning_models() { - let _guard = env_lock::lock_env(ENV_LOCK_KEYS); - assert!(ModelConfig::new("databricks-o3-mini").is_openai_reasoning_model()); - assert!(ModelConfig::new("databricks-o4-mini").is_openai_reasoning_model()); - assert!(ModelConfig::new("databricks-gpt-5").is_openai_reasoning_model()); - } - - #[test] - fn non_reasoning_models() { - let _guard = env_lock::lock_env(ENV_LOCK_KEYS); - assert!(!ModelConfig::new("claude-sonnet-4").is_openai_reasoning_model()); - assert!(!ModelConfig::new("gpt-4o").is_openai_reasoning_model()); - assert!(!ModelConfig::new("databricks-claude-sonnet-4").is_openai_reasoning_model()); - assert!(!ModelConfig::new("goose-claude-sonnet-4").is_openai_reasoning_model()); - assert!(!ModelConfig::new("llama-3-70b").is_openai_reasoning_model()); - } - } - - mod is_reasoning_model { - use super::*; - - const ENV_LOCK_KEYS: [(&str, Option<&str>); 5] = [ - ("GOOSE_MAX_TOKENS", None), - ("GOOSE_TEMPERATURE", None), - ("GOOSE_CONTEXT_LIMIT", None), - ("GOOSE_TOOLSHIM", None), - ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None), - ]; - - #[test] - fn includes_reasoning_model_families() { - let _guard = env_lock::lock_env(ENV_LOCK_KEYS); - assert!(ModelConfig::new("o3-mini").is_reasoning_model()); - assert!(ModelConfig::new("claude-sonnet-4").is_reasoning_model()); - assert!(ModelConfig::new("gemini-3-pro").is_reasoning_model()); - } - - #[test] - fn uses_explicit_metadata_first() { - let _guard = env_lock::lock_env(ENV_LOCK_KEYS); - let mut config = ModelConfig::new("provider-alias"); - config.reasoning = Some(true); - assert!(config.is_reasoning_model()); - - let mut config = ModelConfig::new("claude-sonnet-4"); - config.reasoning = Some(false); - assert!(!config.is_reasoning_model()); - } - } -} diff --git a/crates/goose-provider-types/src/request_log.rs b/crates/goose-provider-types/src/request_log.rs deleted file mode 100644 index f3b9c53815..0000000000 --- a/crates/goose-provider-types/src/request_log.rs +++ /dev/null @@ -1,134 +0,0 @@ -use std::{ - error::Error, - fmt::Display, - sync::{Arc, OnceLock}, -}; - -use serde::Serialize; -use serde_json::json; - -use crate::conversation::token_usage::Usage; - -type RequestLogError = Box; - -static LOGGER: OnceLock> = OnceLock::new(); - -#[derive(Debug)] -pub struct LoggerAlreadyInstalled; - -impl Display for LoggerAlreadyInstalled { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "request logger is already installed") - } -} - -impl Error for LoggerAlreadyInstalled {} - -pub fn install_logger(r: R) -> Result<(), LoggerAlreadyInstalled> { - LOGGER.set(Arc::new(r)).map_err(|_| LoggerAlreadyInstalled) -} - -pub trait RequestLogger: Send + Sync { - fn start(&self) -> Result, RequestLogError>; -} - -pub trait RequestLogHandle: Send { - fn write(&mut self, s: &str) -> Result<(), RequestLogError>; -} - -#[derive(Debug)] -pub enum LogError { - LoggerError(String), - SerializeError(serde_json::Error), -} - -impl Display for LogError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - LogError::LoggerError(msg) => write!(f, "{}", msg), - LogError::SerializeError(error) => write!(f, "serialize error: {}", error), - } - } -} - -impl Error for LogError {} - -impl From for LogError { - fn from(value: RequestLogError) -> Self { - Self::LoggerError(value.to_string()) - } -} - -fn serialize(v: &serde_json::Value) -> Result { - serde_json::to_string(v).map_err(LogError::SerializeError) -} - -pub fn start_log( - model_config: M, - payload: P, -) -> Result>, LogError> -where - M: Serialize, - P: Serialize, -{ - let logger = if let Some(logger) = LOGGER.get() { - logger - } else { - return Ok(None); - }; - - let mut handle = logger.start()?; - let payload = json!({ - "model_config": model_config, - "input": payload, - }); - - handle.write(serialize(&payload)?.as_str())?; - Ok(Some(handle)) -} - -pub trait LoggerHandleExt { - fn write(&mut self, data: &Payload, usage: Option<&Usage>) -> Result<(), LogError> - where - Payload: Serialize; - fn error(&mut self, error: E) -> Result<(), LogError> - where - E: Display; -} - -impl LoggerHandleExt for Option> { - fn write(&mut self, data: &Payload, usage: Option<&Usage>) -> Result<(), LogError> - where - Payload: Serialize, - { - let log = if let Some(log) = self { - log - } else { - return Ok(()); - }; - - let line = serialize(&json!({ - "data": data, - "usage": usage, - }))?; - - Ok(log.write(line.as_str())?) - } - - fn error(&mut self, error: E) -> Result<(), LogError> - where - E: Display, - { - let log = if let Some(log) = self { - log - } else { - return Ok(()); - }; - - let line = serialize(&json!({ - "error": format!("{}", error), - }))?; - - Ok(log.write(line.as_str())?) - } -} diff --git a/crates/goose-provider-types/src/thinking.rs b/crates/goose-provider-types/src/thinking.rs deleted file mode 100644 index abfcc904f1..0000000000 --- a/crates/goose-provider-types/src/thinking.rs +++ /dev/null @@ -1,587 +0,0 @@ -use std::{fmt, str::FromStr, sync::LazyLock}; - -use regex::Regex; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -pub const GEMINI_THOUGHT_SIGNATURE_KEY: &str = "thoughtSignature"; - -pub fn split_think_blocks(text: &str) -> (String, String) { - let mut filter = ThinkFilter::new(); - let mut out = filter.push(text); - let final_out = filter.finish(); - out.content.push_str(&final_out.content); - out.thinking.push_str(&final_out.thinking); - (out.content, out.thinking) -} - -#[derive(Debug, Default, PartialEq, Eq)] -pub struct FilterOut { - pub content: String, - pub thinking: String, -} - -pub struct ThinkFilter { - buffer: String, - inside_think: bool, - think_depth: usize, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ThinkTag { - Open, - Close, - SelfClosing, -} - -enum BufferEvent { - Tag { - pos: usize, - end: usize, - kind: ThinkTag, - }, - Partial(usize), -} - -impl ThinkFilter { - pub fn new() -> Self { - Self { - buffer: String::new(), - inside_think: false, - think_depth: 0, - } - } - - pub fn push(&mut self, chunk: &str) -> FilterOut { - self.buffer.push_str(chunk); - self.process_buffer() - } - - pub fn finish(mut self) -> FilterOut { - let mut out = self.process_buffer(); - if !self.buffer.is_empty() { - if self.inside_think { - out.thinking.push_str(&self.buffer); - } else { - out.content.push_str(&self.buffer); - } - self.buffer.clear(); - } - out - } - - fn process_buffer(&mut self) -> FilterOut { - let mut out = FilterOut::default(); - - loop { - match next_buffer_event(&self.buffer, self.inside_think) { - Some(BufferEvent::Tag { pos, end, kind }) => { - if pos > 0 { - let prefix = self.buffer.get(..pos).unwrap_or_default().to_string(); - if self.inside_think { - out.thinking.push_str(&prefix); - } else { - out.content.push_str(&prefix); - } - } - - self.buffer.drain(..end); - - match kind { - ThinkTag::Open => { - self.think_depth += 1; - self.inside_think = true; - } - ThinkTag::Close => { - self.think_depth = self.think_depth.saturating_sub(1); - self.inside_think = self.think_depth > 0; - } - ThinkTag::SelfClosing => {} - } - } - Some(BufferEvent::Partial(pos)) => { - if pos > 0 { - let prefix = self.buffer.get(..pos).unwrap_or_default().to_string(); - if self.inside_think { - out.thinking.push_str(&prefix); - } else { - out.content.push_str(&prefix); - } - self.buffer.drain(..pos); - } - break; - } - None => { - if !self.buffer.is_empty() { - if self.inside_think { - out.thinking.push_str(&self.buffer); - } else { - out.content.push_str(&self.buffer); - } - self.buffer.clear(); - } - break; - } - } - } - - out - } -} - -impl Default for ThinkFilter { - fn default() -> Self { - Self::new() - } -} - -fn next_buffer_event(buffer: &str, inside_think: bool) -> Option { - let mut search_from = 0; - - while let Some(rel_pos) = buffer.get(search_from..).and_then(|rest| rest.find('<')) { - let pos = search_from + rel_pos; - let suffix = buffer.get(pos..).unwrap_or_default(); - - if let Some((kind, end)) = parse_think_tag(buffer, pos) { - if inside_think || matches!(kind, ThinkTag::Open | ThinkTag::SelfClosing) { - return Some(BufferEvent::Tag { pos, end, kind }); - } - } else if !contains_unquoted_gt(suffix) && is_possible_partial_think_tag(suffix) { - return Some(BufferEvent::Partial(pos)); - } - - search_from = pos + 1; - } - - None -} - -fn parse_think_tag(buffer: &str, start: usize) -> Option<(ThinkTag, usize)> { - let bytes = buffer.as_bytes(); - if bytes.get(start) != Some(&b'<') { - return None; - } - - let mut idx = start + 1; - let is_close = if bytes.get(idx) == Some(&b'/') { - idx += 1; - true - } else { - false - }; - - let name_start = idx; - while bytes.get(idx).is_some_and(u8::is_ascii_alphabetic) { - idx += 1; - } - - if idx == name_start { - return None; - } - - let name = buffer.get(name_start..idx).unwrap_or_default(); - let is_think = name.eq_ignore_ascii_case("think") || name.eq_ignore_ascii_case("thinking"); - if !is_think { - return None; - } - - if is_close { - while bytes.get(idx).is_some_and(u8::is_ascii_whitespace) { - idx += 1; - } - if bytes.get(idx) == Some(&b'>') { - return Some((ThinkTag::Close, idx + 1)); - } - return None; - } - - // Require a real tag boundary immediately after the name (>, /, or whitespace). - // Without this, `` or `` would be classified as a - // think tag and stripped from normal content. - let valid_open_boundary = match bytes.get(idx) { - Some(&b) => b == b'>' || b == b'/' || b.is_ascii_whitespace(), - None => false, - }; - if !valid_open_boundary { - return None; - } - - let mut quote: Option = None; - let mut last_non_ws: Option = None; - while let Some(&byte) = bytes.get(idx) { - match quote { - Some(quote_byte) => { - if byte == quote_byte { - quote = None; - } - } - None if matches!(byte, b'"' | b'\'') => { - quote = Some(byte); - last_non_ws = Some(byte); - } - None if byte == b'>' => { - let kind = if last_non_ws == Some(b'/') { - ThinkTag::SelfClosing - } else { - ThinkTag::Open - }; - return Some((kind, idx + 1)); - } - None if !byte.is_ascii_whitespace() => { - last_non_ws = Some(byte); - } - None => {} - } - idx += 1; - } - - None -} - -fn is_possible_partial_think_tag(suffix: &str) -> bool { - if contains_unquoted_gt(suffix) { - return false; - } - - // Allow a trailing `/` so a chunk boundary that lands between `` in a self-closing `` (or ``) is still recognised - // as a partial tag and buffered until the `>` arrives in the next chunk. - static OPEN_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"(?is)^<(?:t(?:h(?:i(?:n(?:k(?:i(?:n(?:g)?)?)?)?)?)?)?)(?:\s.*|/)?$").unwrap() - }); - static CLOSE_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"(?is)^ bool { - let mut quote: Option = None; - for &byte in text.as_bytes() { - match quote { - Some(quote_byte) => { - if byte == quote_byte { - quote = None; - } - } - None if matches!(byte, b'"' | b'\'') => quote = Some(byte), - None if byte == b'>' => return true, - None => {} - } - } - false -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum ThinkingEffort { - Off, - Low, - Medium, - High, - Max, -} - -impl FromStr for ThinkingEffort { - type Err = String; - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "off" | "disabled" | "none" => Ok(Self::Off), - "low" => Ok(Self::Low), - "medium" | "med" => Ok(Self::Medium), - "high" => Ok(Self::High), - "max" | "xhigh" => Ok(Self::Max), - other => Err(format!("unknown thinking effort: '{other}'")), - } - } -} - -impl fmt::Display for ThinkingEffort { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Off => write!(f, "off"), - Self::Low => write!(f, "low"), - Self::Medium => write!(f, "medium"), - Self::High => write!(f, "high"), - Self::Max => write!(f, "max"), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_split_think_blocks_extracts_inline_reasoning() { - assert_eq!( - split_think_blocks("xy"), - ("y".to_string(), "x".to_string()) - ); - } - - #[test] - fn test_split_think_blocks_is_case_insensitive() { - assert_eq!( - split_think_blocks("xy"), - ("y".to_string(), "x".to_string()) - ); - } - - #[test] - fn test_split_think_blocks_handles_multiple_blocks() { - assert_eq!( - split_think_blocks("abcd"), - ("bd".to_string(), "ac".to_string()) - ); - } - - #[test] - fn test_split_think_blocks_without_tags() { - assert_eq!( - split_think_blocks("plain content"), - ("plain content".to_string(), String::new()) - ); - } - - #[test] - fn test_split_think_blocks_handles_attributes() { - assert_eq!( - split_think_blocks(r#"ab"#), - ("b".to_string(), "a".to_string()) - ); - } - - #[test] - fn test_split_think_blocks_handles_quoted_gt_in_self_closing_attributes() { - for input in [ - r#"Visible"#, - "Visible", - ] { - assert_eq!( - split_think_blocks(input), - ("Visible".to_string(), String::new()), - "mismatch for {input:?}" - ); - } - } - - #[test] - fn test_split_think_blocks_handles_quoted_gt_in_open_attributes() { - assert_eq!( - split_think_blocks(r#"HiddenVisible"#), - ("Visible".to_string(), "Hidden".to_string()) - ); - } - - #[test] - fn test_split_think_blocks_handles_thinking_variant() { - assert_eq!( - split_think_blocks("ab"), - ("b".to_string(), "a".to_string()) - ); - } - - #[test] - fn test_think_filter_streaming_across_partial_tags() { - let mut filter = ThinkFilter::new(); - let mut out = FilterOut::default(); - - for chunk in ["xy"] { - let partial = filter.push(chunk); - out.content.push_str(&partial.content); - out.thinking.push_str(&partial.thinking); - } - - let final_out = filter.finish(); - out.content.push_str(&final_out.content); - out.thinking.push_str(&final_out.thinking); - - assert_eq!(out.content, "y"); - assert_eq!(out.thinking, "x"); - } - - #[test] - fn test_think_filter_preserves_non_think_tags() { - let mut filter = ThinkFilter::new(); - let mut out = filter.push(""); - let final_out = filter.finish(); - out.content.push_str(&final_out.content); - out.thinking.push_str(&final_out.thinking); - - assert_eq!(out.content, "
"); - assert!(out.thinking.is_empty()); - } - - #[test] - fn test_think_filter_finish_treats_unterminated_think_as_thinking() { - let mut filter = ThinkFilter::new(); - let mut out = filter.push("unfinished"); - let final_out = filter.finish(); - out.content.push_str(&final_out.content); - out.thinking.push_str(&final_out.thinking); - - assert!(out.content.is_empty()); - assert_eq!(out.thinking, "unfinished"); - } - - #[test] - fn test_think_filter_tracks_generation_prompt_open_block() { - let mut filter = ThinkFilter::new(); - let _ = filter.push("<|assistant|>\n"); - let mut out = filter.push("hidden reasoningvisible answer"); - let final_out = filter.finish(); - out.content.push_str(&final_out.content); - out.thinking.push_str(&final_out.thinking); - - assert_eq!(out.content, "visible answer"); - assert_eq!(out.thinking, "hidden reasoning"); - } - - #[test] - fn test_think_filter_preserves_tags_with_think_prefix() { - for input in [ - "hello", - "payload", - "note", - ] { - let mut filter = ThinkFilter::new(); - let mut out = filter.push(input); - let final_out = filter.finish(); - out.content.push_str(&final_out.content); - out.thinking.push_str(&final_out.thinking); - - assert_eq!(out.content, input, "content mismatch for {input:?}"); - assert!( - out.thinking.is_empty(), - "unexpected thinking for {input:?}: {:?}", - out.thinking - ); - } - } - - #[test] - fn test_think_filter_accepts_think_with_attributes() { - let mut filter = ThinkFilter::new(); - let mut out = filter.push("hiddenvisible"); - let final_out = filter.finish(); - out.content.push_str(&final_out.content); - out.thinking.push_str(&final_out.thinking); - - assert_eq!(out.content, "visible"); - assert_eq!(out.thinking, "hidden"); - } - - #[test] - fn test_think_filter_treats_self_closing_as_noop() { - // `` carries no reasoning payload. It must not flip the filter - // into "inside_think" mode, and the tag itself must not leak into - // visible content. - for input in [ - "before after", - "before after", - "before after", - "before after", - ] { - let mut filter = ThinkFilter::new(); - let mut out = filter.push(input); - let final_out = filter.finish(); - out.content.push_str(&final_out.content); - out.thinking.push_str(&final_out.thinking); - - assert_eq!( - out.content, "before after", - "content mismatch for {input:?}" - ); - assert!( - out.thinking.is_empty(), - "unexpected thinking for {input:?}: {:?}", - out.thinking - ); - } - } - - #[test] - fn test_think_filter_self_closing_does_not_swallow_following_content() { - // Regression: a self-closing `` used to be classified as an - // Open tag, which incremented think_depth and routed everything after - // it into the thinking bucket for the rest of the stream. - let mut filter = ThinkFilter::new(); - let mut out = filter.push("visible chunk 1"); - let final_out = filter.push("visible chunk 2"); - let tail_out = filter.finish(); - out.content.push_str(&final_out.content); - out.thinking.push_str(&final_out.thinking); - out.content.push_str(&tail_out.content); - out.thinking.push_str(&tail_out.thinking); - - assert_eq!(out.content, "visible chunk 1visible chunk 2"); - assert!(out.thinking.is_empty()); - } - - #[test] - fn test_think_filter_streaming_across_self_closing_boundary() { - // Regression: a chunk boundary between `` in a - // self-closing `` used to fall out of the partial-tag regex - // (which only allowed `...`), so the `` arrived. - for (a, b) in [ - ("before after"), - ("before after"), - ("head tail"), - ] { - let mut filter = ThinkFilter::new(); - let mut out = filter.push(a); - let second = filter.push(b); - let final_out = filter.finish(); - out.content.push_str(&second.content); - out.content.push_str(&final_out.content); - out.thinking.push_str(&second.thinking); - out.thinking.push_str(&final_out.thinking); - - assert!( - !out.content.contains('<'), - "partial tag leaked into content for ({a:?}, {b:?}): {:?}", - out.content - ); - assert!( - out.thinking.is_empty(), - "unexpected thinking for ({a:?}, {b:?}): {:?}", - out.thinking - ); - } - } - - #[test] - fn test_think_filter_streaming_across_quoted_attribute_boundary() { - let mut filter = ThinkFilter::new(); - let mut out = filter.push(r#"Visible"#); - let final_out = filter.finish(); - out.content.push_str(&second.content); - out.content.push_str(&final_out.content); - out.thinking.push_str(&second.thinking); - out.thinking.push_str(&final_out.thinking); - - assert_eq!(out.content, "Visible"); - assert!(out.thinking.is_empty()); - } - - #[test] - fn test_think_filter_self_closing_inside_open_block_closes_nothing() { - // `` inside an open `` block is still a no-op: depth - // should stay at 1 until the real `` arrives. - let mut filter = ThinkFilter::new(); - let mut out = filter.push("before hidden1 hidden2visible"); - let final_out = filter.finish(); - out.content.push_str(&final_out.content); - out.thinking.push_str(&final_out.thinking); - - assert_eq!(out.content, "before visible"); - assert_eq!(out.thinking, "hidden1 hidden2"); - } -} diff --git a/crates/goose-provider-types/src/utils.rs b/crates/goose-provider-types/src/utils.rs deleted file mode 100644 index 1b7777ff2d..0000000000 --- a/crates/goose-provider-types/src/utils.rs +++ /dev/null @@ -1,27 +0,0 @@ -use unicode_normalization::UnicodeNormalization; - -fn is_in_unicode_tag_range(c: char) -> bool { - matches!(c, '\u{E0000}'..='\u{E007F}') -} - -pub fn sanitize_unicode_tags(text: &str) -> String { - let normalized: String = text.nfc().collect(); - - normalized - .chars() - .filter(|&c| !is_in_unicode_tag_range(c)) - .collect() -} - -/// Extract the model name from a JSON object. Common with most providers to have this top level attribute. -pub fn get_model(data: &serde_json::Value) -> String { - if let Some(model) = data.get("model") { - if let Some(model_str) = model.as_str() { - model_str.to_string() - } else { - "Unknown".to_string() - } - } else { - "Unknown".to_string() - } -} diff --git a/crates/goose-providers/Cargo.toml b/crates/goose-providers/Cargo.toml deleted file mode 100644 index 5dd8c3e34e..0000000000 --- a/crates/goose-providers/Cargo.toml +++ /dev/null @@ -1,69 +0,0 @@ -[package] -name = "goose-providers" -version = "0.1.0-alpha.0" -edition.workspace = true -rust-version.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -description.workspace = true - -[lints] -workspace = true - -[features] -default = [] -local-inference = ["dep:goose-local-inference"] -cuda = ["local-inference", "goose-local-inference/cuda"] -vulkan = ["local-inference", "goose-local-inference/vulkan"] -mlx = ["local-inference", "goose-local-inference/mlx"] -rustls-tls = [ - "reqwest/rustls", - "rmcp/reqwest", -] -native-tls = [ - "dep:pem", - "dep:pkcs1", - "dep:pkcs8", - "dep:sec1", - "reqwest/native-tls", - "rmcp/reqwest-native-tls", -] - -[dependencies] -anyhow = { workspace = true } -async-stream = { workspace = true } -chrono = { workspace = true } -futures = { workspace = true } -goose-provider-types = { version = "0.1.0-alpha.0", path = "../goose-provider-types", default-features = false } -goose-local-inference = { version = "0.1.0-alpha.0", path = "../goose-local-inference", default-features = false, optional = true } -reqwest = { workspace = true } -rmcp = { workspace = true, features = ["server", "macros"] } -serde = { workspace = true } -serde_json = { workspace = true } -tracing = { workspace = true } -utoipa = { workspace = true, features = ["chrono"] } -async-trait = { workspace = true } -tokio = { workspace = true } -tokio-stream = { workspace = true, features = ["io-util"] } -tokio-util = { workspace = true, features = ["compat"] } -url = { workspace = true } -urlencoding = { workspace = true } -pem = { version = "3.0.2", default-features = false, features = ["std"], optional = true } -pkcs1 = { version = "0.7.5", default-features = false, features = ["pkcs8", "std"], optional = true } -# v0.10 matches the der/const-oid series used by sec1 v0.7 and pkcs1 v0.7; upgrading to v0.11 causes type mismatches across those crates. -pkcs8 = { version = "0.10", default-features = false, features = ["alloc", "std"], optional = true } -sec1 = { version = "0.7", default-features = false, features = ["der", "pkcs8", "std"], optional = true } -include_dir = { workspace = true } - -[dev-dependencies] -test-case = { workspace = true } -tempfile = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread"] } -tokio-stream = { workspace = true } -env-lock = { workspace = true } -wiremock.workspace = true - -[[example]] -name = "streaming" -required-features = ["rustls-tls"] diff --git a/crates/goose-providers/examples/declarative.rs b/crates/goose-providers/examples/declarative.rs deleted file mode 100644 index 1c1aa189ea..0000000000 --- a/crates/goose-providers/examples/declarative.rs +++ /dev/null @@ -1,27 +0,0 @@ -use anyhow::Result; -use futures::StreamExt; -use goose_providers::{ - base::Provider, conversation::message::Message, declarative::EnvKeyResolver, model::ModelConfig, -}; - -async fn complete(provider: &dyn Provider, model: ModelConfig) -> Result<()> { - let system = "You are a knowledgable geography expert"; - let messages = [Message::user().with_text("what is the capital of France?")]; - let mut stream = provider.stream(&model, system, &messages, &[]).await?; - - while let Some((Some(msg), _)) = stream.next().await.transpose()? { - print!("{}", msg.as_concat_text()); - } - println!(); - - Ok(()) -} - -#[tokio::main] -async fn main() -> Result<()> { - let model = ModelConfig::new("deepseek-v4-flash"); - let provider = goose_providers::deepseek::create(None, EnvKeyResolver {})?; - complete(provider.as_ref(), model).await?; - - Ok(()) -} diff --git a/crates/goose-providers/examples/streaming.rs b/crates/goose-providers/examples/streaming.rs deleted file mode 100644 index 66ea1da737..0000000000 --- a/crates/goose-providers/examples/streaming.rs +++ /dev/null @@ -1,39 +0,0 @@ -use std::env; - -use anyhow::Result; -use futures::StreamExt; -use goose_providers::{ - api_client::{ApiClient, AuthMethod}, - base::Provider, - conversation::message::Message, - model::ModelConfig, - openai::OpenAiProvider, -}; - -async fn stream(provider: impl Provider, model: ModelConfig) -> Result<()> { - let system = "You are a knowledgable geography expert"; - let messages = [Message::user().with_text("what is the capital of France?")]; - - let mut stream = provider.stream(&model, system, &messages, &[]).await?; - - while let Some((Some(msg), _)) = stream.next().await.transpose()? { - print!("{}", msg.as_concat_text()); - } - println!(); - Ok(()) -} - -#[tokio::main] -async fn main() -> Result<()> { - let key = env::var("OPENAI_API_KEY").map_err(|_| anyhow::anyhow!("need an OpenAI key"))?; - let api_client = ApiClient::new_with_tls( - "https://api.openai.com".to_string(), - AuthMethod::BearerToken(key), - Some(Default::default()), - )?; - - let provider = OpenAiProvider::new(api_client); - let model = ModelConfig::new("gpt-5.4-mini"); - - stream(provider, model).await -} diff --git a/crates/goose-providers/src/anthropic.rs b/crates/goose-providers/src/anthropic.rs deleted file mode 100644 index 33755a6862..0000000000 --- a/crates/goose-providers/src/anthropic.rs +++ /dev/null @@ -1,402 +0,0 @@ -use crate::api_client::{AuthMethod, TlsConfig}; -use crate::base::ProviderDescriptor; -use crate::declarative::{DeclarativeProviderConfig, KeyResolver}; -use crate::errors::ProviderError; -use crate::request_log::{start_log, LoggerHandleExt}; -use anyhow::Result; -use async_stream::try_stream; -use async_trait::async_trait; -use futures::TryStreamExt; -use reqwest::StatusCode; -use serde_json::Value; -use std::io; -use tokio::pin; -use tokio_util::io::StreamReader; - -use super::api_client::ApiClient; -use super::base::{ConfigKey, MessageStream, ModelInfo, Provider, ProviderMetadata}; -use super::formats::anthropic::{ - create_request, response_to_streaming_message, AnthropicFormatOptions, ANTHROPIC_PROVIDER_NAME, -}; -use super::openai_compatible::handle_status; -use super::openai_compatible::map_http_error_to_provider_error; -use super::retry::ProviderRetry; -use crate::conversation::message::Message; -use crate::model::ModelConfig; -use rmcp::model::Tool; - -pub const ANTHROPIC_DEFAULT_MODEL: &str = "claude-sonnet-4-5"; -pub const ANTHROPIC_DEFAULT_FAST_MODEL: &str = "claude-haiku-4-5"; -const ANTHROPIC_KNOWN_MODELS: &[&str] = &[ - "claude-opus-4-8", - "claude-opus-4-7", - // Claude 4.6 models - "claude-opus-4-6", - "claude-sonnet-4-6", - // Claude 4.5 models with aliases - "claude-sonnet-4-5", - "claude-sonnet-4-5-20250929", - "claude-haiku-4-5", - "claude-haiku-4-5-20251001", - "claude-opus-4-5", - "claude-opus-4-5-20251101", - // Legacy Claude 4.0 models - "claude-sonnet-4-0", - "claude-sonnet-4-20250514", - "claude-opus-4-0", - "claude-opus-4-20250514", -]; - -const ANTHROPIC_DOC_URL: &str = "https://docs.anthropic.com/en/docs/about-claude/models"; -pub const ANTHROPIC_API_VERSION: &str = "2023-06-01"; - -#[derive(serde::Serialize)] -pub struct AnthropicProvider { - #[serde(skip)] - api_client: ApiClient, - supports_streaming: bool, - name: String, - custom_models: Option>, - dynamic_models: Option, - skip_canonical_filtering: bool, - #[serde(skip)] - format_options: AnthropicFormatOptions, -} - -/// Builder for [`AnthropicProvider`]. -/// -/// Exposes every field of the provider so that constructors living outside -/// `anthropic.rs` (e.g. in `anthropic_def.rs`, which lives in the `goose` -/// crate) can assemble a provider without needing direct access to the -/// struct's private fields. -pub struct AnthropicProviderBuilder { - api_client: ApiClient, - supports_streaming: bool, - name: String, - custom_models: Option>, - dynamic_models: Option, - skip_canonical_filtering: bool, - format_options: AnthropicFormatOptions, -} - -impl AnthropicProviderBuilder { - pub fn new(api_client: ApiClient) -> Self { - Self { - api_client, - supports_streaming: true, - name: ANTHROPIC_PROVIDER_NAME.to_string(), - custom_models: None, - dynamic_models: None, - skip_canonical_filtering: false, - format_options: AnthropicFormatOptions::default(), - } - } - - pub fn api_client(mut self, api_client: ApiClient) -> Self { - self.api_client = api_client; - self - } - - pub fn map_api_client(mut self, f: impl FnOnce(ApiClient) -> ApiClient) -> Self { - self.api_client = f(self.api_client); - self - } - - pub fn try_map_api_client( - mut self, - f: impl FnOnce(ApiClient) -> Result, - ) -> Result { - self.api_client = f(self.api_client)?; - Ok(self) - } - - pub fn supports_streaming(mut self, supports_streaming: bool) -> Self { - self.supports_streaming = supports_streaming; - self - } - - pub fn name(mut self, name: impl Into) -> Self { - self.name = name.into(); - self - } - - pub fn custom_models(mut self, custom_models: Option>) -> Self { - self.custom_models = custom_models; - self - } - - pub fn dynamic_models(mut self, dynamic_models: Option) -> Self { - self.dynamic_models = dynamic_models; - self - } - - pub fn skip_canonical_filtering(mut self, skip_canonical_filtering: bool) -> Self { - self.skip_canonical_filtering = skip_canonical_filtering; - self - } - - pub fn format_options(mut self, format_options: AnthropicFormatOptions) -> Self { - self.format_options = format_options; - self - } - - pub fn build(self) -> AnthropicProvider { - AnthropicProvider { - api_client: self.api_client, - supports_streaming: self.supports_streaming, - name: self.name, - custom_models: self.custom_models, - dynamic_models: self.dynamic_models, - skip_canonical_filtering: self.skip_canonical_filtering, - format_options: self.format_options, - } - } -} - -impl AnthropicProvider { - async fn fetch_models_from_api(&self) -> Result, ProviderError> { - let response = self.api_client.request("v1/models").api_get().await?; - - if response.status == StatusCode::NOT_FOUND { - let msg = response - .payload - .as_ref() - .and_then(|p| p.get("error").and_then(|e| e.get("message"))) - .and_then(|m| m.as_str()) - .unwrap_or("models endpoint not found") - .to_string(); - return Err(ProviderError::EndpointNotFound(msg)); - } - - if response.status != StatusCode::OK { - return Err(map_http_error_to_provider_error( - response.status, - response.payload, - "v1/models", - )); - } - - let json = response.payload.unwrap_or_default(); - let arr = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| { - ProviderError::RequestFailed( - "Missing 'data' array in Anthropic models response".to_string(), - ) - })?; - - let mut models: Vec = arr - .iter() - .filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(str::to_string)) - .collect(); - models.sort(); - Ok(models) - } -} - -impl ProviderDescriptor for AnthropicProvider { - fn metadata() -> ProviderMetadata { - let models: Vec = ANTHROPIC_KNOWN_MODELS - .iter() - .map(|&model_name| ModelInfo::new(model_name, 200_000)) - .collect(); - - ProviderMetadata::with_models( - ANTHROPIC_PROVIDER_NAME, - "Anthropic", - "Claude and other models from Anthropic", - ANTHROPIC_DEFAULT_MODEL, - models, - ANTHROPIC_DOC_URL, - vec![ - ConfigKey::new("ANTHROPIC_API_KEY", true, true, None, true), - ConfigKey::new( - "ANTHROPIC_HOST", - true, - false, - Some("https://api.anthropic.com"), - false, - ), - ], - ) - .with_fast_model(ANTHROPIC_DEFAULT_FAST_MODEL) - .with_setup_steps(vec![ - "Go to https://platform.claude.com/settings/keys", - "Click 'Create Key'", - "Copy the key and paste it above", - ]) - } -} - -#[async_trait] -impl Provider for AnthropicProvider { - fn get_name(&self) -> &str { - &self.name - } - - fn skip_canonical_filtering(&self) -> bool { - self.skip_canonical_filtering - } - - async fn fetch_supported_models(&self) -> Result, ProviderError> { - if let Some(custom_models) = &self.custom_models { - if self.dynamic_models == Some(false) { - return Ok(custom_models.clone()); - } - match self.fetch_models_from_api().await { - Ok(models) => return Ok(models), - Err(e) if e.is_endpoint_not_found() => { - tracing::debug!( - "Models endpoint not implemented for provider '{}' ({}), using predefined list", - self.name, - e - ); - return Ok(custom_models.clone()); - } - Err(e) => return Err(e), - } - } - - self.fetch_models_from_api().await - } - - async fn stream( - &self, - model_config: &ModelConfig, - system: &str, - messages: &[Message], - tools: &[Tool], - ) -> Result { - let mut payload = create_request( - ANTHROPIC_PROVIDER_NAME, - model_config, - system, - messages, - tools, - self.format_options, - )?; - payload - .as_object_mut() - .unwrap() - .insert("stream".to_string(), Value::Bool(true)); - - let mut log = start_log(model_config, &payload)?; - - let response = self - .with_retry(|| async { - let request = self.api_client.request("v1/messages"); - let resp = request.response_post(&payload).await?; - handle_status(resp).await - }) - .await - .inspect_err(|e| { - let _ = log.error(e); - })?; - - let stream = response.bytes_stream().map_err(io::Error::other); - - Ok(Box::pin(try_stream! { - let stream_reader = StreamReader::new(stream); - let framed = tokio_util::codec::FramedRead::new(stream_reader, tokio_util::codec::LinesCodec::new()).map_err(anyhow::Error::from); - - let message_stream = response_to_streaming_message(framed); - pin!(message_stream); - while let Some(message) = futures::StreamExt::next(&mut message_stream).await { - let (message, usage) = message.map_err(ProviderError::from_stream_error)?; - log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?; - yield (message, usage); - } - })) - } -} - -fn format_options_for_provider(preserves_thinking: bool) -> AnthropicFormatOptions { - AnthropicFormatOptions { - preserve_unsigned_thinking: preserves_thinking, - preserve_thinking_context: preserves_thinking, - thinking_disabled: false, - } -} - -pub fn from_declarative_config( - config: DeclarativeProviderConfig, - tls_config: Option, - key_resolver: impl KeyResolver, -) -> Result { - let custom_models = if !config.models.is_empty() { - Some( - config - .models - .iter() - .map(|m| m.name.clone()) - .collect::>(), - ) - } else { - None - }; - - if config.dynamic_models == Some(false) && custom_models.is_none() { - return Err(anyhow::anyhow!( - "Provider '{}' has dynamic_models: false but no static models listed; \ - at least one entry in `models` is required.", - config.name - )); - } - - let api_key = if config.api_key_env.is_empty() { - None - } else { - match key_resolver.resolve_key(config.api_key_env.as_str()) { - Ok(key) => Some(key), - Err(err) => { - if config.requires_auth { - anyhow::bail!("missing required key {}: {}", config.api_key_env, err); - } - None - } - } - }; - - let auth = match api_key { - Some(key) if !key.is_empty() => AuthMethod::ApiKey { - header_name: "x-api-key".to_string(), - key, - }, - _ => AuthMethod::NoAuth, - }; - - let format_options = format_options_for_provider(config.preserves_thinking); - - let mut api_client = ApiClient::new_with_tls(config.base_url, auth, tls_config)?; - - if let Some(headers) = &config.headers { - let mut header_map = reqwest::header::HeaderMap::new(); - header_map.insert( - reqwest::header::HeaderName::from_static("anthropic-version"), - reqwest::header::HeaderValue::from_static(ANTHROPIC_API_VERSION), - ); - for (key, value) in headers { - let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?; - let header_value = reqwest::header::HeaderValue::from_str(value)?; - header_map.insert(header_name, header_value); - } - api_client = api_client.with_headers(header_map)?; - } else { - api_client = api_client.with_header("anthropic-version", ANTHROPIC_API_VERSION)?; - } - - let supports_streaming = config.supports_streaming.unwrap_or(true); - - if !supports_streaming { - return Err(anyhow::anyhow!( - "Anthropic provider does not support non-streaming mode. All Claude models support streaming. \ - Please remove 'supports_streaming: false' from your provider configuration." - )); - } - - Ok(AnthropicProviderBuilder::new(api_client) - .supports_streaming(supports_streaming) - .name(config.name.clone()) - .custom_models(custom_models) - .dynamic_models(config.dynamic_models) - .skip_canonical_filtering(config.skip_canonical_filtering) - .format_options(format_options)) -} diff --git a/crates/goose-providers/src/declarative.rs b/crates/goose-providers/src/declarative.rs deleted file mode 100644 index 4062bb6927..0000000000 --- a/crates/goose-providers/src/declarative.rs +++ /dev/null @@ -1,591 +0,0 @@ -#[macro_use] -mod macros; - -use std::{collections::HashMap, path::Path, str::FromStr}; - -use anyhow::Result; -use include_dir::{include_dir, Dir}; -use serde::{Deserialize, Deserializer, Serialize}; -use utoipa::ToSchema; - -pub static FIXED_PROVIDERS: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/declarative/definitions"); - -pub(crate) mod declarative_providers { - use super::*; - - expose_declarative_providers!( - alibaba, - atomic_chat, - cerebras, - deepseek, - empiriolabs, - fireworks, - futurmix, - groq, - iflytek, - iflytek_astron, - inception, - llama_swap, - lmstudio, - minimax, - mistral, - moonshot, - nearai, - novita, - nvidia, - ollama_cloud, - omlx, - opencode_go, - orcarouter, - ovhcloud, - perplexity, - routstr, - saladcloud, - scaleway, - tanzu, - tensorix, - together, - venice, - vercel_ai_gateway, - zai, - zhipu, - ); -} - -use crate::{ - anthropic, - api_client::TlsConfig, - base::{ModelInfo, Provider}, - ollama, openai, -}; - -pub fn fixed_provider_configs() -> anyhow::Result> { - declarative_providers::fixed_provider_configs() -} - -pub fn fixed_provider_config_entries() -> Vec<(&'static str, &'static str)> { - declarative_providers::fixed_provider_config_entries() -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct EnvVarConfig { - pub name: String, - #[serde(default)] - pub required: bool, - #[serde(default)] - pub secret: bool, - /// Defaults to the value of `required` if not specified. - /// UIs may use this to feature this config value more prominently. - pub primary: Option, - pub description: Option, - pub default: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum ProviderEngine { - #[serde(alias = "openai_compatible")] - OpenAI, - #[serde(alias = "ollama_compatible")] - Ollama, - #[serde(alias = "anthropic_compatible")] - Anthropic, -} - -impl FromStr for ProviderEngine { - type Err = anyhow::Error; - - fn from_str(engine: &str) -> Result { - match engine.trim().to_lowercase().as_str() { - "openai" | "openai_compatible" => Ok(Self::OpenAI), - "anthropic" | "anthropic_compatible" => Ok(Self::Anthropic), - "ollama" | "ollama_compatible" => Ok(Self::Ollama), - _ => Err(anyhow::anyhow!("Invalid provider type: {}", engine)), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct DeclarativeProviderConfig { - pub name: String, - pub engine: ProviderEngine, - pub display_name: String, - pub description: Option, - #[serde(default)] - pub api_key_env: String, - pub base_url: String, - pub models: Vec, - pub headers: Option>, - pub timeout_seconds: Option, - pub supports_streaming: Option, - #[serde(default = "default_requires_auth")] - pub requires_auth: bool, - #[serde(default)] - pub catalog_provider_id: Option, - #[serde(default)] - pub base_path: Option, - #[serde(default)] - pub env_vars: Option>, - /// Controls whether `fetch_supported_models` calls the provider's `/v1/models` - /// endpoint or returns the static `models` list directly. - /// - /// - `Some(false)` + non-empty `models`: return the static list; no API call. - /// Construction fails if `models` is empty. - /// - `Some(true)` or `None`: try the API; fall back to `models` on 404. - #[serde(default)] - pub dynamic_models: Option, - #[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, - #[serde(default)] - pub preserves_thinking: bool, -} - -fn default_requires_auth() -> bool { - true -} - -pub fn should_preserve_thinking_by_default(engine: &ProviderEngine) -> bool { - matches!(engine, ProviderEngine::OpenAI) -} - -/// Deserialize an optional string, treating empty/whitespace-only values as None. -fn deserialize_non_empty_string<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let opt: Option = Option::deserialize(deserializer)?; - Ok(opt.filter(|s| !s.trim().is_empty())) -} - -impl DeclarativeProviderConfig { - pub fn id(&self) -> &str { - &self.name - } - - pub fn display_name(&self) -> &str { - &self.display_name - } - - pub fn models(&self) -> &[ModelInfo] { - &self.models - } -} - -pub trait KeyResolver { - type Error: std::error::Error + Send + Sync + 'static; - - fn resolve_key(&self, key: &str) -> std::result::Result; -} - -pub struct EnvKeyResolver; - -impl EnvKeyResolver { - pub fn new() -> Self { - EnvKeyResolver {} - } -} - -impl Default for EnvKeyResolver { - fn default() -> Self { - Self::new() - } -} - -impl KeyResolver for EnvKeyResolver { - type Error = std::env::VarError; - - fn resolve_key(&self, key: &str) -> std::result::Result { - std::env::var(key) - } -} - -fn expand_env_vars(template: &str, env_vars: &[EnvVarConfig]) -> Result { - let mut result = template.to_string(); - - for var in env_vars { - let placeholder = format!("${{{}}}", var.name); - if !result.contains(&placeholder) { - continue; - } - - let value = match std::env::var(&var.name) { - Ok(value) => value, - Err(_) => match &var.default { - Some(default) => default.clone(), - None if var.required => { - anyhow::bail!("Required environment variable {} is not set", var.name) - } - None => continue, - }, - }; - - result = result.replace(&placeholder, &value); - } - - Ok(result) -} - -fn resolve_config(config: &mut DeclarativeProviderConfig) -> Result<()> { - if let Some(env_vars) = &config.env_vars { - config.base_url = expand_env_vars(&config.base_url, env_vars)?; - - for var in env_vars { - if var.name.ends_with("_STREAMING") { - let value = std::env::var(&var.name) - .ok() - .or_else(|| var.default.clone()) - .map(|value| value.eq_ignore_ascii_case("true")); - if let Some(value) = value { - config.supports_streaming = Some(value); - } - } - } - } - - Ok(()) -} - -pub fn deserialize_provider_config(json: &str) -> Result { - let raw: serde_json::Value = serde_json::from_str(json)?; - let preserves_thinking_was_set = raw.get("preserves_thinking").is_some(); - let mut config: DeclarativeProviderConfig = serde_json::from_value(raw)?; - - if !preserves_thinking_was_set { - config.preserves_thinking = should_preserve_thinking_by_default(&config.engine); - } - - Ok(config) -} - -fn config_from_json(json: &str) -> Result { - let mut config = deserialize_provider_config(json)?; - resolve_config(&mut config)?; - Ok(config) -} - -pub fn load_custom_providers(dir: &Path) -> Result> { - if !dir.exists() { - return Ok(Vec::new()); - } - - std::fs::read_dir(dir)? - .filter_map(|entry| { - let path = entry.ok()?.path(); - (path.extension()? == "json").then_some(path) - }) - .map(|path| { - let content = std::fs::read_to_string(&path)?; - deserialize_provider_config(&content) - .map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", path.display(), e)) - }) - .collect() -} - -pub fn from_json( - json: &str, - tls_config: Option, - key_resolver: impl KeyResolver, -) -> Result> { - let config = config_from_json(json)?; - - match config.engine { - ProviderEngine::OpenAI => openai::from_declarative_config(config, tls_config, key_resolver) - .map(|provider| Box::new(provider.build()) as Box), - ProviderEngine::Ollama => ollama::from_declarative_config(config, tls_config, key_resolver) - .map(|provider| Box::new(provider.build()) as Box), - ProviderEngine::Anthropic => { - anthropic::from_declarative_config(config, tls_config, key_resolver) - .map(|provider| Box::new(provider.build()) as Box) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use std::collections::HashSet; - - fn model_json() -> serde_json::Value { - json!({ - "name": "test-model", - "context_limit": 4096, - "input_token_cost": null, - "output_token_cost": null, - "currency": null, - "supports_cache_control": null, - "reasoning": false - }) - } - - #[test] - fn provider_engine_deserializes_compatible_aliases() { - let openai: DeclarativeProviderConfig = serde_json::from_value(json!({ - "name": "test-openai", - "engine": "openai_compatible", - "display_name": "Test OpenAI", - "base_url": "http://localhost:1234", - "models": [model_json()] - })) - .unwrap(); - assert_eq!(openai.engine, ProviderEngine::OpenAI); - - let anthropic: DeclarativeProviderConfig = serde_json::from_value(json!({ - "name": "test-anthropic", - "engine": "anthropic_compatible", - "display_name": "Test Anthropic", - "base_url": "http://localhost:1234", - "models": [model_json()] - })) - .unwrap(); - assert_eq!(anthropic.engine, ProviderEngine::Anthropic); - - let ollama: DeclarativeProviderConfig = serde_json::from_value(json!({ - "name": "test-ollama", - "engine": "ollama_compatible", - "display_name": "Test Ollama", - "base_url": "http://localhost:11434", - "models": [model_json()] - })) - .unwrap(); - assert_eq!(ollama.engine, ProviderEngine::Ollama); - } - - #[test] - fn groq_json_disables_thinking_preservation() { - let config = - deserialize_provider_config(crate::groq::JSON).expect("groq.json should parse"); - - assert!(!config.preserves_thinking); - } - - fn placeholder_var_names(template: &str) -> Vec { - template - .split("${") - .skip(1) - .filter_map(|chunk| chunk.split_once('}')) - .map(|(name, _)| name.to_string()) - .collect() - } - - fn validate_provider_id(id: &str) -> Result<()> { - let mut chars = id.chars(); - let Some(first) = chars.next() else { - anyhow::bail!("Invalid provider id: provider id cannot be empty"); - }; - - if !(first.is_ascii_lowercase() || first.is_ascii_digit() || first == '_') { - anyhow::bail!("Invalid provider id: {id}"); - } - - if chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-') - { - Ok(()) - } else { - anyhow::bail!("Invalid provider id: {id}") - } - } - - #[test] - fn expose_declarative_providers_enumerates_all_bundled_json_files() { - let enumerated: HashSet<_> = fixed_provider_config_entries() - .into_iter() - .map(|(path, _)| path.to_string()) - .collect(); - let bundled: HashSet<_> = FIXED_PROVIDERS - .files() - .filter(|file| file.path().extension().and_then(|s| s.to_str()) == Some("json")) - .map(|file| { - file.path() - .file_name() - .unwrap() - .to_string_lossy() - .into_owned() - }) - .collect(); - - assert_eq!(enumerated, bundled); - } - - #[test] - fn all_bundled_providers_are_valid() { - let mut seen_ids = HashSet::new(); - - for (path, json) in fixed_provider_config_entries() { - let config = deserialize_provider_config(json) - .unwrap_or_else(|e| panic!("{path} failed to parse: {e}")); - - validate_provider_id(config.id()) - .unwrap_or_else(|e| panic!("{path} has an invalid provider id: {e}")); - assert!( - seen_ids.insert(config.id().to_string()), - "{path} has a duplicate provider id: {}", - config.id() - ); - assert!(!config.base_url.is_empty(), "{path} has an empty base_url"); - - if config.dynamic_models == Some(false) { - assert!( - !config.models.is_empty(), - "{path} disables dynamic_models but lists no static models" - ); - } - - let declared: HashSet<&str> = config - .env_vars - .iter() - .flatten() - .map(|v| v.name.as_str()) - .collect(); - let templates = std::iter::once(config.base_url.as_str()) - .chain(config.base_path.as_deref()) - .chain( - config - .headers - .iter() - .flat_map(|h| h.values()) - .map(String::as_str), - ); - for template in templates { - for var in placeholder_var_names(template) { - assert!( - declared.contains(var.as_str()), - "{path} references ${{{var}}} but declares no matching env_var" - ); - } - } - } - - assert!(!seen_ids.is_empty(), "no bundled providers were found"); - } - - #[test] - fn fixed_provider_configs_are_unresolved() { - let configs = fixed_provider_configs().expect("bundled providers should load"); - let config = configs - .iter() - .find(|config| config.env_vars.is_some()) - .expect("at least one bundled provider should declare env_vars"); - - assert!( - config.base_url.contains("${"), - "{} should keep base_url placeholders unresolved", - config.id() - ); - } - - #[test] - fn from_json_defaults_openai_preserves_thinking_to_true() { - let json = json!({ - "name": "test-provider", - "engine": "openai", - "display_name": "Test Provider", - "base_url": "http://localhost:1234/v1/chat/completions", - "models": [model_json()], - "requires_auth": false, - "dynamic_models": false - }) - .to_string(); - - let config = config_from_json(&json).unwrap(); - - assert!(config.preserves_thinking); - } - - #[test] - fn from_json_preserves_explicit_openai_preserves_thinking_false() { - let json = json!({ - "name": "test-provider", - "engine": "openai", - "display_name": "Test Provider", - "base_url": "http://localhost:1234/v1/chat/completions", - "models": [model_json()], - "requires_auth": false, - "dynamic_models": false, - "preserves_thinking": false - }) - .to_string(); - - let config = config_from_json(&json).unwrap(); - - assert!(!config.preserves_thinking); - } - - #[test] - fn from_json_expands_base_url_from_env_var_default() { - let _guard = env_lock::lock_env([("TEST_PROVIDER_HOST", None::<&str>)]); - let json = json!({ - "name": "test-provider", - "engine": "openai", - "display_name": "Test Provider", - "base_url": "${TEST_PROVIDER_HOST}/v1/chat/completions", - "models": [model_json()], - "requires_auth": false, - "dynamic_models": false, - "env_vars": [{ - "name": "TEST_PROVIDER_HOST", - "default": "http://localhost:1234" - }] - }) - .to_string(); - - let provider = from_json(&json, None, EnvKeyResolver).unwrap(); - - assert_eq!(provider.get_name(), "test-provider"); - } - - #[tokio::test] - async fn from_json_ollama_returns_static_models_when_dynamic_models_false() { - let json = json!({ - "name": "test-ollama", - "engine": "ollama", - "display_name": "Test Ollama", - "base_url": "http://localhost:11434", - "models": [model_json()], - "requires_auth": false, - "dynamic_models": false - }) - .to_string(); - - let provider = from_json(&json, None, EnvKeyResolver).unwrap(); - - assert_eq!( - provider.fetch_supported_models().await.unwrap(), - vec!["test-model".to_string()] - ); - } - - #[test] - fn from_json_errors_when_required_env_var_is_missing() { - let _guard = env_lock::lock_env([("TEST_PROVIDER_REQUIRED_HOST", None::<&str>)]); - let json = json!({ - "name": "test-provider", - "engine": "openai", - "display_name": "Test Provider", - "base_url": "${TEST_PROVIDER_REQUIRED_HOST}/v1/chat/completions", - "models": [model_json()], - "requires_auth": false, - "dynamic_models": false, - "env_vars": [{ - "name": "TEST_PROVIDER_REQUIRED_HOST", - "required": true - }] - }) - .to_string(); - - let err = match from_json(&json, None, EnvKeyResolver) { - Ok(_) => panic!("expected missing required env var error"), - Err(err) => err, - }; - - assert!(err - .to_string() - .contains("Required environment variable TEST_PROVIDER_REQUIRED_HOST is not set")); - } -} diff --git a/crates/goose-providers/src/declarative/definitions/empiriolabs.json b/crates/goose-providers/src/declarative/definitions/empiriolabs.json deleted file mode 100644 index f0d6f536df..0000000000 --- a/crates/goose-providers/src/declarative/definitions/empiriolabs.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "empiriolabs", - "engine": "openai", - "display_name": "EmpirioLabs AI", - "description": "Frontier open and proprietary chat models through one OpenAI-compatible API with streaming support", - "api_key_env": "EMPIRIOLABS_API_KEY", - "base_url": "https://api.empiriolabs.ai/v1/chat/completions", - "models": [ - { - "name": "qwen3-7-plus", - "context_limit": 1000000, - "max_tokens": 65536 - }, - { - "name": "qwen3-7-max", - "context_limit": 1000000, - "max_tokens": 65536 - }, - { - "name": "deepseek-v4-pro", - "context_limit": 1000000, - "max_tokens": 65536 - }, - { - "name": "deepseek-v4-flash", - "context_limit": 1000000, - "max_tokens": 65536 - }, - { - "name": "glm-5-1", - "context_limit": 202000, - "max_tokens": 131072 - }, - { - "name": "kimi-k2-7-code", - "context_limit": 256000, - "max_tokens": 131072 - }, - { - "name": "minimax-m3", - "context_limit": 524000, - "max_tokens": 131072 - } - ], - "supports_streaming": true, - "dynamic_models": false, - "model_doc_link": "https://docs.empiriolabs.ai", - "setup_steps": [ - "Go to https://platform.empiriolabs.ai/dashboard/api-keys", - "Create or copy an existing API key", - "Paste the key above as EMPIRIOLABS_API_KEY" - ] -} diff --git a/crates/goose-providers/src/declarative/definitions/fireworks.json b/crates/goose-providers/src/declarative/definitions/fireworks.json deleted file mode 100644 index 85b56bb674..0000000000 --- a/crates/goose-providers/src/declarative/definitions/fireworks.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "fireworks-ai", - "engine": "openai", - "display_name": "Fireworks AI", - "description": "Fast serverless inference for open models with an OpenAI-compatible API", - "api_key_env": "FIREWORKS_API_KEY", - "base_url": "https://api.fireworks.ai/inference/v1/chat/completions", - "catalog_provider_id": "fireworks-ai", - "dynamic_models": false, - "models": [ - { - "name": "accounts/fireworks/models/kimi-k2p7-code", - "context_limit": 262000 - }, - { - "name": "accounts/fireworks/models/kimi-k2p6", - "context_limit": 262000 - }, - { - "name": "accounts/fireworks/models/glm-5p2", - "context_limit": 1048576 - }, - { - "name": "accounts/fireworks/models/deepseek-v4-pro", - "context_limit": 1000000 - }, - { - "name": "accounts/fireworks/models/deepseek-v4-flash", - "context_limit": 1000000 - }, - { - "name": "accounts/fireworks/models/minimax-m3", - "context_limit": 512000 - }, - { - "name": "accounts/fireworks/models/qwen3p7-plus", - "context_limit": 262144 - }, - { - "name": "accounts/fireworks/models/glm-5p1", - "context_limit": 202800 - }, - { - "name": "accounts/fireworks/models/gpt-oss-120b", - "context_limit": 131072 - }, - { - "name": "accounts/fireworks/models/gpt-oss-20b", - "context_limit": 131072 - } - ], - "preserves_thinking": true, - "supports_streaming": true, - "model_doc_link": "https://fireworks.ai/models" -} diff --git a/crates/goose-providers/src/declarative/definitions/iflytek.json b/crates/goose-providers/src/declarative/definitions/iflytek.json deleted file mode 100644 index a0c2da0387..0000000000 --- a/crates/goose-providers/src/declarative/definitions/iflytek.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "iflytek", - "engine": "openai", - "display_name": "iFlytek Spark", - "description": "iFlytek Spark (่ฎฏ้ฃžๆ˜Ÿ็ซ) models via the OpenAI-compatible HTTP API. Authenticate with your Spark HTTP API password (APIPassword). Lists the 4.0Ultra and Max families, which per the Spark HTTP docs accept system messages (always sent by goose). Best used for chat: Spark only returns OpenAI-style tool_calls when the request body sets tool_calls_switch=true, which this declarative config cannot inject, so tool-using extensions may not work as-is.", - "api_key_env": "SPARK_API_PASSWORD", - "base_url": "https://spark-api-open.xf-yun.com/v1", - "dynamic_models": false, - "skip_canonical_filtering": true, - "models": [ - {"name": "4.0Ultra", "context_limit": 8192}, - {"name": "generalv3.5", "context_limit": 8192}, - {"name": "max-32k", "context_limit": 32768} - ], - "supports_streaming": true, - "model_doc_link": "https://www.xfyun.cn/doc/spark/HTTP%E8%B0%83%E7%94%A8%E6%96%87%E6%A1%A3.html", - "setup_steps": [ - "Sign in to https://xinghuo.xfyun.cn/sparkapi and create or select a Spark model plan", - "Open the model's 'HTTP ๆœๅŠกๆŽฅๅฃ่ฎค่ฏไฟกๆฏ' page and copy the APIPassword (each model version has its own password)", - "Paste the APIPassword above as SPARK_API_PASSWORD" - ] -} diff --git a/crates/goose-providers/src/declarative/definitions/iflytek_astron.json b/crates/goose-providers/src/declarative/definitions/iflytek_astron.json deleted file mode 100644 index 6cffd3ba6a..0000000000 --- a/crates/goose-providers/src/declarative/definitions/iflytek_astron.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "iflytek_astron", - "engine": "openai", - "display_name": "iFlytek Astron MaaS", - "description": "iFlytek Astron MaaS (่ฎฏ้ฃžๆ˜Ÿ่พฐ) models via the OpenAI-compatible API. Defaults to the Token Plan endpoint; set ASTRON_BASE_URL to https://maas-coding-api.cn-huabei-1.xf-yun.com/v2 to use the Coding Plan (e.g. astron-code-latest). Each plan has its own API key.", - "api_key_env": "ASTRON_API_KEY", - "base_url": "${ASTRON_BASE_URL}", - "env_vars": [ - { - "name": "ASTRON_BASE_URL", - "required": false, - "secret": false, - "default": "https://maas-token-api.cn-huabei-1.xf-yun.com/v2", - "description": "Astron MaaS API base URL. Use https://maas-coding-api.cn-huabei-1.xf-yun.com/v2 for Coding Plan models like astron-code-latest." - } - ], - "dynamic_models": false, - "skip_canonical_filtering": true, - "models": [ - {"name": "xsparkx2", "context_limit": 131072}, - {"name": "xsparkx2flash", "context_limit": 131072}, - {"name": "xopglm51", "context_limit": 204800}, - {"name": "xopglm5", "context_limit": 204800}, - {"name": "xopdeepseekv4pro", "context_limit": 131072}, - {"name": "xopdeepseekv4flash", "context_limit": 131072}, - {"name": "xopdeepseekv32", "context_limit": 131072}, - {"name": "xopkimik26", "context_limit": 262144}, - {"name": "xminimaxm25", "context_limit": 131072}, - {"name": "xopqwen35397b", "context_limit": 262144}, - {"name": "astron-code-latest", "context_limit": 131072} - ], - "supports_streaming": true, - "preserves_thinking": true, - "model_doc_link": "https://www.xfyun.cn/doc/spark/TokenPlan.html", - "setup_steps": [ - "Sign in to https://maas.xfyun.cn and subscribe to a Token Plan (general models) or Coding Plan (astron-code-latest)", - "Copy the dedicated API Key for your plan from the subscription page", - "Paste the API Key above as ASTRON_API_KEY", - "For Coding Plan, set ASTRON_BASE_URL to https://maas-coding-api.cn-huabei-1.xf-yun.com/v2" - ] -} diff --git a/crates/goose-providers/src/declarative/definitions/orcarouter.json b/crates/goose-providers/src/declarative/definitions/orcarouter.json deleted file mode 100644 index fa830284b0..0000000000 --- a/crates/goose-providers/src/declarative/definitions/orcarouter.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "orcarouter", - "engine": "openai", - "display_name": "OrcaRouter", - "description": "Adaptive router for OpenAI, Anthropic, Google, xAI, DeepSeek, and 150+ other frontier models through a single OpenAI-compatible API.", - "api_key_env": "ORCAROUTER_API_KEY", - "base_url": "https://api.orcarouter.ai/v1", - "catalog_provider_id": "orcarouter", - "headers": { - "http-referer": "https://goose-docs.ai", - "x-title": "goose" - }, - "models": [ - { "name": "anthropic/claude-sonnet-4.6", "context_limit": 200000 }, - { "name": "openai/gpt-5.5", "context_limit": 400000 }, - { "name": "google/gemini-3-flash-preview", "context_limit": 1000000 }, - { "name": "anthropic/claude-opus-4.7", "context_limit": 200000 }, - { "name": "grok/grok-4.3", "context_limit": 256000 }, - { "name": "deepseek/deepseek-v4-pro", "context_limit": 163840 }, - { "name": "orcarouter/auto", "context_limit": 128000 } - ], - "dynamic_models": true, - "supports_streaming": true, - "preserves_thinking": true, - "model_doc_link": "https://www.orcarouter.ai/models", - "setup_steps": [ - "Sign up at https://www.orcarouter.ai", - "Create an API key in the console", - "Paste the key above as ORCAROUTER_API_KEY" - ] -} diff --git a/crates/goose-providers/src/declarative/definitions/together.json b/crates/goose-providers/src/declarative/definitions/together.json deleted file mode 100644 index 203fe96717..0000000000 --- a/crates/goose-providers/src/declarative/definitions/together.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "together", - "engine": "openai", - "display_name": "Together AI", - "description": "Open-source and custom AI models via Together AI's OpenAI-compatible API.", - "api_key_env": "TOGETHER_API_KEY", - "base_url": "https://api.together.xyz/v1", - "catalog_provider_id": "togetherai", - "dynamic_models": true, - "models": [ - { - "name": "meta-llama/Llama-3.3-70B-Instruct-Turbo", - "context_limit": 131072 - }, - { - "name": "deepseek-ai/DeepSeek-V3", - "context_limit": 131072 - }, - { - "name": "deepseek-ai/DeepSeek-R1", - "context_limit": 163839 - }, - { - "name": "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8", - "context_limit": 262144 - }, - { - "name": "Qwen/Qwen3-235B-A22B-Instruct-2507-tput", - "context_limit": 262144 - }, - { - "name": "moonshotai/Kimi-K2.5", - "context_limit": 262144 - }, - { - "name": "openai/gpt-oss-120b", - "context_limit": 131072 - } - ], - "preserves_thinking": true, - "supports_streaming": true, - "model_doc_link": "https://docs.together.ai/docs/inference-models", - "setup_steps": [ - "Sign in to https://api.together.xyz", - "Navigate to API Keys in your account settings", - "Create a new API key", - "Copy the key and paste it above" - ] -} diff --git a/crates/goose-providers/src/declarative/macros.rs b/crates/goose-providers/src/declarative/macros.rs deleted file mode 100644 index a2fe7ef1f8..0000000000 --- a/crates/goose-providers/src/declarative/macros.rs +++ /dev/null @@ -1,44 +0,0 @@ -macro_rules! expose_declarative_provider { - ($module:ident, $definition:expr) => { - pub mod $module { - use anyhow::Result; - - use crate::{ - api_client::TlsConfig, - base::Provider, - declarative::{from_json, KeyResolver}, - }; - - pub const JSON: &str = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/src/declarative/definitions/", - $definition, - ".json" - )); - - pub fn create( - tls_config: Option, - key_resolver: impl KeyResolver, - ) -> Result> { - from_json(JSON, tls_config, key_resolver) - } - } - }; -} - -macro_rules! expose_declarative_providers { - ($($module:ident),+ $(,)?) => { - $(expose_declarative_provider!($module, stringify!($module));)+ - - pub(crate) fn fixed_provider_configs() -> anyhow::Result> { - fixed_provider_config_entries() - .into_iter() - .map(|(_, json)| deserialize_provider_config(json)) - .collect() - } - - pub(crate) fn fixed_provider_config_entries() -> Vec<(&'static str, &'static str)> { - vec![$((concat!(stringify!($module), ".json"), $module::JSON)),+] - } - }; -} diff --git a/crates/goose-providers/src/lib.rs b/crates/goose-providers/src/lib.rs deleted file mode 100644 index 77e011b176..0000000000 --- a/crates/goose-providers/src/lib.rs +++ /dev/null @@ -1,21 +0,0 @@ -pub mod anthropic; -pub mod api_client; -pub mod databricks; -pub mod databricks_auth; -pub mod databricks_v2; -pub mod google; -pub use goose_provider_types::{ - base, canonical, conversation, errors, formats, goose_mode, images, json, model, permission, - request_log, retry, thinking, utils, -}; -pub mod declarative; -pub mod http_status; -#[cfg(feature = "local-inference")] -pub mod local_inference; -pub mod ollama; -pub mod openai; -pub mod openai_compatible; - -pub use declarative::declarative_providers::*; - -pub mod snowflake; diff --git a/crates/goose-providers/src/local_inference.rs b/crates/goose-providers/src/local_inference.rs deleted file mode 100644 index 954840b856..0000000000 --- a/crates/goose-providers/src/local_inference.rs +++ /dev/null @@ -1 +0,0 @@ -pub use goose_local_inference::*; diff --git a/crates/goose-providers/src/ollama.rs b/crates/goose-providers/src/ollama.rs deleted file mode 100644 index f6ac65fcc0..0000000000 --- a/crates/goose-providers/src/ollama.rs +++ /dev/null @@ -1,764 +0,0 @@ -use super::api_client::ApiClient; -use super::base::{ConfigKey, MessageStream, Provider, ProviderMetadata}; -use super::openai_compatible::handle_status; -use super::retry::{ProviderRetry, RetryConfig}; -use crate::api_client::{AuthMethod, TlsConfig}; -use crate::base::ProviderDescriptor; -use crate::conversation::message::Message; -use crate::declarative::{DeclarativeProviderConfig, KeyResolver}; -use crate::errors::ProviderError; -use crate::formats::ollama::{create_request, response_to_streaming_message_ollama}; -use crate::images::ImageFormat; -use crate::model::ModelConfig; -use crate::request_log::{start_log, LoggerHandleExt, RequestLogHandle}; -use anyhow::{Error, Result}; -use async_stream::try_stream; -use async_trait::async_trait; -use futures::TryStreamExt; -use reqwest::{Response, StatusCode}; -use rmcp::model::Tool; -use serde_json::{json, Value}; -use std::time::Duration; -use tokio::pin; -use tokio_stream::StreamExt; -use tokio_util::codec::{FramedRead, LinesCodec}; -use tokio_util::io::StreamReader; -use url::Url; - -pub const OLLAMA_PROVIDER_NAME: &str = "ollama"; -pub const OLLAMA_HOST: &str = "localhost"; -pub const OLLAMA_TIMEOUT: u64 = 600; -pub const OLLAMA_DEFAULT_PORT: u16 = 11434; -pub const OLLAMA_DEFAULT_MODEL: &str = "qwen3"; -pub const OLLAMA_KNOWN_MODELS: &[&str] = &[ - OLLAMA_DEFAULT_MODEL, - "qwen3-vl", - "qwen3-coder:30b", - "qwen3-coder:480b-cloud", -]; -pub const OLLAMA_DOC_URL: &str = "https://ollama.com/library"; - -// Ollama-specific retry config: large models can take 30-120s to load into memory, -// during which Ollama returns 500 errors. Use more retries with gradual backoff -// to wait for the model to become ready. -const OLLAMA_MAX_RETRIES: usize = 10; -const OLLAMA_INITIAL_RETRY_INTERVAL_MS: u64 = 2000; -const OLLAMA_BACKOFF_MULTIPLIER: f64 = 1.5; -const OLLAMA_MAX_RETRY_INTERVAL_MS: u64 = 15_000; - -/// Provider settings resolved from `config::Config` at construction time. -/// -/// All values that the Ollama provider reads out of the global config are -/// resolved once, up front, and carried here. This keeps the streaming hot -/// path free of config lookups and makes the provider's config dependencies -/// explicit at the construction boundary. -#[derive(Debug, Clone, serde::Serialize)] -pub struct OllamaOptions { - /// Explicit context window override from `GOOSE_INPUT_LIMIT`. - /// `None` when unset, zero, or invalid; the model's context limit is then - /// used as the fallback. - pub input_limit: Option, - /// Whether to keep `stream_options` in the request (`OLLAMA_STREAM_USAGE`, - /// default `true`). - pub stream_usage: bool, - /// Per-chunk stream timeout in seconds, resolved from - /// `OLLAMA_STREAM_TIMEOUT` > `GOOSE_STREAM_TIMEOUT` > `OLLAMA_TIMEOUT` > - /// default (120s). - pub chunk_timeout_secs: u64, -} - -impl Default for OllamaOptions { - fn default() -> Self { - Self { - input_limit: None, - stream_usage: true, - chunk_timeout_secs: OLLAMA_DEFAULT_CHUNK_TIMEOUT_SECS, - } - } -} - -#[derive(serde::Serialize)] -pub struct OllamaProvider { - #[serde(skip)] - api_client: ApiClient, - name: String, - custom_models: Option>, - dynamic_models: Option, - skip_canonical_filtering: bool, - options: OllamaOptions, -} - -pub struct OllamaProviderBuilder { - api_client: ApiClient, - name: String, - custom_models: Option>, - dynamic_models: Option, - skip_canonical_filtering: bool, - options: OllamaOptions, -} - -impl OllamaProviderBuilder { - pub fn new(api_client: ApiClient) -> Self { - Self { - api_client, - name: OLLAMA_PROVIDER_NAME.to_string(), - custom_models: None, - dynamic_models: None, - skip_canonical_filtering: false, - options: OllamaOptions::default(), - } - } - - pub fn api_client(mut self, api_client: ApiClient) -> Self { - self.api_client = api_client; - self - } - - pub fn map_api_client(mut self, f: impl FnOnce(ApiClient) -> ApiClient) -> Self { - self.api_client = f(self.api_client); - self - } - - pub fn try_map_api_client( - mut self, - f: impl FnOnce(ApiClient) -> Result, - ) -> Result { - self.api_client = f(self.api_client)?; - Ok(self) - } - - pub fn name(mut self, name: impl Into) -> Self { - self.name = name.into(); - self - } - - pub fn custom_models(mut self, custom_models: Option>) -> Self { - self.custom_models = custom_models; - self - } - - pub fn dynamic_models(mut self, dynamic_models: Option) -> Self { - self.dynamic_models = dynamic_models; - self - } - - pub fn skip_canonical_filtering(mut self, skip_canonical_filtering: bool) -> Self { - self.skip_canonical_filtering = skip_canonical_filtering; - self - } - - pub fn options(mut self, options: OllamaOptions) -> Self { - self.options = options; - self - } - - pub fn build(self) -> OllamaProvider { - OllamaProvider { - api_client: self.api_client, - name: self.name, - custom_models: self.custom_models, - dynamic_models: self.dynamic_models, - skip_canonical_filtering: self.skip_canonical_filtering, - options: self.options, - } - } -} - -impl OllamaProvider { - pub fn new( - api_client: ApiClient, - name: String, - skip_canonical_filtering: bool, - options: OllamaOptions, - ) -> Self { - OllamaProviderBuilder::new(api_client) - .name(name) - .skip_canonical_filtering(skip_canonical_filtering) - .options(options) - .build() - } - - pub fn with_options(mut self, options: OllamaOptions) -> Self { - self.options = options; - self - } - - async fn fetch_models_from_api(&self) -> Result, ProviderError> { - fetch_ollama_model_names(&self.api_client) - .await? - .ok_or_else(|| ProviderError::RequestFailed("No models array in response".to_string())) - } -} - -pub async fn fetch_ollama_model_names( - client: &ApiClient, -) -> Result>, ProviderError> { - let response = client - .request("api/tags") - .response_get() - .await - .map_err(|e| ProviderError::RequestFailed(format!("Failed to fetch models: {}", e)))?; - - if response.status() == StatusCode::NOT_FOUND { - return Err(ProviderError::EndpointNotFound( - "Ollama models endpoint not found".to_string(), - )); - } - - if !response.status().is_success() { - return Err(ProviderError::RequestFailed(format!( - "Failed to fetch models: HTTP {}", - response.status() - ))); - } - - let json: Value = response - .json() - .await - .map_err(|e| ProviderError::RequestFailed(format!("Failed to parse response: {}", e)))?; - - let Some(models) = json.get("models").and_then(|m| m.as_array()) else { - return Ok(None); - }; - - let mut names: Vec = models - .iter() - .filter_map(|m| m.get("name").and_then(|n| n.as_str()).map(String::from)) - .collect(); - - names.sort(); - Ok(Some(names)) -} - -fn resolve_ollama_num_ctx(options: &OllamaOptions, model_config: &ModelConfig) -> Option { - options.input_limit.or(model_config.context_limit) -} - -fn apply_ollama_options(payload: &mut Value, options: &OllamaOptions, model_config: &ModelConfig) { - if let Some(obj) = payload.as_object_mut() { - // Gate stream_options behind OLLAMA_STREAM_USAGE (default: true). - // Older Ollama builds that don't support stream_options may stall before - // emitting any SSE data, blocking until the client timeout (600s). - // with_line_timeout() only protects after the first line arrives, so - // users on older builds should set OLLAMA_STREAM_USAGE=false. - if !options.stream_usage { - obj.remove("stream_options"); - } - - // Convert max_completion_tokens / max_tokens to Ollama's options.num_predict. - // Reasoning models emit max_completion_tokens; non-reasoning models emit max_tokens. - let max_tokens = obj - .remove("max_completion_tokens") - .or_else(|| obj.remove("max_tokens")); - if let Some(max_tokens) = max_tokens { - let options_value = obj.entry("options").or_insert_with(|| json!({})); - if let Some(options_obj) = options_value.as_object_mut() { - options_obj.entry("num_predict").or_insert(max_tokens); - } - } - - // Apply num_ctx from context limit settings. - if let Some(limit) = resolve_ollama_num_ctx(options, model_config) { - let options_value = obj.entry("options").or_insert_with(|| json!({})); - if let Some(options_obj) = options_value.as_object_mut() { - options_obj.insert("num_ctx".to_string(), json!(limit)); - } - } - } -} - -pub fn from_declarative_config( - config: DeclarativeProviderConfig, - tls_config: Option, - key_resolver: impl KeyResolver, -) -> Result { - let custom_models = if !config.models.is_empty() { - Some( - config - .models - .iter() - .map(|m| m.name.clone()) - .collect::>(), - ) - } else { - None - }; - - if config.dynamic_models == Some(false) && custom_models.is_none() { - return Err(anyhow::anyhow!( - "Provider '{}' has dynamic_models: false but no static models listed; \ - at least one entry in `models` is required.", - config.name - )); - } - - let timeout = Duration::from_secs(config.timeout_seconds.unwrap_or(OLLAMA_TIMEOUT)); - - let base_has_scheme = - config.base_url.starts_with("http://") || config.base_url.starts_with("https://"); - let base = if base_has_scheme { - config.base_url.clone() - } else { - format!("http://{}", config.base_url) - }; - - let mut base_url = Url::parse(&base) - .map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?; - - let is_localhost = matches!(base_url.host_str(), Some("localhost" | "127.0.0.1" | "::1")); - - if base_url.port().is_none() && !base_has_scheme && is_localhost { - base_url - .set_port(Some(OLLAMA_DEFAULT_PORT)) - .map_err(|_| anyhow::anyhow!("Failed to set default port"))?; - } - - let api_key = if config.api_key_env.is_empty() { - None - } else { - match key_resolver.resolve_key(config.api_key_env.as_str()) { - Ok(key) => Some(key), - Err(err) => { - if config.requires_auth { - anyhow::bail!("missing required key {}: {}", config.api_key_env, err); - } - None - } - } - }; - - let auth = match api_key { - Some(key) if !key.is_empty() => AuthMethod::BearerToken(key), - _ => AuthMethod::NoAuth, - }; - - let mut api_client = - ApiClient::with_timeout_and_tls(base_url.to_string(), auth, timeout, tls_config)?; - - if let Some(headers) = &config.headers { - let mut header_map = reqwest::header::HeaderMap::new(); - for (key, value) in headers { - let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?; - let header_value = reqwest::header::HeaderValue::from_str(value)?; - header_map.insert(header_name, header_value); - } - api_client = api_client.with_headers(header_map)?; - } - - let supports_streaming = config.supports_streaming.unwrap_or(true); - - if !supports_streaming { - return Err(anyhow::anyhow!( - "Ollama provider does not support non-streaming mode. All Ollama models support streaming. \ - Please remove 'supports_streaming: false' from your provider configuration." - )); - } - - Ok(OllamaProviderBuilder::new(api_client) - .name(config.name.clone()) - .custom_models(custom_models) - .dynamic_models(config.dynamic_models) - .skip_canonical_filtering(config.skip_canonical_filtering)) -} - -impl ProviderDescriptor for OllamaProvider { - fn metadata() -> ProviderMetadata { - ProviderMetadata::new( - OLLAMA_PROVIDER_NAME, - "Ollama", - "Local open source models", - OLLAMA_DEFAULT_MODEL, - OLLAMA_KNOWN_MODELS.to_vec(), - OLLAMA_DOC_URL, - vec![ - ConfigKey::new("OLLAMA_HOST", true, false, Some(OLLAMA_HOST), true), - ConfigKey::new( - "OLLAMA_TIMEOUT", - false, - false, - Some(&(OLLAMA_TIMEOUT.to_string())), - false, - ), - ], - ) - } -} - -#[async_trait] -impl Provider for OllamaProvider { - fn get_name(&self) -> &str { - &self.name - } - - fn skip_canonical_filtering(&self) -> bool { - self.skip_canonical_filtering - } - - fn retry_config(&self) -> RetryConfig { - RetryConfig::new( - OLLAMA_MAX_RETRIES, - OLLAMA_INITIAL_RETRY_INTERVAL_MS, - OLLAMA_BACKOFF_MULTIPLIER, - OLLAMA_MAX_RETRY_INTERVAL_MS, - ) - .transient_only() - } - - async fn stream( - &self, - model_config: &ModelConfig, - system: &str, - messages: &[Message], - tools: &[Tool], - ) -> Result { - let mut payload = create_request( - model_config, - system, - messages, - tools, - &ImageFormat::OpenAi, - true, - )?; - apply_ollama_options(&mut payload, &self.options, model_config); - let mut log = start_log(model_config, &payload)?; - - let response = self - .with_retry(|| async { - let resp = self - .api_client - .response_post("v1/chat/completions", &payload) - .await?; - handle_status(resp).await - }) - .await - .inspect_err(|e| { - let _ = log.error(e); - })?; - stream_ollama(response, self.options.chunk_timeout_secs, log) - } - - async fn fetch_supported_models(&self) -> Result, ProviderError> { - if let Some(custom_models) = &self.custom_models { - if self.dynamic_models == Some(false) { - return Ok(custom_models.clone()); - } - - match self.fetch_models_from_api().await { - Ok(models) => return Ok(models), - Err(e) if e.is_endpoint_not_found() => { - tracing::debug!( - "Models endpoint not implemented for provider '{}' ({}), using predefined list", - self.name, - e - ); - return Ok(custom_models.clone()); - } - Err(e) => return Err(e), - } - } - - self.fetch_models_from_api().await - } -} - -/// Default per-chunk timeout for Ollama streaming responses (seconds). -/// Configurable via OLLAMA_STREAM_TIMEOUT, GOOSE_STREAM_TIMEOUT, or falls back -/// to OLLAMA_TIMEOUT. Set high to accommodate slower models (CPU inference, -/// large parameter counts, complex reasoning). -pub const OLLAMA_DEFAULT_CHUNK_TIMEOUT_SECS: u64 = 120; - -/// Wraps a line stream with a per-item timeout at the raw SSE level. -/// This detects dead connections without false-positive stalls during long -/// tool-call generations where response_to_streaming_message_ollama buffers. -fn with_line_timeout( - stream: impl futures::Stream> + Unpin + Send + 'static, - timeout_secs: u64, -) -> std::pin::Pin> + Send>> { - let timeout = Duration::from_secs(timeout_secs); - Box::pin(try_stream! { - let mut stream = stream; - - // Allow time-to-first-token to be governed by the request timeout. - // Only enforce per-chunk timeout after first SSE line arrives. - match stream.next().await { - Some(first_item) => yield first_item?, - None => return, - } - loop { - match tokio::time::timeout(timeout, stream.next()).await { - Ok(Some(item)) => yield item?, - Ok(None) => break, - Err(_) => { - Err::<(), anyhow::Error>(anyhow::anyhow!( - "Ollama stream stalled: no data received for {}s. \ - This may indicate the model is overwhelmed by the request payload. \ - Try a smaller model, reduce the number of tools, or increase the \ - timeout via OLLAMA_STREAM_TIMEOUT, GOOSE_STREAM_TIMEOUT, or \ - OLLAMA_TIMEOUT in your config.", - timeout_secs - ))?; - } - } - } - }) -} - -/// Ollama-specific streaming handler with XML tool call fallback. -/// Uses the Ollama format module which buffers text when XML tool calls are detected, -/// preventing duplicate content from being emitted to the UI. -/// Timeout is applied at the raw SSE line level via with_line_timeout so that -/// buffering inside response_to_streaming_message_ollama does not cause false stalls. -fn stream_ollama( - response: Response, - chunk_timeout: u64, - mut log: Option>, -) -> Result { - let stream = response.bytes_stream().map_err(std::io::Error::other); - - Ok(Box::pin(try_stream! { - let stream_reader = StreamReader::new(stream); - let framed = FramedRead::new(stream_reader, LinesCodec::new()) - .map_err(Error::from); - - let timed_lines = with_line_timeout(framed, chunk_timeout); - let message_stream = response_to_streaming_message_ollama(timed_lines); - pin!(message_stream); - - while let Some(message) = message_stream.next().await { - let (message, usage) = message.map_err(ProviderError::from_stream_error)?; - log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?; - yield (message, usage); - } - })) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::base::ModelInfo; - - fn ollama_config( - dynamic_models: Option, - models: Vec, - ) -> DeclarativeProviderConfig { - ollama_config_with_base_url(dynamic_models, models, "http://localhost:11434") - } - - fn ollama_config_with_base_url( - dynamic_models: Option, - models: Vec, - base_url: &str, - ) -> DeclarativeProviderConfig { - DeclarativeProviderConfig { - name: "test-ollama".to_string(), - engine: crate::declarative::ProviderEngine::Ollama, - display_name: "Test Ollama".to_string(), - description: None, - api_key_env: String::new(), - base_url: base_url.to_string(), - models, - headers: None, - timeout_seconds: None, - supports_streaming: None, - requires_auth: false, - catalog_provider_id: None, - base_path: None, - env_vars: None, - dynamic_models, - skip_canonical_filtering: false, - model_doc_link: None, - setup_steps: vec![], - fast_model: None, - preserves_thinking: false, - } - } - - #[tokio::test] - async fn fetch_supported_models_uses_static_models_when_dynamic_models_false() { - let provider = from_declarative_config( - ollama_config(Some(false), vec![ModelInfo::new("static-model", 4096)]), - None, - crate::declarative::EnvKeyResolver, - ) - .unwrap() - .build(); - - assert_eq!( - provider.fetch_supported_models().await.unwrap(), - vec!["static-model".to_string()] - ); - } - - #[test] - fn from_custom_config_requires_static_models_when_dynamic_models_false() { - let err = from_declarative_config( - ollama_config(Some(false), vec![]), - None, - crate::declarative::EnvKeyResolver, - ) - .err() - .expect("expected static models validation error"); - - assert!(err - .to_string() - .contains("dynamic_models: false but no static models listed")); - } - - #[tokio::test] - async fn fetch_supported_models_falls_back_to_static_models_on_404() { - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/tags")) - .respond_with(ResponseTemplate::new(404)) - .expect(1) - .mount(&server) - .await; - - let provider = from_declarative_config( - ollama_config_with_base_url( - None, - vec![ModelInfo::new("static-model", 4096)], - &server.uri(), - ), - None, - crate::declarative::EnvKeyResolver, - ) - .unwrap() - .build(); - - assert_eq!( - provider.fetch_supported_models().await.unwrap(), - vec!["static-model".to_string()] - ); - } - - #[test] - fn test_apply_ollama_options_uses_input_limit() { - let options = OllamaOptions { - input_limit: Some(8192), - ..Default::default() - }; - let model_config = ModelConfig::new("qwen3").with_context_limit(Some(16_000)); - let mut payload = json!({}); - apply_ollama_options(&mut payload, &options, &model_config); - assert_eq!(payload["options"]["num_ctx"], 8192); - } - - #[test] - fn test_apply_ollama_options_falls_back_to_context_limit() { - let options = OllamaOptions::default(); - let model_config = ModelConfig::new("qwen3").with_context_limit(Some(12_000)); - let mut payload = json!({}); - apply_ollama_options(&mut payload, &options, &model_config); - assert_eq!(payload["options"]["num_ctx"], 12_000); - } - - #[test] - fn test_apply_ollama_options_skips_when_no_limit() { - let options = OllamaOptions::default(); - let mut model_config = ModelConfig::new("qwen3"); - model_config.context_limit = None; - let mut payload = json!({}); - apply_ollama_options(&mut payload, &options, &model_config); - assert!(payload.get("options").is_none()); - } - - #[test] - fn test_raw_create_request_contains_unsupported_ollama_fields() { - use crate::formats::ollama::create_request; - - let model_config = ModelConfig::new("llama3.1").with_max_tokens(Some(4096)); - let messages = vec![crate::conversation::message::Message::user().with_text("hi")]; - - let payload = create_request( - &model_config, - "You are a helpful assistant.", - &messages, - &[], - &ImageFormat::OpenAi, - true, - ) - .unwrap(); - - assert!( - payload.get("stream_options").is_some(), - "create_request should produce stream_options for usage tracking" - ); - assert!( - payload.get("max_tokens").is_some(), - "create_request should produce max_tokens (unsupported by Ollama)" - ); - } - - #[test] - fn test_apply_ollama_options_preserves_stream_options_by_default() { - use crate::formats::ollama::create_request; - - let options = OllamaOptions::default(); - let model_config = ModelConfig::new("llama3.1").with_max_tokens(Some(4096)); - let messages = vec![crate::conversation::message::Message::user().with_text("hi")]; - - let mut payload = create_request( - &model_config, - "You are a helpful assistant.", - &messages, - &[], - &ImageFormat::OpenAi, - true, - ) - .unwrap(); - - apply_ollama_options(&mut payload, &options, &model_config); - - assert!( - payload.get("stream_options").is_some(), - "stream_options should be preserved by default for usage tracking" - ); - assert!( - payload.get("max_tokens").is_none(), - "max_tokens should be removed for Ollama" - ); - assert!( - payload.get("max_completion_tokens").is_none(), - "max_completion_tokens should be removed for Ollama" - ); - assert_eq!( - payload["options"]["num_predict"], 4096, - "max_tokens should be moved to options.num_predict" - ); - assert_eq!(payload["stream"], true, "stream field should be preserved"); - } - - #[test] - fn test_apply_ollama_options_strips_stream_options_when_disabled() { - use crate::formats::ollama::create_request; - - let options = OllamaOptions { - input_limit: None, - stream_usage: false, - chunk_timeout_secs: 120, - }; - let model_config = ModelConfig::new("llama3.1").with_max_tokens(Some(4096)); - let messages = vec![crate::conversation::message::Message::user().with_text("hi")]; - - let mut payload = create_request( - &model_config, - "You are a helpful assistant.", - &messages, - &[], - &ImageFormat::OpenAi, - true, - ) - .unwrap(); - - apply_ollama_options(&mut payload, &options, &model_config); - - assert!( - payload.get("stream_options").is_none(), - "stream_options should be removed when OLLAMA_STREAM_USAGE=false" - ); - } -} diff --git a/crates/goose-providers/src/openai.rs b/crates/goose-providers/src/openai.rs deleted file mode 100644 index 3c920ca144..0000000000 --- a/crates/goose-providers/src/openai.rs +++ /dev/null @@ -1,1220 +0,0 @@ -use super::api_client::ApiClient; -use super::base::{ConfigKey, ModelInfo, Provider, ProviderMetadata}; -use super::retry::ProviderRetry; -use crate::api_client::{AuthMethod, TlsConfig}; -use crate::conversation::message::Message; -use crate::conversation::token_usage::{CostSource, ProviderUsage}; -use crate::declarative::{DeclarativeProviderConfig, KeyResolver}; -use crate::errors::ProviderError; -use crate::formats::openai::is_openai_responses_model; -use crate::formats::openai::{ - create_request_with_options, get_cost, get_usage, response_to_message, OpenAiFormatOptions, -}; -use crate::formats::openai_responses::{ - create_responses_request, get_responses_usage, responses_api_to_message, ResponsesApiResponse, -}; -use crate::images::ImageFormat; -use crate::openai_compatible::{ - handle_response_openai_compat, handle_status, stream_openai_compat, stream_responses_compat, -}; -use crate::request_log::{start_log, LoggerHandleExt}; -use anyhow::Result; -use async_trait::async_trait; -use reqwest::StatusCode; -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -use crate::base::{MessageStream, ProviderDescriptor}; -use crate::model::ModelConfig; -use rmcp::model::Tool; - -pub const OPEN_AI_PROVIDER_NAME: &str = "openai"; -pub const OPEN_AI_DEFAULT_BASE_PATH: &str = "v1/chat/completions"; -pub const OPEN_AI_VERSIONLESS_BASE_PATH: &str = "chat/completions"; -const OPEN_AI_DEFAULT_RESPONSES_PATH: &str = "v1/responses"; -const OPEN_AI_DEFAULT_MODELS_PATH: &str = "v1/models"; -pub const OPEN_AI_DEFAULT_MODEL: &str = "gpt-4o"; -pub const OPEN_AI_DEFAULT_FAST_MODEL: &str = "gpt-4o-mini"; -pub const OPEN_AI_KNOWN_MODELS: &[(&str, usize)] = &[ - ("gpt-4o", 128_000), - ("gpt-4o-mini", 128_000), - ("gpt-4.1", 128_000), - ("gpt-4.1-mini", 128_000), - ("o1", 200_000), - ("o3", 200_000), - ("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-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), - ("gpt-5.5", 1_050_000), - ("gpt-5.5-pro", 1_050_000), - ("gpt-5.6-luna", 1_050_000), - ("gpt-5.6-sol", 1_050_000), - ("gpt-5.6-terra", 1_050_000), -]; - -pub const OPEN_AI_DOC_URL: &str = "https://platform.openai.com/docs/models"; -const DEFAULT_TIMEOUT_SECONDS: u64 = 600; - -type OpenAiBaseUrlParts = (String, Vec<(String, String)>, bool); - -/// Ensure a base URL has an explicit scheme. -/// -/// Users frequently enter hosts like `localhost:1234` without a scheme. The -/// `url` crate parses such input as `scheme="localhost"`, `path="1234"`, -/// silently dropping both the host and the port. When no `://` is present we -/// prepend a sensible scheme (`http://` for local hosts, `https://` -/// otherwise) so the host and port survive parsing. -pub fn ensure_url_scheme(raw_url: &str) -> String { - let trimmed = raw_url.trim(); - if trimmed.contains("://") { - return trimmed.to_string(); - } - - let host_part = trimmed.split(['/', '?']).next().unwrap_or(trimmed); - let bare_host = if let Some(rest) = host_part.strip_prefix('[') { - rest.split(']').next().unwrap_or(rest) - } else { - host_part.split(':').next().unwrap_or(host_part) - }; - let is_local = bare_host == "localhost" - || bare_host == "127.0.0.1" - || bare_host == "0.0.0.0" - || bare_host == "::1"; - - let scheme = if is_local { "http" } else { "https" }; - format!("{}://{}", scheme, trimmed) -} - -pub fn parse_openai_base_url(raw_url: &str) -> Result { - let raw_url = ensure_url_scheme(raw_url); - let raw_url = raw_url.as_str(); - let parsed = url::Url::parse(raw_url) - .map_err(|e| anyhow::anyhow!("Invalid OPENAI_BASE_URL '{}': {}", raw_url, e))?; - - let authority = parsed[..url::Position::BeforePath].to_string(); - let query_params: Vec<(String, String)> = parsed - .query_pairs() - .map(|(k, v)| (k.into_owned(), v.into_owned())) - .collect(); - - let path = parsed.path().trim_end_matches('/'); - if path.is_empty() || path == "/" { - return Ok((authority, query_params, true)); - } - - if path == "/v1" { - return Ok((authority, query_params, true)); - } - if let Some(prefix) = path.strip_suffix("/v1") { - return Ok((format!("{}{}", authority, prefix), query_params, true)); - } - - Ok((format!("{}{}", authority, path), query_params, false)) -} - -#[derive(Debug, serde::Serialize)] -pub struct OpenAiProvider { - #[serde(skip)] - api_client: ApiClient, - base_path: String, - organization: Option, - project: Option, - custom_headers: Option>, - supports_streaming: bool, - name: String, - custom_models: Option>, - dynamic_models: Option, - skip_canonical_filtering: bool, - preserve_thinking_context: bool, - #[serde(skip)] - n_ctx_cache: Arc>>>, -} - -/// Builder for [`OpenAiProvider`]. -/// -/// Exposes every field of the provider so that constructors living outside -/// `openai.rs` (e.g. in `openai_def.rs`) can assemble a provider without -/// needing direct access to the struct's private fields. -pub struct OpenAiProviderBuilder { - api_client: ApiClient, - base_path: String, - organization: Option, - project: Option, - custom_headers: Option>, - supports_streaming: bool, - name: String, - custom_models: Option>, - dynamic_models: Option, - skip_canonical_filtering: bool, - preserve_thinking_context: bool, -} - -impl OpenAiProviderBuilder { - pub fn new(api_client: ApiClient) -> Self { - Self { - api_client, - base_path: OPEN_AI_DEFAULT_BASE_PATH.to_string(), - organization: None, - project: None, - custom_headers: None, - supports_streaming: true, - name: OPEN_AI_PROVIDER_NAME.to_string(), - custom_models: None, - dynamic_models: None, - skip_canonical_filtering: false, - preserve_thinking_context: false, - } - } - - pub fn api_client(mut self, api_client: ApiClient) -> Self { - self.api_client = api_client; - self - } - - pub fn map_api_client(mut self, f: impl FnOnce(ApiClient) -> ApiClient) -> Self { - self.api_client = f(self.api_client); - self - } - - pub fn try_map_api_client( - mut self, - f: impl FnOnce(ApiClient) -> Result, - ) -> Result { - self.api_client = f(self.api_client)?; - Ok(self) - } - - pub fn base_path(mut self, base_path: impl Into) -> Self { - self.base_path = base_path.into(); - self - } - - pub fn organization(mut self, organization: Option) -> Self { - self.organization = organization; - self - } - - pub fn project(mut self, project: Option) -> Self { - self.project = project; - self - } - - pub fn custom_headers(mut self, custom_headers: Option>) -> Self { - self.custom_headers = custom_headers; - self - } - - pub fn supports_streaming(mut self, supports_streaming: bool) -> Self { - self.supports_streaming = supports_streaming; - self - } - - pub fn name(mut self, name: impl Into) -> Self { - self.name = name.into(); - self - } - - pub fn custom_models(mut self, custom_models: Option>) -> Self { - self.custom_models = custom_models; - self - } - - pub fn dynamic_models(mut self, dynamic_models: Option) -> Self { - self.dynamic_models = dynamic_models; - self - } - - pub fn skip_canonical_filtering(mut self, skip_canonical_filtering: bool) -> Self { - self.skip_canonical_filtering = skip_canonical_filtering; - self - } - - pub fn preserve_thinking_context(mut self, preserve_thinking_context: bool) -> Self { - self.preserve_thinking_context = preserve_thinking_context; - self - } - - pub fn build(self) -> OpenAiProvider { - OpenAiProvider { - api_client: self.api_client, - base_path: self.base_path, - organization: self.organization, - project: self.project, - custom_headers: self.custom_headers, - supports_streaming: self.supports_streaming, - name: self.name, - custom_models: self.custom_models, - dynamic_models: self.dynamic_models, - skip_canonical_filtering: self.skip_canonical_filtering, - preserve_thinking_context: self.preserve_thinking_context, - n_ctx_cache: Arc::new(Mutex::new(HashMap::new())), - } - } -} - -impl OpenAiProvider { - #[doc(hidden)] - pub fn new(api_client: ApiClient) -> Self { - Self { - api_client, - base_path: OPEN_AI_DEFAULT_BASE_PATH.to_string(), - organization: None, - project: None, - custom_headers: None, - supports_streaming: true, - name: OPEN_AI_PROVIDER_NAME.to_string(), - custom_models: None, - dynamic_models: None, - skip_canonical_filtering: false, - preserve_thinking_context: false, - n_ctx_cache: Arc::new(Mutex::new(HashMap::new())), - } - } - - fn normalize_base_path(base_path: &str) -> String { - if let Some(path) = base_path.strip_prefix('/') { - format!("/{}", path.trim_end_matches('/')) - } else { - base_path.trim_end_matches('/').to_string() - } - } - - fn is_chat_completions_path(base_path: &str) -> bool { - let normalized = Self::normalize_base_path(base_path).to_ascii_lowercase(); - normalized.contains("chat/completions") - } - - fn is_responses_path(base_path: &str) -> bool { - let normalized = Self::normalize_base_path(base_path).to_ascii_lowercase(); - normalized.ends_with("responses") || normalized.contains("/responses") - } - - fn is_responses_model(model_name: &str) -> bool { - is_openai_responses_model(model_name) - } - - fn should_use_responses_api(model_name: &str, base_path: &str) -> bool { - let normalized_base_path = Self::normalize_base_path(base_path); - // Only the standard "v1/chat/completions" is treated as a default - // path that defers to model-based routing. The versionless - // "chat/completions" (derived from an OPENAI_BASE_URL without /v1) - // is treated as custom because versionless gateways typically do not - // support the Responses API. - let has_custom_base_path = normalized_base_path != OPEN_AI_DEFAULT_BASE_PATH; - - if has_custom_base_path { - if Self::is_responses_path(&normalized_base_path) { - return true; - } - if Self::is_chat_completions_path(&normalized_base_path) { - return false; - } - } - - Self::is_responses_model(model_name) - } - - /// Providers known to reject `max_completion_tokens` and require - /// the legacy `max_tokens` field instead. - const PROVIDERS_NEEDING_MAX_TOKENS_REMAP: &[&str] = &[ - "cerebras", - "custom_deepseek", - "groq", - "inception", - "kimi", - "lmstudio", - "mistral", - "moonshot", - "nearai", - "ovhcloud", - ]; - - const PROVIDERS_NEEDING_STANDARD_CHAT_PARAMS: &[&str] = &["nearai"]; - - fn sanitize_request_for_compat(&self, mut payload: serde_json::Value) -> serde_json::Value { - if let Some(obj) = payload.as_object_mut() { - if Self::PROVIDERS_NEEDING_MAX_TOKENS_REMAP.contains(&self.name.as_str()) { - if let Some(value) = obj.remove("max_completion_tokens") { - obj.entry("max_tokens").or_insert(value); - } - } - - if Self::PROVIDERS_NEEDING_STANDARD_CHAT_PARAMS.contains(&self.name.as_str()) { - let model_name = obj.get("model").and_then(|model| model.as_str()); - if !model_name.is_some_and(Self::is_responses_model) { - obj.remove("reasoning_effort"); - } - - if let Some(messages) = obj.get_mut("messages").and_then(|m| m.as_array_mut()) { - for message in messages { - if message - .get("role") - .and_then(|role| role.as_str()) - .is_some_and(|role| role == "developer") - { - message["role"] = serde_json::Value::String("system".to_string()); - } - } - } - } - } - - payload - } - - fn should_use_responses_api_for_provider(&self, model_name: &str) -> bool { - if Self::PROVIDERS_NEEDING_STANDARD_CHAT_PARAMS.contains(&self.name.as_str()) { - return false; - } - - Self::should_use_responses_api(model_name, &self.base_path) - } - - fn map_base_path(base_path: &str, target: &str, fallback: &str) -> String { - let normalized = Self::normalize_base_path(base_path); - if normalized.ends_with(target) || normalized.contains(&format!("/{target}")) { - return normalized; - } - - if Self::is_chat_completions_path(&normalized) { - return normalized.replacen("chat/completions", target, 1); - } - - if Self::is_responses_path(&normalized) { - return normalized.replacen("responses", target, 1); - } - - if normalized.starts_with('/') { - format!("/{}", fallback.trim_start_matches('/')) - } else { - fallback.to_string() - } - } - - async fn fetch_models_from_api(&self) -> Result, ProviderError> { - let models_path = - Self::map_base_path(&self.base_path, "models", OPEN_AI_DEFAULT_MODELS_PATH); - let response = self.api_client.request(&models_path).response_get().await?; - - if response.status() == StatusCode::NOT_FOUND { - let body = response.text().await.unwrap_or_default(); - return Err(ProviderError::EndpointNotFound(body)); - } - - let json = handle_response_openai_compat(response).await?; - if let Some(err_obj) = json.get("error") { - let msg = err_obj - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or("unknown error"); - return Err(ProviderError::Authentication(msg.to_string())); - } - - let data = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| { - ProviderError::UsageError("Missing data field in JSON response".into()) - })?; - let mut models: Vec = data - .iter() - .filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(str::to_string)) - .collect(); - models.sort(); - Ok(models) - } - - /// llama.cpp and Ollama expose the actual allocated context window in the - /// non-standard `meta.n_ctx` field of `/v1/models`. Returns `None` when absent - /// (e.g. real OpenAI). - async fn fetch_n_ctx_from_api(&self, model_name: &str) -> Option { - let models_path = - Self::map_base_path(&self.base_path, "models", OPEN_AI_DEFAULT_MODELS_PATH); - let response = self - .api_client - .request(&models_path) - .response_get() - .await - .ok()?; - let json = handle_response_openai_compat(response).await.ok()?; - parse_n_ctx_from_models(&json, model_name) - } -} - -/// Extract `meta.n_ctx` for `model_name` from a `/v1/models` response body. -fn parse_n_ctx_from_models(json: &serde_json::Value, model_name: &str) -> Option { - let data = json.get("data")?.as_array()?; - - let n_ctx = |entry: &serde_json::Value| -> Option { - entry - .get("meta")? - .get("n_ctx")? - .as_u64() - .map(|v| v as usize) - }; - - if let Some(entry) = data - .iter() - .find(|e| e.get("id").and_then(|v| v.as_str()) == Some(model_name)) - { - return n_ctx(entry); - } - - // For single-model servers without --alias, llama.cpp reports the loaded model - // file path as id rather than the client's alias, so no entry matches above. - // Fall back to the sole entry's n_ctx. - match data.as_slice() { - [only] => n_ctx(only), - _ => None, - } -} - -impl ProviderDescriptor for OpenAiProvider { - fn metadata() -> ProviderMetadata { - let models = OPEN_AI_KNOWN_MODELS - .iter() - .map(|(name, limit)| ModelInfo::new(*name, *limit)) - .collect(); - ProviderMetadata::with_models( - OPEN_AI_PROVIDER_NAME, - "OpenAI", - "GPT-4 and other OpenAI models, including OpenAI compatible ones", - OPEN_AI_DEFAULT_MODEL, - models, - OPEN_AI_DOC_URL, - vec![ - ConfigKey::new("OPENAI_API_KEY", false, true, None, true), - ConfigKey::new("OPENAI_BASE_URL", false, false, None, false), - ConfigKey::new( - "OPENAI_HOST", - true, - false, - Some("https://api.openai.com"), - false, - ), - ConfigKey::new( - "OPENAI_BASE_PATH", - true, - false, - Some("v1/chat/completions"), - false, - ), - ConfigKey::new("OPENAI_ORGANIZATION", false, false, None, false), - ConfigKey::new("OPENAI_PROJECT", false, false, None, false), - ConfigKey::new("OPENAI_CUSTOM_HEADERS", false, true, None, false), - ConfigKey::new("OPENAI_TIMEOUT", false, false, Some("600"), false), - ], - ) - .with_setup_steps(vec![ - "Go to https://platform.openai.com and sign up or log in", - "Navigate to API Keys in the left sidebar", - "Click 'Create new secret key'", - "Copy the key and paste it above", - ]) - } -} - -#[async_trait] -impl Provider for OpenAiProvider { - fn get_name(&self) -> &str { - &self.name - } - - fn skip_canonical_filtering(&self) -> bool { - self.skip_canonical_filtering - } - - /// Resolve the effective context limit. When the config carries an explicit - /// limit (GOOSE_CONTEXT_LIMIT, a session override, or a known/canonical - /// value) it is used as-is. Otherwise probe `/v1/models`: llama.cpp and - /// Ollama report the real allocated window via the non-standard - /// `meta.n_ctx` field, which fixes auto-compaction for local servers that - /// would otherwise fall back to DEFAULT_CONTEXT_LIMIT. The probe is bounded - /// by a short timeout so a hung endpoint can't stall the caller. - async fn get_context_limit(&self, model_config: &ModelConfig) -> Result { - if let Some(limit) = model_config.context_limit { - return Ok(limit); - } - - if let Some(cached) = self - .n_ctx_cache - .lock() - .ok() - .and_then(|cache| cache.get(&model_config.model_name).copied()) - { - return Ok(cached.unwrap_or_else(|| model_config.context_limit())); - } - - const N_CTX_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); - let probed = tokio::time::timeout( - N_CTX_PROBE_TIMEOUT, - self.fetch_n_ctx_from_api(&model_config.model_name), - ) - .await - .ok() - .flatten(); - - if let Ok(mut cache) = self.n_ctx_cache.lock() { - cache.insert(model_config.model_name.clone(), probed); - } - - Ok(probed.unwrap_or_else(|| model_config.context_limit())) - } - - async fn fetch_supported_models(&self) -> Result, ProviderError> { - if let Some(custom_models) = &self.custom_models { - if self.dynamic_models == Some(false) { - return Ok(custom_models.clone()); - } - match self.fetch_models_from_api().await { - Ok(models) => return Ok(models), - Err(e) if e.is_endpoint_not_found() => { - tracing::debug!( - "Models endpoint not implemented for provider '{}' ({}), using predefined list", - self.name, - e - ); - return Ok(custom_models.clone()); - } - Err(e) => return Err(e), - } - } - - self.fetch_models_from_api().await - } - - async fn stream( - &self, - model_config: &ModelConfig, - system: &str, - messages: &[Message], - tools: &[Tool], - ) -> Result { - if self.should_use_responses_api_for_provider(&model_config.model_name) { - let mut payload = create_responses_request(model_config, system, messages, tools)?; - payload["stream"] = serde_json::Value::Bool(self.supports_streaming); - - let mut log = start_log(model_config, &payload)?; - - let response = self - .with_retry(|| async { - let payload_clone = payload.clone(); - let resp = self - .api_client - .response_post( - &Self::map_base_path( - &self.base_path, - "responses", - OPEN_AI_DEFAULT_RESPONSES_PATH, - ), - &payload_clone, - ) - .await?; - handle_status(resp).await - }) - .await - .inspect_err(|e| { - let _ = log.error(e); - })?; - - if self.supports_streaming { - stream_responses_compat(response, log) - } else { - let json: serde_json::Value = response.json().await.map_err(|e| { - ProviderError::RequestFailed(format!("Failed to parse JSON: {}", e)) - })?; - - let responses_api_response: ResponsesApiResponse = - serde_json::from_value(json.clone()).map_err(|e| { - ProviderError::ExecutionError(format!( - "Failed to parse responses API response: {}", - e - )) - })?; - - let message = responses_api_to_message(&responses_api_response)?; - let usage_data = get_responses_usage(&responses_api_response); - let usage_json = json.get("usage").unwrap_or(&serde_json::Value::Null); - let mut usage = ProviderUsage::new(model_config.model_name.clone(), usage_data); - if let Some(cost) = get_cost(usage_json) { - usage = usage.with_cost(cost, CostSource::ProviderReported); - } - - log.write( - &serde_json::to_value(&message).unwrap_or_default(), - Some(&usage_data), - )?; - - Ok(super::base::stream_from_single_message(message, usage)) - } - } else { - let payload = create_request_with_options( - model_config, - system, - messages, - tools, - &ImageFormat::OpenAi, - self.supports_streaming, - OpenAiFormatOptions { - preserve_thinking_context: self.preserve_thinking_context, - }, - )?; - let payload = self.sanitize_request_for_compat(payload); - let mut log = start_log(model_config, &payload)?; - - let response = self - .with_retry(|| async { - let resp = self - .api_client - .response_post(&self.base_path, &payload) - .await?; - handle_status(resp).await - }) - .await - .inspect_err(|e| { - let _ = log.error(e); - })?; - - if self.supports_streaming { - stream_openai_compat(response, log) - } else { - let json: serde_json::Value = response.json().await.map_err(|e| { - ProviderError::RequestFailed(format!("Failed to parse JSON: {}", e)) - })?; - - let message = response_to_message(&json).map_err(|e| { - ProviderError::RequestFailed(format!("Failed to parse message: {}", e)) - })?; - - let usage_json = json.get("usage").unwrap_or(&serde_json::Value::Null); - let usage_data = get_usage(usage_json); - let mut usage = ProviderUsage::new(model_config.model_name.clone(), usage_data); - if let Some(cost) = get_cost(usage_json) { - usage = usage.with_cost(cost, CostSource::ProviderReported); - } - - log.write( - &serde_json::to_value(&message).unwrap_or_default(), - Some(&usage_data), - )?; - - Ok(super::base::stream_from_single_message(message, usage)) - } - } - } -} - -pub fn from_declarative_config( - config: DeclarativeProviderConfig, - tls_config: Option, - key_resolver: impl KeyResolver, -) -> Result { - let custom_models = if !config.models.is_empty() { - Some( - config - .models - .iter() - .map(|m| m.name.clone()) - .collect::>(), - ) - } else { - None - }; - - if config.dynamic_models == Some(false) && custom_models.is_none() { - return Err(anyhow::anyhow!( - "Provider '{}' has dynamic_models: false but no static models listed; \ - at least one entry in `models` is required.", - config.name - )); - } - - let api_key = if config.api_key_env.is_empty() { - None - } else { - match key_resolver.resolve_key(config.api_key_env.as_str()) { - Ok(key) => Some(key), - Err(err) => { - if config.requires_auth { - anyhow::bail!("missing required key {}: {}", config.api_key_env, err); - } - None - } - } - }; - - let normalized_base_url = ensure_url_scheme(&config.base_url); - let url = url::Url::parse(&normalized_base_url) - .map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?; - - let host = url[..url::Position::BeforePath].to_string(); - let base_path = if let Some(ref explicit_path) = config.base_path { - explicit_path.trim_start_matches('/').to_string() - } else { - derive_base_path(url.path()) - }; - - let timeout_secs = config.timeout_seconds.unwrap_or(DEFAULT_TIMEOUT_SECONDS); - - let auth = match api_key { - Some(key) if !key.is_empty() => AuthMethod::BearerToken(key), - _ => AuthMethod::NoAuth, - }; - let mut api_client = ApiClient::with_timeout_and_tls( - host, - auth, - std::time::Duration::from_secs(timeout_secs), - tls_config, - )?; - - if let Some(query) = url.query() { - let query_params = url::form_urlencoded::parse(query.as_bytes()) - .map(|(key, value)| (key.into_owned(), value.into_owned())) - .collect(); - api_client = api_client.with_query(query_params); - } - - if let Some(headers) = &config.headers { - let mut header_map = reqwest::header::HeaderMap::new(); - for (key, value) in headers { - let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?; - let header_value = reqwest::header::HeaderValue::from_str(value)?; - header_map.insert(header_name, header_value); - } - api_client = api_client.with_headers(header_map)?; - } - - Ok(OpenAiProviderBuilder::new(api_client) - .base_path(base_path) - .custom_headers(config.headers) - .supports_streaming(config.supports_streaming.unwrap_or(true)) - .name(config.name.clone()) - .custom_models(custom_models) - .dynamic_models(config.dynamic_models) - .skip_canonical_filtering(config.skip_canonical_filtering) - .preserve_thinking_context(config.preserves_thinking)) -} - -pub fn parse_custom_headers(s: String) -> HashMap { - s.split(',') - .filter_map(|header| { - let mut parts = header.splitn(2, '='); - let key = parts.next().map(|s| s.trim().to_string())?; - let value = parts.next().map(|s| s.trim().to_string())?; - Some((key, value)) - }) - .collect() -} - -pub 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.ends_with("chat/completions") { - stripped.to_string() - } else if ends_with_version_segment(normalized) { - format!("{}/chat/completions", normalized) - } else { - format!("{}/v1/chat/completions", normalized) - } -} - -fn ends_with_version_segment(path: &str) -> bool { - let last = path.rsplit('/').next().unwrap_or(path); - last.strip_prefix('v') - .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::api_client::AuthMethod; - use serde_json::json; - - #[test] - fn gpt_5_5_and_5_6_models_have_expected_context_limits() { - for model in [ - "gpt-5.5", - "gpt-5.5-pro", - "gpt-5.6-luna", - "gpt-5.6-sol", - "gpt-5.6-terra", - ] { - assert_eq!( - OPEN_AI_KNOWN_MODELS - .iter() - .find(|(name, _)| *name == model) - .map(|(_, limit)| *limit), - Some(1_050_000), - "unexpected context limit for {model}" - ); - } - } - - fn make_provider(name: &str) -> OpenAiProvider { - OpenAiProvider { - api_client: ApiClient::new_with_tls( - "http://localhost".to_string(), - AuthMethod::NoAuth, - None, - ) - .unwrap(), - base_path: "v1/chat/completions".to_string(), - organization: None, - project: None, - custom_headers: None, - supports_streaming: true, - name: name.to_string(), - custom_models: None, - dynamic_models: None, - skip_canonical_filtering: false, - preserve_thinking_context: false, - n_ctx_cache: Arc::new(Mutex::new(HashMap::new())), - } - } - - #[test] - fn sanitize_remaps_max_completion_tokens_for_compat_provider() { - let provider = make_provider("mistral"); - let payload = json!({ - "model": "mistral-medium-latest", - "messages": [], - "max_completion_tokens": 16384 - }); - - let result = provider.sanitize_request_for_compat(payload); - let obj = result.as_object().unwrap(); - - assert!(!obj.contains_key("max_completion_tokens")); - assert_eq!(obj.get("max_tokens").unwrap(), &json!(16384)); - } - - #[test] - fn sanitize_preserves_existing_max_tokens_for_compat_provider() { - let provider = make_provider("mistral"); - let payload = json!({ - "model": "mistral-medium-latest", - "messages": [], - "max_tokens": 4096, - "max_completion_tokens": 16384 - }); - - let result = provider.sanitize_request_for_compat(payload); - let obj = result.as_object().unwrap(); - - assert!(!obj.contains_key("max_completion_tokens")); - assert_eq!(obj.get("max_tokens").unwrap(), &json!(4096)); - } - - #[test] - fn sanitize_noop_for_native_openai_provider() { - let provider = make_provider("openai"); - let payload = json!({ - "model": "o3", - "messages": [], - "max_completion_tokens": 16384 - }); - - let result = provider.sanitize_request_for_compat(payload); - let obj = result.as_object().unwrap(); - - assert!(obj.contains_key("max_completion_tokens")); - assert!(!obj.contains_key("max_tokens")); - } - - #[test] - fn sanitize_noop_for_unknown_provider() { - let provider = make_provider("some_future_provider"); - let payload = json!({ - "model": "future-model", - "messages": [], - "max_completion_tokens": 16384 - }); - - let result = provider.sanitize_request_for_compat(payload); - let obj = result.as_object().unwrap(); - - assert!(obj.contains_key("max_completion_tokens")); - assert!(!obj.contains_key("max_tokens")); - } - - #[test] - fn sanitize_no_token_params() { - let provider = make_provider("groq"); - let payload = json!({ - "model": "llama-3.3-70b-versatile", - "messages": [] - }); - - let result = provider.sanitize_request_for_compat(payload.clone()); - assert_eq!(result, payload); - } - - #[test] - fn sanitize_nearai_reasoning_chat_params() { - let provider = make_provider("nearai"); - let payload = json!({ - "model": "Qwen/Qwen3.6-35B-A3B-FP8", - "messages": [ - { - "role": "developer", - "content": "system instructions" - }, - { - "role": "user", - "content": "hello" - } - ], - "reasoning_effort": "medium", - "max_completion_tokens": 16384 - }); - - let result = provider.sanitize_request_for_compat(payload); - let obj = result.as_object().unwrap(); - - assert!(!obj.contains_key("reasoning_effort")); - assert!(!obj.contains_key("max_completion_tokens")); - assert_eq!(obj.get("max_tokens").unwrap(), &json!(16384)); - assert_eq!(obj["messages"][0]["role"], "system"); - assert_eq!(obj["messages"][1]["role"], "user"); - } - - #[test] - fn sanitize_nearai_preserves_openai_reasoning_effort() { - let provider = make_provider("nearai"); - let payload = json!({ - "model": "openai/gpt-5", - "messages": [], - "reasoning_effort": "medium", - "max_completion_tokens": 16384 - }); - - let result = provider.sanitize_request_for_compat(payload); - let obj = result.as_object().unwrap(); - - assert_eq!(obj.get("reasoning_effort"), Some(&json!("medium"))); - assert!(!obj.contains_key("max_completion_tokens")); - assert_eq!(obj.get("max_tokens").unwrap(), &json!(16384)); - } - - #[test] - fn nearai_uses_chat_completions_for_openai_reasoning_models() { - let provider = make_provider("nearai"); - - assert!(!provider.should_use_responses_api_for_provider("openai/gpt-5")); - assert!(!provider.should_use_responses_api_for_provider("openai/o3")); - } - - #[test] - 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] - fn custom_chat_path_maps_to_responses_path() { - let responses_path = OpenAiProvider::map_base_path( - "openai/v1/chat/completions", - "responses", - "v1/responses", - ); - assert_eq!(responses_path, "openai/v1/responses"); - } - - #[test] - fn responses_path_maps_to_models_path() { - let models_path = - OpenAiProvider::map_base_path("openai/v1/responses", "models", "v1/models"); - assert_eq!(models_path, "openai/v1/models"); - } - - #[test] - fn unknown_path_falls_back_to_default_models_path() { - let models_path = OpenAiProvider::map_base_path("custom/path", "models", "v1/models"); - assert_eq!(models_path, "v1/models"); - } - - #[test] - fn absolute_chat_path_maps_to_absolute_responses_path() { - let responses_path = - OpenAiProvider::map_base_path("/v1/chat/completions", "responses", "v1/responses"); - assert_eq!(responses_path, "/v1/responses"); - } - - #[test] - fn unknown_absolute_path_falls_back_to_absolute_models_path() { - let models_path = OpenAiProvider::map_base_path("/custom/path", "models", "v1/models"); - assert_eq!(models_path, "/v1/models"); - } - #[test] - fn versionless_base_path_opts_out_of_responses_for_codex_models() { - assert!(!OpenAiProvider::should_use_responses_api( - "gpt-5-codex", - "chat/completions" - )); - } - - #[test] - fn ensure_url_scheme_adds_http_for_local_hosts() { - assert_eq!(ensure_url_scheme("localhost:1234"), "http://localhost:1234"); - assert_eq!( - ensure_url_scheme("127.0.0.1:8080/v1"), - "http://127.0.0.1:8080/v1" - ); - assert_eq!(ensure_url_scheme("0.0.0.0:3000"), "http://0.0.0.0:3000"); - assert_eq!(ensure_url_scheme("[::1]:1234"), "http://[::1]:1234"); - } - - #[test] - fn ensure_url_scheme_adds_https_for_remote_hosts() { - assert_eq!( - ensure_url_scheme("api.example.com:8443/v1"), - "https://api.example.com:8443/v1" - ); - assert_eq!(ensure_url_scheme("example.com"), "https://example.com"); - } - - #[test] - fn ensure_url_scheme_preserves_existing_scheme() { - assert_eq!( - ensure_url_scheme("http://localhost:1234"), - "http://localhost:1234" - ); - assert_eq!( - ensure_url_scheme("https://api.openai.com/v1"), - "https://api.openai.com/v1" - ); - } - - fn custom_config(base_url: &str) -> DeclarativeProviderConfig { - DeclarativeProviderConfig { - name: "test-openai".to_string(), - engine: crate::declarative::ProviderEngine::OpenAI, - display_name: "Test OpenAI".to_string(), - description: None, - api_key_env: String::new(), - base_url: base_url.to_string(), - models: vec![crate::base::ModelInfo::new("test-model", 4096)], - headers: None, - timeout_seconds: None, - supports_streaming: None, - requires_auth: false, - catalog_provider_id: None, - base_path: None, - env_vars: None, - dynamic_models: Some(false), - skip_canonical_filtering: false, - model_doc_link: None, - setup_steps: vec![], - fast_model: None, - preserves_thinking: false, - } - } - - #[test] - fn from_custom_config_preserves_ipv6_authority() { - let provider = from_declarative_config( - custom_config("http://[::1]:1234/v1"), - None, - crate::declarative::EnvKeyResolver, - ) - .unwrap() - .build(); - - assert_eq!(provider.api_client.host(), "http://[::1]:1234"); - } - - #[test] - fn from_custom_config_preserves_userinfo_authority() { - let provider = from_declarative_config( - custom_config("https://user:pass@gateway.example/v1"), - None, - crate::declarative::EnvKeyResolver, - ) - .unwrap() - .build(); - - assert_eq!( - provider.api_client.host(), - "https://user:pass@gateway.example" - ); - } - - #[test] - fn parse_n_ctx_falls_back_to_sole_entry_when_id_differs() { - let body = json!({ - "data": [ - { "id": "/models/qwen3.gguf", "meta": { "n_ctx": 32768 } } - ] - }); - assert_eq!(parse_n_ctx_from_models(&body, "qwen3"), Some(32768)); - } - - #[test] - fn parse_n_ctx_no_fallback_with_multiple_unmatched_entries() { - let body = json!({ - "data": [ - { "id": "model-a", "meta": { "n_ctx": 4096 } }, - { "id": "model-b", "meta": { "n_ctx": 8192 } } - ] - }); - assert_eq!(parse_n_ctx_from_models(&body, "model-c"), None); - } - - #[test] - fn derive_base_path_not_removing_api_path() { - let r = derive_base_path("https://opencode.ai/zen/go"); - assert_eq!(r, "https://opencode.ai/zen/go/v1/chat/completions"); - } - - #[test] - fn derive_base_path_should_support_v1() { - let r = derive_base_path("https://opencode.ai/zen/go/v1"); - assert_eq!(r, "https://opencode.ai/zen/go/v1/chat/completions"); - } - - #[test] - fn derive_base_path_should_support_no_base_path() { - let r = derive_base_path("https://opencode.ai/"); - assert_eq!(r, "https://opencode.ai/v1/chat/completions"); - } - - #[test] - fn derive_base_path_preserves_non_v1_version_prefix() { - // Zhipu's default base_url is https://open.bigmodel.cn/api/paas/v4 and - // from_custom_config passes url.path() ("/api/paas/v4") here. The - // existing /api/paas/v4 version must not gain an extra /v1 segment. - let r = derive_base_path("/api/paas/v4"); - assert_eq!(r, "api/paas/v4/chat/completions"); - } - - #[test] - fn derive_base_path_does_not_treat_v_word_as_version() { - let r = derive_base_path("/api/voice"); - assert_eq!(r, "api/voice/v1/chat/completions"); - } -} diff --git a/crates/goose-sdk-types/Cargo.toml b/crates/goose-sdk-types/Cargo.toml deleted file mode 100644 index afda65dd86..0000000000 --- a/crates/goose-sdk-types/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "goose-sdk-types" -version = "0.1.0-alpha.0" -edition.workspace = true -rust-version.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -description = "Shared types for the Goose SDK" - -[dependencies] -agent-client-protocol = { workspace = true, features = ["unstable"] } -agent-client-protocol-schema = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -schemars = { workspace = true, features = ["derive"] } - -[package.metadata.cargo-machete] -# Used to provide extras imports for agent-client-protocol -ignored = ["agent-client-protocol-schema"] diff --git a/crates/goose-sdk-types/src/custom_notifications.rs b/crates/goose-sdk-types/src/custom_notifications.rs deleted file mode 100644 index 868e3185de..0000000000 --- a/crates/goose-sdk-types/src/custom_notifications.rs +++ /dev/null @@ -1,226 +0,0 @@ -use crate::custom_requests::CustomMethodSchema; -use agent_client_protocol::{JsonRpcMessage, JsonRpcNotification}; -use schemars::{JsonSchema, SchemaGenerator}; -use serde::{Deserialize, Serialize}; - -/// Goose-custom session update notification โ€” a parallel to ACP's -/// `session/update` carrying goose-specific update variants. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcNotification)] -#[notification(method = "_goose/unstable/session/update")] -#[serde(rename_all = "camelCase")] -pub struct GooseSessionNotification { - pub session_id: String, - pub update: GooseSessionUpdate, -} - -/// Discriminated union of goose-specific session update payloads. -/// Variant tag matches ACP's convention (`sessionUpdate: ""`). -/// -/// `discriminator.mapping` is what makes TS codegen (`@hey-api/openapi-ts`) -/// emit the correct snake_case tag value even when this enum has a single -/// variant. Add a mapping entry per variant. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "sessionUpdate", rename_all = "snake_case")] -#[schemars(extend("discriminator" = { - "propertyName": "sessionUpdate", - "mapping": { - "usage_update": "#/$defs/SessionUsageUpdate", - "status_message": "#/$defs/StatusMessageUpdate", - "message_usage": "#/$defs/MessageUsageUpdate" - } -}))] -pub enum GooseSessionUpdate { - UsageUpdate(SessionUsageUpdate), - StatusMessage(StatusMessageUpdate), - MessageUsage(MessageUsageUpdate), -} - -impl Default for GooseSessionUpdate { - fn default() -> Self { - GooseSessionUpdate::UsageUpdate(SessionUsageUpdate::default()) - } -} - -/// Streaming context-window usage update for a session. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct SessionUsageUpdate { - pub used: u64, - pub context_limit: u64, - pub accumulated_input_tokens: u64, - pub accumulated_output_tokens: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub accumulated_cost: Option, -} - -/// Per-message token usage/cost/timing, keyed by the message id used for -/// chunk matching. Sent live after a turn's messages and on replay. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct MessageUsageUpdate { - #[serde(skip_serializing_if = "Option::is_none")] - pub message_id: Option, - pub usage: MessageUsageData, -} - -/// Wire mirror of the conversation `MessageUsage` (this crate cannot depend on -/// goose-provider-types); field names and serde casing MUST stay in parity. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct MessageUsageData { - #[serde(skip_serializing_if = "Option::is_none")] - pub input_tokens: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub output_tokens: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub total_tokens: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_read_tokens: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_write_tokens: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cost: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cost_source: Option, - /// Wall-clock generation time, used by the client for a tokens/sec readout. - #[serde(skip_serializing_if = "Option::is_none")] - pub elapsed_ms: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub time_to_first_token_ms: Option, - /// Usage from a compaction/summarization call rather than a normal turn. - #[serde(default)] - pub is_compaction: bool, -} - -/// Wire mirror of the conversation `CostSource`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum CostSourceData { - /// Cost returned directly by the provider (e.g. OpenRouter `usage.cost`). - ProviderReported, - /// Cost computed from the canonical pricing table. - Estimated, -} - -/// Live UI/session status. This is not conversation transcript content, and -/// should not be persisted or replayed as history. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct StatusMessageUpdate { - pub status: StatusMessage, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum StatusMessage { - #[serde(rename_all = "camelCase")] - Notice { message: String }, - #[serde(rename_all = "camelCase")] - Progress { message: String }, -} - -fn notification_schema(generator: &mut SchemaGenerator) -> CustomMethodSchema -where - T: Default + JsonRpcMessage + JsonSchema, -{ - let dummy = T::default(); - let type_name = std::any::type_name::() - .rsplit("::") - .next() - .unwrap_or(std::any::type_name::()) - .to_string(); - CustomMethodSchema { - method: dummy.method().to_string(), - params_schema: Some(generator.subschema_for::()), - params_type_name: Some(type_name), - response_schema: None, - response_type_name: None, - } -} - -/// Schemas for every goose-custom outbound notification. To register a new -/// notification, define the struct above (with `JsonRpcNotification` + -/// `Default`) and add one line below. -pub fn custom_notification_schemas(generator: &mut SchemaGenerator) -> Vec { - vec![notification_schema::(generator)] -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn status_message_serializes_to_expected_wire_shape() { - let notification = GooseSessionNotification { - session_id: "s1".to_string(), - update: GooseSessionUpdate::StatusMessage(StatusMessageUpdate { - status: StatusMessage::Notice { - message: "Compaction complete".to_string(), - }, - }), - }; - - let value = serde_json::to_value(notification).unwrap(); - - assert_eq!( - value, - json!({ - "sessionId": "s1", - "update": { - "sessionUpdate": "status_message", - "status": { - "type": "notice", - "message": "Compaction complete" - } - } - }) - ); - } - - #[test] - fn message_usage_serializes_to_expected_wire_shape() { - let notification = GooseSessionNotification { - session_id: "s1".to_string(), - update: GooseSessionUpdate::MessageUsage(MessageUsageUpdate { - message_id: Some("m1".to_string()), - usage: MessageUsageData { - input_tokens: Some(1200), - output_tokens: Some(340), - total_tokens: Some(1540), - cache_read_tokens: Some(1000), - cache_write_tokens: None, - cost: Some(0.0123), - cost_source: Some(CostSourceData::Estimated), - elapsed_ms: Some(4200), - time_to_first_token_ms: Some(840), - is_compaction: false, - }, - }), - }; - - let value = serde_json::to_value(notification).unwrap(); - - assert_eq!( - value, - json!({ - "sessionId": "s1", - "update": { - "sessionUpdate": "message_usage", - "messageId": "m1", - "usage": { - "inputTokens": 1200, - "outputTokens": 340, - "totalTokens": 1540, - "cacheReadTokens": 1000, - "cost": 0.0123, - "costSource": "estimated", - "elapsedMs": 4200, - "timeToFirstTokenMs": 840, - "isCompaction": false - } - } - }) - ); - } -} diff --git a/crates/goose-sdk-types/src/custom_requests/recipe.rs b/crates/goose-sdk-types/src/custom_requests/recipe.rs deleted file mode 100644 index 101fa230df..0000000000 --- a/crates/goose-sdk-types/src/custom_requests/recipe.rs +++ /dev/null @@ -1,394 +0,0 @@ -use std::collections::HashMap; - -use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -use super::EmptyResponse; - -fn default_recipe_version() -> String { - "1.0.0".to_string() -} - -pub const REQUEST_RECIPE_PARAMS_METHOD: &str = "_goose/unstable/session/recipe/request-params"; - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct RecipeDto { - #[serde(default = "default_recipe_version")] - pub version: String, - pub title: String, - pub description: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub instructions: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prompt: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub extensions: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub settings: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub activities: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub author: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parameters: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub response: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sub_recipes: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub retry: Option, -} - -impl Default for RecipeDto { - fn default() -> Self { - Self { - version: default_recipe_version(), - title: String::new(), - description: String::new(), - instructions: None, - prompt: None, - extensions: None, - settings: None, - activities: None, - author: None, - parameters: None, - response: None, - sub_recipes: None, - retry: None, - } - } -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -pub struct RecipeAuthorDto { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub contact: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub metadata: Option, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -pub struct RecipeSettingsDto { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub goose_provider: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub goose_model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub temperature: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_turns: Option, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -pub struct RecipeResponseDto { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub json_schema: Option, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -pub struct SubRecipeDto { - pub name: String, - pub path: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub values: Option>, - #[serde(default)] - pub sequential_when_repeated: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -pub struct RecipeParameterDto { - pub key: String, - pub input_type: RecipeParameterInputTypeDto, - pub requirement: RecipeParameterRequirementDto, - pub description: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub default: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub options: Option>, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum RecipeParameterInputTypeDto { - #[default] - String, - Number, - Boolean, - Date, - File, - Select, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum RecipeParameterRequirementDto { - #[default] - Required, - Optional, - UserPrompt, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -pub struct RecipeRetryConfigDto { - pub max_retries: u32, - #[serde(default)] - pub checks: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub on_failure: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timeout_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub on_failure_timeout_seconds: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum RecipeSuccessCheckDto { - Shell { command: String }, -} - -impl Default for RecipeSuccessCheckDto { - fn default() -> Self { - Self::Shell { - command: String::new(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum RecipeExtensionDto { - Builtin { - name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - display_name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - timeout: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - bundled: Option, - /// Tool allowlist for this extension. Omit this field to allow all tools. - #[serde(default, skip_serializing_if = "Option::is_none")] - available_tools: Option>, - }, - Platform { - name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - display_name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - bundled: Option, - /// Tool allowlist for this extension. Omit this field to allow all tools. - #[serde(default, skip_serializing_if = "Option::is_none")] - available_tools: Option>, - }, - Stdio { - name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - description: Option, - cmd: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - args: Vec, - #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] - envs: HashMap, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - env_keys: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - timeout: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - cwd: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - bundled: Option, - /// Tool allowlist for this extension. Omit this field to allow all tools. - #[serde(default, skip_serializing_if = "Option::is_none")] - available_tools: Option>, - }, - StreamableHttp { - name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - description: Option, - uri: String, - #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] - envs: HashMap, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - env_keys: Vec, - #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] - headers: HashMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - timeout: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - socket: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - bundled: Option, - /// Tool allowlist for this extension. Omit this field to allow all tools. - #[serde(default, skip_serializing_if = "Option::is_none")] - available_tools: Option>, - }, -} - -impl Default for RecipeExtensionDto { - fn default() -> Self { - Self::Builtin { - name: String::new(), - description: None, - display_name: None, - timeout: None, - bundled: None, - available_tools: None, - } - } -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -pub struct RecipeListEntryDto { - pub id: String, - pub recipe: RecipeDto, - pub file_path: String, - pub last_modified: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub schedule_cron: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slash_command: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct RequestRecipeParams { - pub session_id: String, - pub parameters: Vec, -} - -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub enum RecipeParamsAction { - #[default] - Submit, - Cancel, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct RecipeParamsResponse { - #[serde(default)] - pub action: RecipeParamsAction, - #[serde(default)] - pub values: HashMap, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/recipes/encode", - response = EncodeRecipeResponse -)] -pub struct EncodeRecipeRequest { - pub recipe: RecipeDto, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct EncodeRecipeResponse { - pub deeplink: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/recipes/decode", - response = DecodeRecipeResponse -)] -pub struct DecodeRecipeRequest { - pub deeplink: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct DecodeRecipeResponse { - pub recipe: RecipeDto, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/recipes/scan", response = ScanRecipeResponse)] -pub struct ScanRecipeRequest { - pub recipe: RecipeDto, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct ScanRecipeResponse { - pub has_security_warnings: bool, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/recipes/save", response = SaveRecipeResponse)] -pub struct SaveRecipeRequest { - pub recipe: RecipeDto, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub id: Option, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct SaveRecipeResponse { - pub id: String, - pub file_name: String, - pub file_path: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/recipes/parse", response = ParseRecipeResponse)] -pub struct ParseRecipeRequest { - pub content: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct ParseRecipeResponse { - pub recipe: RecipeDto, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/recipes/delete", response = EmptyResponse)] -pub struct DeleteRecipeRequest { - pub id: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/recipes/list", response = ListRecipesResponse)] -pub struct ListRecipesRequest {} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct ListRecipesResponse { - pub recipes: Vec, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/recipes/schedule", response = EmptyResponse)] -pub struct ScheduleRecipeRequest { - pub id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cron_schedule: Option, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/recipes/slash-command", - response = EmptyResponse -)] -pub struct SetRecipeSlashCommandRequest { - pub id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slash_command: Option, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/recipes/to-yaml", - response = RecipeToYamlResponse -)] -pub struct RecipeToYamlRequest { - pub recipe: RecipeDto, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct RecipeToYamlResponse { - pub yaml: String, -} diff --git a/crates/goose-sdk-types/src/custom_requests/schedule.rs b/crates/goose-sdk-types/src/custom_requests/schedule.rs deleted file mode 100644 index 37457a7bf6..0000000000 --- a/crates/goose-sdk-types/src/custom_requests/schedule.rs +++ /dev/null @@ -1,169 +0,0 @@ -use agent_client_protocol::schema::v1::SessionInfo; -use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -use super::{EmptyResponse, RecipeDto}; - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct ScheduledJobDto { - pub id: String, - pub source: String, - pub cron: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_run: Option, - pub currently_running: bool, - pub paused: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub current_session_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub job_start_time: Option, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/schedules/list", - response = ListSchedulesResponse -)] -pub struct ListSchedulesRequest {} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct ListSchedulesResponse { - pub jobs: Vec, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/schedules/create", - response = CreateScheduleResponse -)] -pub struct CreateScheduleRequest { - pub id: String, - pub recipe: RecipeDto, - pub cron: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct CreateScheduleResponse { - pub job: ScheduledJobDto, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/schedules/delete", response = EmptyResponse)] -#[serde(rename_all = "camelCase")] -pub struct DeleteScheduleRequest { - pub schedule_id: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/schedules/update", - response = UpdateScheduleResponse -)] -#[serde(rename_all = "camelCase")] -pub struct UpdateScheduleRequest { - pub schedule_id: String, - pub cron: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct UpdateScheduleResponse { - pub job: ScheduledJobDto, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/schedules/run-now", - response = RunScheduleNowResponse -)] -#[serde(rename_all = "camelCase")] -pub struct RunScheduleNowRequest { - pub schedule_id: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct RunScheduleNowResponse { - pub status: RunScheduleNowStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_id: Option, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub enum RunScheduleNowStatus { - #[default] - Completed, - Cancelled, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/schedules/sessions/list", - response = ListScheduleSessionsResponse -)] -#[serde(rename_all = "camelCase")] -pub struct ListScheduleSessionsRequest { - pub schedule_id: String, - pub limit: usize, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct ListScheduleSessionsResponse { - pub sessions: Vec, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/schedules/pause", response = EmptyResponse)] -#[serde(rename_all = "camelCase")] -pub struct PauseScheduleRequest { - pub schedule_id: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/schedules/unpause", - response = EmptyResponse -)] -#[serde(rename_all = "camelCase")] -pub struct UnpauseScheduleRequest { - pub schedule_id: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/schedules/running-job/kill", - response = KillRunningJobResponse -)] -#[serde(rename_all = "camelCase")] -pub struct KillRunningJobRequest { - pub job_id: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct KillRunningJobResponse { - pub message: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/schedules/running-job/inspect", - response = InspectRunningJobResponse -)] -#[serde(rename_all = "camelCase")] -pub struct InspectRunningJobRequest { - pub job_id: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct InspectRunningJobResponse { - pub running: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub job_start_time: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub running_duration_seconds: Option, -} diff --git a/crates/goose-sdk-types/src/lib.rs b/crates/goose-sdk-types/src/lib.rs deleted file mode 100644 index 0358354605..0000000000 --- a/crates/goose-sdk-types/src/lib.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Shared Goose ACP wire types. -//! -//! These wire types keep a single source of truth for Goose's custom -//! `_goose/*` JSON-RPC methods. - -pub mod custom_notifications; -pub mod custom_requests; diff --git a/crates/goose-sdk/.gitignore b/crates/goose-sdk/.gitignore deleted file mode 100644 index 0916a2813c..0000000000 --- a/crates/goose-sdk/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -generated -examples/uniffi/*.jar -python/build/ -python/dist/ -python/src/*.egg-info/ -python/src/goose/ diff --git a/crates/goose-sdk/Cargo.toml b/crates/goose-sdk/Cargo.toml index 48cdfb7cab..5cdfc6bb07 100644 --- a/crates/goose-sdk/Cargo.toml +++ b/crates/goose-sdk/Cargo.toml @@ -1,49 +1,22 @@ [package] name = "goose-sdk" -version = "0.1.0-alpha.0" +version.workspace = true edition.workspace = true rust-version.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -description = "Rust SDK for Goose with optional uniffi bindings for Python/Kotlin" - -[lib] -name = "goose_sdk" -crate-type = ["cdylib", "staticlib", "rlib"] - -[[bin]] -name = "goose-uniffi-bindgen" -path = "src/bin/uniffi-bindgen.rs" -required-features = ["uniffi"] - -[features] -default = [] -uniffi = [ - "dep:uniffi", - "dep:thiserror", - "dep:anyhow", - "dep:goose-providers", - "dep:futures", - "dep:serde_json", - "dep:tokio", -] +description = "Rust SDK for talking to Goose over the Agent Client Protocol (ACP)" [dependencies] -goose-sdk-types = { version = "0.1.0-alpha.0", path = "../goose-sdk-types" } agent-client-protocol = { workspace = true, features = ["unstable"] } agent-client-protocol-schema = { workspace = true } - -uniffi = { version = "0.32", features = ["cli"], optional = true } -thiserror = { version = "2", optional = true } -goose-providers = { version = "0.1.0-alpha.0", path = "../goose-providers", features = ["rustls-tls"], optional = true } -futures = { workspace = true, optional = true } -serde_json = { workspace = true, optional = true } -tokio = { workspace = true, features = ["rt-multi-thread", "sync"], optional = true } -anyhow = { workspace = true, optional = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +schemars = { workspace = true, features = ["derive"] } [dev-dependencies] -tokio = { workspace = true, features = ["rt-multi-thread", "macros", "process", "io-std", "io-util"] } +tokio = { workspace = true } tokio-util = { workspace = true, features = ["compat", "rt"] } [package.metadata.cargo-machete] diff --git a/crates/goose-sdk/README.md b/crates/goose-sdk/README.md deleted file mode 100644 index e10329d358..0000000000 --- a/crates/goose-sdk/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# goose-sdk - -The bindings layer for Goose. It houses the shared types used for both ACP and -SDK access, and exposes a cross-language version of the Goose API. - -With `--features uniffi` the crate compiles to native bindings for Python and -Kotlin (namespace `goose` / `io.aaif.goose`). The UniFFI surface currently lets -callers construct declarative providers from JSON and stream provider -completions. - -```bash -just python # build bindings + run examples/uniffi/provider.py -just kotlin # build bindings + run examples/uniffi/Provider.kt -``` - -## Python package - -The PyPI package is published as `goose-sdk` and imports as `goose`. -Build a local wheel from the repository root with: - -```bash -just --justfile crates/goose-sdk/justfile python-wheel -``` - -This regenerates the UniFFI Python bindings, copies the release native library -into the package, and writes the wheel to `crates/goose-sdk/python/dist/`. diff --git a/crates/goose-sdk/examples/acp_client.rs b/crates/goose-sdk/examples/acp_client.rs index a7421ca883..9ffcdc51dd 100644 --- a/crates/goose-sdk/examples/acp_client.rs +++ b/crates/goose-sdk/examples/acp_client.rs @@ -19,11 +19,11 @@ //! cargo run -p goose-sdk --example acp_client -- --goose-bin ./target/debug/goose "Explain Rust's ownership model in one sentence" //! ``` -use agent_client_protocol::schema::v1::{ - ContentBlock, InitializeRequest, RequestPermissionOutcome, RequestPermissionRequest, - RequestPermissionResponse, SelectedPermissionOutcome, SessionNotification, SessionUpdate, +use agent_client_protocol::schema::{ + ContentBlock, InitializeRequest, ProtocolVersion, RequestPermissionOutcome, + RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome, + SessionNotification, SessionUpdate, }; -use agent_client_protocol::schema::ProtocolVersion; use agent_client_protocol::{Client, ConnectionTo}; use goose_sdk::custom_requests::GetExtensionsRequest; use std::path::PathBuf; diff --git a/crates/goose-sdk/examples/deepseek.json b/crates/goose-sdk/examples/deepseek.json deleted file mode 100644 index 1d22074437..0000000000 --- a/crates/goose-sdk/examples/deepseek.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "deepseek", - "engine": "openai", - "display_name": "DeepSeek", - "description": "Custom DeepSeek provider", - "api_key_env": "DEEPSEEK_API_KEY", - "base_url": "https://api.deepseek.com", - "models": [ - { - "name": "deepseek-chat", - "context_limit": 128000, - "input_token_cost": null, - "output_token_cost": null, - "currency": null, - "supports_cache_control": null - }, - { - "name": "deepseek-reasoner", - "context_limit": 128000, - "input_token_cost": null, - "output_token_cost": null, - "currency": null, - "supports_cache_control": null - } - ], - "headers": null, - "timeout_seconds": null, - "preserves_thinking": true, - "supports_streaming": true -} diff --git a/crates/goose-sdk/examples/uniffi/Provider.kt b/crates/goose-sdk/examples/uniffi/Provider.kt deleted file mode 100644 index 6e23e2f044..0000000000 --- a/crates/goose-sdk/examples/uniffi/Provider.kt +++ /dev/null @@ -1,31 +0,0 @@ -package aaif.example - -import io.aaif.goose.DeclarativeProvider -import io.aaif.goose.MessageRole -import io.aaif.goose.ProviderMessage -import io.aaif.goose.ProviderModelConfig -import java.nio.file.Paths - -fun main() { - val examplesDir = Paths.get("crates/goose-sdk/examples") - val provider = DeclarativeProvider.fromJson(examplesDir.resolve("deepseek.json").toFile().readText()) - val model = ProviderModelConfig(modelName = "deepseek-v4-flash") - val messages = listOf( - ProviderMessage( - role = MessageRole.USER, - text = "what is the capital of France?", - ), - ) - val stream = provider.stream( - model, - "You are a knowledgable geography expert", - messages, - ) - - while (true) { - val chunk = stream.next() ?: break - chunk.text?.let { print(it) } - chunk.usageJson?.let { println("\nusage: $it") } - } - println() -} diff --git a/crates/goose-sdk/examples/uniffi/README.md b/crates/goose-sdk/examples/uniffi/README.md deleted file mode 100644 index 9b426e6a89..0000000000 --- a/crates/goose-sdk/examples/uniffi/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# UniFFI examples - -These examples exercise the in-process Goose SDK UniFFI bindings from Python and Kotlin. - -## Prerequisites - -```bash -source bin/activate-hermit -export DEEPSEEK_API_KEY=... -``` - -## Generate bindings - -Regenerate the Python and Kotlin bindings before running the examples: - -```bash -just --justfile crates/goose-sdk/justfile _generate python -just --justfile crates/goose-sdk/justfile _generate kotlin -``` - -This writes generated bindings and the debug native library under `crates/goose-sdk/generated/`. - -## Python provider example - -```bash -DYLD_LIBRARY_PATH=target/debug LD_LIBRARY_PATH=target/debug \ - uv run --script crates/goose-sdk/examples/uniffi/provider.py -``` - -## Kotlin provider example - -Download JNA if it is not already present: - -```bash -curl -sSL -o crates/goose-sdk/examples/uniffi/jna.jar \ - https://repo1.maven.org/maven2/net/java/dev/jna/jna/5.14.0/jna-5.14.0.jar -``` - -Compile and run: - -```bash -kotlinc -cp crates/goose-sdk/examples/uniffi/jna.jar -nowarn \ - crates/goose-sdk/generated/io/aaif/goose/goose.kt \ - crates/goose-sdk/examples/uniffi/Provider.kt \ - -include-runtime -d crates/goose-sdk/examples/uniffi/provider.jar - -java -Djna.library.path=target/debug \ - --enable-native-access=ALL-UNNAMED \ - -cp crates/goose-sdk/examples/uniffi/provider.jar:crates/goose-sdk/examples/uniffi/jna.jar \ - aaif.example.ProviderKt -``` - -On Linux, use the same command; `LD_LIBRARY_PATH=target/debug` can also be set if needed. On macOS, `-Djna.library.path=target/debug` is usually enough, but `DYLD_LIBRARY_PATH=target/debug` can also be set if JNA cannot find `libgoose_sdk.dylib`. diff --git a/crates/goose-sdk/examples/uniffi/provider.py b/crates/goose-sdk/examples/uniffi/provider.py deleted file mode 100755 index 0112f0b4bc..0000000000 --- a/crates/goose-sdk/examples/uniffi/provider.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env -S uv run --script -"""Goose SDK demo: build a declarative provider and stream a completion.""" -import json -import sys -from pathlib import Path - -HERE = Path(__file__).resolve().parent -sys.path.insert(0, str(HERE.parent.parent / "generated")) - -from goose import ( # noqa: E402 - DeclarativeProvider, - MessageRole, - ProviderMessage, - ProviderModelConfig, -) - - -def main() -> None: - provider = DeclarativeProvider.from_json((HERE.parent / "deepseek.json").read_text()) - model = ProviderModelConfig(model_name="deepseek-v4-flash") - messages = [ProviderMessage(role=MessageRole.USER, text="what is the capital of France?")] - stream = provider.stream( - model, - "You are a knowledgable geography expert", - messages, - ) - - while chunk := stream.next(): - if chunk.text: - print(chunk.text, end="") - if chunk.usage_json: - usage = json.loads(chunk.usage_json) - print(f"\nusage: {usage}") - print() - - -if __name__ == "__main__": - main() diff --git a/crates/goose-sdk/justfile b/crates/goose-sdk/justfile deleted file mode 100644 index 407b48ef00..0000000000 --- a/crates/goose-sdk/justfile +++ /dev/null @@ -1,157 +0,0 @@ -set shell := ["bash", "-cu"] -set working-directory := '../..' - -lib_ext := if os() == "macos" { "dylib" } else if os() == "windows" { "dll" } else { "so" } -lib_prefix := if os() == "windows" { "" } else { "lib" } -lib_name := lib_prefix + "goose_sdk." + lib_ext -debug_lib_dir := "./target/debug" -release_lib_dir := "./target/release" -debug_lib_path := debug_lib_dir / lib_name -release_lib_path := release_lib_dir / lib_name -debug_bindgen := "./target/debug/goose-uniffi-bindgen" -release_bindgen := "./target/release/goose-uniffi-bindgen" -config := "./crates/goose-sdk/uniffi.toml" -gen_dir := "./crates/goose-sdk/generated" -examples_dir := "./crates/goose-sdk/examples/uniffi" -python_dir := "./crates/goose-sdk/python" -python_package_dir := python_dir / "src/goose" - -_default: - @just --list --justfile {{justfile()}} - -_build: - cargo build -p goose-sdk --features uniffi -q - -_build-release: - cargo build -p goose-sdk --features uniffi --release -q - -_generate lang: _build - {{debug_bindgen}} generate --library {{debug_lib_path}} --config {{config}} --language {{lang}} --no-format --out-dir {{gen_dir}} 2>/dev/null - cp {{debug_lib_path}} {{gen_dir}}/ - touch {{gen_dir}}/__init__.py - -python: (_generate "python") - DYLD_LIBRARY_PATH={{debug_lib_dir}} LD_LIBRARY_PATH={{debug_lib_dir}} \ - python3 {{examples_dir}}/provider.py - -kotlin: (_generate "kotlin") - @if [ ! -f {{examples_dir}}/jna.jar ]; then \ - curl -sSL -o {{examples_dir}}/jna.jar \ - https://repo1.maven.org/maven2/net/java/dev/jna/jna/5.14.0/jna-5.14.0.jar; \ - fi - kotlinc -cp {{examples_dir}}/jna.jar -nowarn \ - {{gen_dir}}/io/aaif/goose/goose.kt \ - {{examples_dir}}/Provider.kt \ - -include-runtime -d {{examples_dir}}/provider.jar 2>/dev/null - java -Djna.library.path={{debug_lib_dir}} \ - --enable-native-access=ALL-UNNAMED \ - -cp {{examples_dir}}/provider.jar:{{examples_dir}}/jna.jar aaif.example.ProviderKt - -python-bindings profile="debug": - @case "{{profile}}" in \ - debug) cargo build -p goose-sdk --features uniffi -q; bindgen={{debug_bindgen}}; lib_path={{debug_lib_path}} ;; \ - release) cargo build -p goose-sdk --features uniffi --release -q; bindgen={{release_bindgen}}; lib_path={{release_lib_path}} ;; \ - *) echo 'profile must be debug or release' >&2; exit 1 ;; \ - esac; \ - rm -rf {{python_package_dir}}; \ - mkdir -p {{python_package_dir}}; \ - "$bindgen" generate --library "$lib_path" --config {{config}} --language python --no-format --out-dir {{python_package_dir}} 2>/dev/null; \ - mv {{python_package_dir}}/goose.py {{python_package_dir}}/__init__.py; \ - cp "$lib_path" {{python_package_dir}}/; \ - touch {{python_package_dir}}/py.typed - -python-wheel: (python-bindings "release") - rm -rf {{python_dir}}/build {{python_dir}}/dist {{python_dir}}/*.egg-info {{python_dir}}/src/*.egg-info - cd {{python_dir}} && UV_NO_CONFIG=1 PIP_CONFIG_FILE=/dev/null PIP_INDEX_URL=https://pypi.org/simple uvx --default-index https://pypi.org/simple --from build pyproject-build --wheel - -python-check: python-wheel - python3 -m pip install --force-reinstall {{python_dir}}/dist/*.whl - python3 -c 'import goose; print(goose.__name__)' - -python-publish repository="pypi": python-wheel - UV_NO_CONFIG=1 PIP_CONFIG_FILE=/dev/null PIP_INDEX_URL=https://pypi.org/simple uvx --default-index https://pypi.org/simple twine check {{python_dir}}/dist/*.whl - @if [ "{{repository}}" = "pypi" ]; then \ - UV_NO_CONFIG=1 PIP_CONFIG_FILE=/dev/null PIP_INDEX_URL=https://pypi.org/simple uvx --default-index https://pypi.org/simple twine upload {{python_dir}}/dist/*.whl; \ - else \ - UV_NO_CONFIG=1 PIP_CONFIG_FILE=/dev/null PIP_INDEX_URL=https://pypi.org/simple uvx --default-index https://pypi.org/simple twine upload --repository {{repository}} {{python_dir}}/dist/*.whl; \ - fi - -crates-publish dry_run="true": - @set -euo pipefail; \ - dry_run_flag=""; \ - if [ "{{dry_run}}" = "true" ]; then \ - dry_run_flag="--dry-run"; \ - elif [ "{{dry_run}}" != "false" ]; then \ - echo 'dry_run must be true or false' >&2; \ - exit 1; \ - fi; \ - for crate in \ - goose-provider-types \ - goose-sdk-types \ - goose-download-manager \ - goose-local-inference \ - goose-providers \ - goose-sdk; do \ - cargo publish -p "$crate" $dry_run_flag --allow-dirty; \ - done - -bump-version rust_version: - #!/usr/bin/env bash - set -euo pipefail - python3 - "{{rust_version}}" <<'PY' - import re - import sys - from pathlib import Path - - rust_version = sys.argv[1] - match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:-alpha\.(\d+))?", rust_version) - if not match: - raise SystemExit("rust_version must look like 0.1.0 or 0.1.0-alpha.0") - - major, minor, patch, alpha = match.groups() - python_version = f"{major}.{minor}.{patch}" if alpha is None else f"{major}.{minor}.{patch}a{alpha}" - - crate_names = [ - "goose-provider-types", - "goose-sdk-types", - "goose-download-manager", - "goose-local-inference", - "goose-providers", - "goose-sdk", - ] - crate_paths = {name: Path("crates") / name / "Cargo.toml" for name in crate_names} - - def replace_unique(path: Path, pattern: str, replacement: str) -> None: - text = path.read_text() - new_text, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) - if count != 1: - raise SystemExit(f"expected one match for {pattern!r} in {path}") - path.write_text(new_text) - - for path in crate_paths.values(): - replace_unique(path, r'^version\s*=\s*"[^"]+"', f'version = "{rust_version}"') - - dependency_names = [ - "goose-provider-types", - "goose-sdk-types", - "goose-download-manager", - "goose-local-inference", - "goose-providers", - ] - for path in crate_paths.values(): - text = path.read_text() - for dependency in dependency_names: - text = re.sub( - rf'({re.escape(dependency)}\s*=\s*[^\n]*version\s*=\s*)"[^"]+"', - rf'\1"{rust_version}"', - text, - ) - path.write_text(text) - - pyproject = Path("crates/goose-sdk/python/pyproject.toml") - replace_unique(pyproject, r'^version\s*=\s*"[^"]+"', f'version = "{python_version}"') - - print(f"Rust crates: {rust_version}") - print(f"Python package: {python_version}") - PY - cargo fmt --all diff --git a/crates/goose-sdk/python/README.md b/crates/goose-sdk/python/README.md deleted file mode 100644 index 492d4d4fcf..0000000000 --- a/crates/goose-sdk/python/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# goose-sdk - -Python bindings for the Goose SDK. - -This package is generated from the Rust `goose-sdk` crate using UniFFI. - -## Build a local wheel - -From the repository root: - -```bash -just --justfile crates/goose-sdk/justfile python-wheel -``` - -The wheel is written to `crates/goose-sdk/python/dist/`. diff --git a/crates/goose-sdk/python/pyproject.toml b/crates/goose-sdk/python/pyproject.toml deleted file mode 100644 index c12f5b9b30..0000000000 --- a/crates/goose-sdk/python/pyproject.toml +++ /dev/null @@ -1,34 +0,0 @@ -[build-system] -requires = ["setuptools>=69", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "goose-sdk" -version = "0.1.0a0" -description = "Python bindings for the Goose SDK" -readme = "README.md" -requires-python = ">=3.9" -license = "Apache-2.0" -authors = [{ name = "AAIF", email = "ai-oss-tools@block.xyz" }] -keywords = ["goose", "sdk", "ai", "agent", "uniffi"] -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Rust", -] - -[project.urls] -Homepage = "https://github.com/aaif-goose/goose" -Repository = "https://github.com/aaif-goose/goose" -Issues = "https://github.com/aaif-goose/goose/issues" - -[tool.setuptools] -package-dir = { "" = "src" } - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.setuptools.package-data] -goose = ["*.so", "*.dylib", "*.dll", "py.typed"] diff --git a/crates/goose-sdk/python/setup.py b/crates/goose-sdk/python/setup.py deleted file mode 100644 index 5166d0ee97..0000000000 --- a/crates/goose-sdk/python/setup.py +++ /dev/null @@ -1,15 +0,0 @@ -from setuptools import setup -from wheel.bdist_wheel import bdist_wheel - - -class BinaryWheel(bdist_wheel): - def finalize_options(self): - super().finalize_options() - self.root_is_pure = False - - def get_tag(self): - _, _, platform_tag = super().get_tag() - return "py3", "none", platform_tag - - -setup(cmdclass={"bdist_wheel": BinaryWheel}) diff --git a/crates/goose-sdk/src/bin/uniffi-bindgen.rs b/crates/goose-sdk/src/bin/uniffi-bindgen.rs deleted file mode 100644 index f6cff6cf1d..0000000000 --- a/crates/goose-sdk/src/bin/uniffi-bindgen.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - uniffi::uniffi_bindgen_main() -} diff --git a/crates/goose-sdk/src/bindings.rs b/crates/goose-sdk/src/bindings.rs deleted file mode 100644 index b44dd6601c..0000000000 --- a/crates/goose-sdk/src/bindings.rs +++ /dev/null @@ -1,231 +0,0 @@ -//! In-process uniffi bindings for the Goose SDK. -//! -//! This is the API surface exposed to Python and Kotlin. It currently focuses -//! on declarative providers: consumers can construct a provider from JSON and -//! stream completions from it. - -use std::sync::{Arc, Mutex}; - -use futures::StreamExt; -use goose_providers::{ - base::{MessageStream, Provider}, - conversation::message::Message, - declarative::EnvKeyResolver, - model::ModelConfig, -}; - -/// Errors surfaced across the uniffi boundary. -#[derive(Debug, thiserror::Error, uniffi::Error)] -pub enum GooseError { - #[error("{0}")] - Generic(String), -} - -impl From for GooseError { - fn from(error: anyhow::Error) -> Self { - Self::Generic(error.to_string()) - } -} - -impl From for GooseError { - fn from(error: goose_providers::errors::ProviderError) -> Self { - Self::Generic(error.to_string()) - } -} - -impl From for GooseError { - fn from(error: serde_json::Error) -> Self { - Self::Generic(error.to_string()) - } -} - -/// A text message passed to a provider. -#[derive(Debug, Clone, uniffi::Record)] -pub struct ProviderMessage { - pub role: MessageRole, - pub text: String, -} - -/// Supported message roles for provider requests and streamed responses. -#[derive(Debug, Clone, uniffi::Enum)] -pub enum MessageRole { - User, - Assistant, -} - -impl ProviderMessage { - fn to_goose_message(&self) -> Message { - match self.role { - MessageRole::User => Message::user().with_text(&self.text), - MessageRole::Assistant => Message::assistant().with_text(&self.text), - } - } -} - -/// Model selection and optional generation settings for a provider request. -#[derive(Debug, Clone, uniffi::Record)] -pub struct ProviderModelConfig { - pub model_name: String, - #[uniffi(default = None)] - pub context_limit: Option, - #[uniffi(default = None)] - pub temperature: Option, - #[uniffi(default = None)] - pub max_tokens: Option, - #[uniffi(default = false)] - pub toolshim: bool, - #[uniffi(default = None)] - pub toolshim_model: Option, - /// Provider-specific request parameters as a JSON object string. - #[uniffi(default = None)] - pub request_params_json: Option, - #[uniffi(default = None)] - pub reasoning: Option, -} - -impl ProviderModelConfig { - fn to_goose_model_config(&self) -> Result { - let mut config = ModelConfig::new(&self.model_name) - .with_context_limit(self.context_limit.map(|limit| limit as usize)) - .with_temperature(self.temperature) - .with_max_tokens(self.max_tokens) - .with_toolshim(self.toolshim) - .with_toolshim_model(self.toolshim_model.clone()); - - if let Some(request_params_json) = &self.request_params_json { - let request_params = serde_json::from_str(request_params_json)?; - config = config.with_merged_request_params(request_params); - } - - config.reasoning = self.reasoning; - Ok(config) - } -} - -/// One item yielded by a provider stream. -#[derive(Debug, Clone, uniffi::Record)] -pub struct ProviderStreamChunk { - /// The concatenated text content in this message chunk, if one was emitted. - pub text: Option, - /// Full Goose message JSON for callers that need non-text content such as tool requests. - pub message_json: Option, - /// Provider usage JSON when the provider emits usage metadata. - pub usage_json: Option, -} - -/// A declarative Goose provider constructed from provider JSON. -#[derive(uniffi::Object)] -pub struct DeclarativeProvider { - provider: Box, - runtime: Arc, -} - -#[uniffi::export] -impl DeclarativeProvider { - /// Construct a declarative provider using the process environment to resolve - /// configured API key environment variables. - #[uniffi::constructor] - pub fn from_json(json: String) -> Result, GooseError> { - let provider = goose_providers::declarative::from_json(&json, None, EnvKeyResolver {})?; - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(|error| GooseError::Generic(error.to_string()))?; - - Ok(Arc::new(Self { - provider, - runtime: Arc::new(runtime), - })) - } - - pub fn name(&self) -> String { - self.provider.get_name().to_string() - } - - /// Start a streaming completion request. Tools are not yet exposed over the - /// uniffi boundary, so this calls providers with an empty tool list. - pub fn stream( - &self, - model: ProviderModelConfig, - system: String, - messages: Vec, - ) -> Result, GooseError> { - let model = model.to_goose_model_config()?; - let messages = messages - .iter() - .map(ProviderMessage::to_goose_message) - .collect::>(); - let stream = - self.runtime - .block_on(self.provider.stream(&model, &system, &messages, &[]))?; - - Ok(Arc::new(DeclarativeProviderStream { - stream: Mutex::new(stream), - runtime: Arc::clone(&self.runtime), - })) - } -} - -/// A blocking iterator over provider stream chunks. -#[derive(uniffi::Object)] -pub struct DeclarativeProviderStream { - stream: Mutex, - runtime: Arc, -} - -#[uniffi::export] -impl DeclarativeProviderStream { - /// Return the next stream chunk, or `None` when the stream is exhausted. - pub fn next(&self) -> Result, GooseError> { - let mut stream = self - .stream - .lock() - .map_err(|_| GooseError::Generic("provider stream lock poisoned".to_string()))?; - - let Some((message, usage)) = self.runtime.block_on(stream.next()).transpose()? else { - return Ok(None); - }; - - let text = message.as_ref().map(Message::as_concat_text); - let message_json = message.as_ref().map(serde_json::to_string).transpose()?; - let usage_json = usage.as_ref().map(serde_json::to_string).transpose()?; - - Ok(Some(ProviderStreamChunk { - text, - message_json, - usage_json, - })) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn model_config_rejects_invalid_request_params_json() { - let config = ProviderModelConfig { - model_name: "test".to_string(), - context_limit: None, - temperature: None, - max_tokens: None, - toolshim: false, - toolshim_model: None, - request_params_json: Some("not json".to_string()), - reasoning: None, - }; - - assert!(config.to_goose_model_config().is_err()); - } - - #[test] - fn provider_message_converts_user_text() { - let message = ProviderMessage { - role: MessageRole::User, - text: "what is the capital of France?".to_string(), - } - .to_goose_message(); - - assert_eq!(message.as_concat_text(), "what is the capital of France?"); - } -} diff --git a/crates/goose-sdk-types/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs similarity index 59% rename from crates/goose-sdk-types/src/custom_requests.rs rename to crates/goose-sdk/src/custom_requests.rs index a4502ca255..5b007574bb 100644 --- a/crates/goose-sdk-types/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -1,14 +1,8 @@ -use agent_client_protocol::schema::v1::{AvailableCommand, ContentBlock, McpServer, SessionInfo}; use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -mod recipe; -pub use recipe::*; -mod schedule; -pub use schedule::*; - /// Schema descriptor for a single custom method, produced by the /// `#[custom_methods]` macro's generated `custom_method_schemas()` function. /// @@ -31,16 +25,18 @@ pub struct CustomMethodSchema { #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/unstable/session/extensions/add", response = EmptyResponse)] #[serde(rename_all = "camelCase")] -pub struct AddSessionExtensionRequest { +pub struct AddExtensionRequest { pub session_id: String, - pub extension: GooseExtension, + /// Extension configuration (see ExtensionConfig variants: Stdio, StreamableHttp, Builtin, Platform). + #[serde(default)] + pub config: serde_json::Value, } /// Remove an extension from an active session. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/unstable/session/extensions/remove", response = EmptyResponse)] #[serde(rename_all = "camelCase")] -pub struct RemoveSessionExtensionRequest { +pub struct RemoveExtensionRequest { pub session_id: String, pub name: String, } @@ -51,28 +47,13 @@ pub struct RemoveSessionExtensionRequest { #[serde(rename_all = "camelCase")] pub struct GetToolsRequest { pub session_id: String, - /// Filter tools to those belonging to this extension. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub extension_name: Option, -} - -/// A single tool item returned by the tools list endpoint. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct ToolListItem { - pub name: String, - pub description: String, - pub parameters: Vec, - pub permission: Option, - pub input_schema: serde_json::Value, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub output_schema: Option, } /// Tools response. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] pub struct GetToolsResponse { - pub tools: Vec, + /// Array of tool info objects with `name`, `description`, `parameters`, and optional `permission`. + pub tools: Vec, } /// Read a resource from an extension. @@ -118,58 +99,6 @@ pub struct GooseToolCallResponse { pub meta: Option, } -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/apps/list", response = AppsListResponse)] -#[serde(rename_all = "camelCase")] -pub struct AppsListRequest { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_id: Option, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct AppsListResponse { - #[serde(default)] - pub apps: Vec, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/apps/export", response = AppsExportResponse)] -#[serde(rename_all = "camelCase")] -pub struct AppsExportRequest { - pub name: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct AppsExportResponse { - pub html: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/apps/import", response = AppsImportResponse)] -#[serde(rename_all = "camelCase")] -pub struct AppsImportRequest { - pub html: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct AppsImportResponse { - pub name: String, - pub message: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/apps/delete", response = AppsDeleteResponse)] -#[serde(rename_all = "camelCase")] -pub struct AppsDeleteRequest { - pub name: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct AppsDeleteResponse { - pub name: String, - pub message: String, -} - /// Update the working directory for a session. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/unstable/session/working-dir/update", response = EmptyResponse)] @@ -210,119 +139,6 @@ pub struct SetSessionSystemPromptRequest { pub text: String, } -/// Add user input to the currently active prompt without starting a new prompt. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/session/steer", - response = SteerSessionResponse -)] -#[serde(rename_all = "camelCase")] -pub struct SteerSessionRequest { - pub session_id: String, - #[serde(default)] - pub prompt: Vec, - pub expected_run_id: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct SteerSessionResponse { - pub run_id: String, - /// Stable id of the queued steer message. The same id later appears as - /// `messageId` on the streamed `UserMessageChunk` (with `_meta.goose.steer`), - /// letting clients correlate a queued steer with its pickup. - pub message_id: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/diagnostics/get", - response = DiagnosticsGetResponse -)] -#[serde(rename_all = "camelCase")] -pub struct DiagnosticsGetRequest { - pub session_id: String, - #[serde(default)] - pub level: DiagnosticsReportLevel, -} - -#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum DiagnosticsReportLevel { - #[default] - Summary, - Full, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct DiagnosticsGetResponse { - pub report: serde_json::Value, -} - -/// Information about a prompt template, including its default content and customization status. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct PromptTemplateEntry { - pub name: String, - pub description: String, - pub default_content: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub user_content: Option, - pub is_customized: bool, -} - -/// List all available Goose prompt templates. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/config/prompts/list", response = ListPromptsResponse)] -#[serde(rename_all = "camelCase")] -pub struct ListPromptsRequest {} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct ListPromptsResponse { - pub prompts: Vec, -} - -/// Read a Goose prompt template. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/config/prompts/get", response = GetPromptResponse)] -#[serde(rename_all = "camelCase")] -pub struct GetPromptRequest { - pub name: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct GetPromptResponse { - pub name: String, - pub content: String, - pub default_content: String, - pub is_customized: bool, -} - -/// Save a custom Goose prompt template. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/config/prompts/save", response = PromptOperationResponse)] -#[serde(rename_all = "camelCase")] -pub struct SavePromptRequest { - pub name: String, - pub content: String, -} - -/// Reset a Goose prompt template to its default content. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/config/prompts/reset", response = PromptOperationResponse)] -#[serde(rename_all = "camelCase")] -pub struct ResetPromptRequest { - pub name: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct PromptOperationResponse { - pub message: String, -} - /// Delete a session. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "session/delete", response = EmptyResponse)] @@ -331,114 +147,30 @@ pub struct DeleteSessionRequest { pub session_id: String, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum GooseExtension { - Builtin { - name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - display_name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - timeout: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - bundled: Option, - /// Tool allowlist for this extension. Omit this field to allow all tools. - #[serde(default, skip_serializing_if = "Option::is_none")] - available_tools: Option>, - }, - Platform { - name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - display_name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - bundled: Option, - /// Tool allowlist for this extension. Omit this field to allow all tools. - #[serde(default, skip_serializing_if = "Option::is_none")] - available_tools: Option>, - }, - Mcp { - server: McpServer, - #[serde(default, rename = "envKeys", skip_serializing_if = "Vec::is_empty")] - env_keys: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - timeout: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - socket: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - bundled: Option, - /// Tool allowlist for this extension. Omit this field to allow all tools. - #[serde(default, skip_serializing_if = "Option::is_none")] - available_tools: Option>, - }, -} - -impl Default for GooseExtension { - fn default() -> Self { - Self::Builtin { - name: String::new(), - description: None, - display_name: None, - timeout: None, - bundled: None, - available_tools: None, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct GooseExtensionEntry { - pub extension: GooseExtension, - pub enabled: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_key: Option, -} - -/// List Goose-owned extension definitions available to configure or enable. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/extensions/available", - response = GetAvailableExtensionsResponse -)] -pub struct GetAvailableExtensionsRequest {} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct GetAvailableExtensionsResponse { - pub extensions: Vec, -} - /// List configured extensions and any warnings. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/config/extensions/list", - response = GetConfigExtensionsResponse -)] -pub struct GetConfigExtensionsRequest {} +#[request(method = "_goose/unstable/config/extensions/list", response = GetExtensionsResponse)] +pub struct GetExtensionsRequest {} /// List configured extensions and any warnings. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct GetConfigExtensionsResponse { - pub extensions: Vec, - #[serde(default)] +pub struct GetExtensionsResponse { + /// Array of ExtensionEntry objects with `enabled` flag, `configKey`, and flattened config details. + pub extensions: Vec, pub warnings: Vec, } -pub type GetExtensionsRequest = GetConfigExtensionsRequest; -pub type GetExtensionsResponse = GetConfigExtensionsResponse; - /// Persist a new extension to the user's global goose config. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/unstable/config/extensions/add", response = EmptyResponse)] #[serde(rename_all = "camelCase")] pub struct AddConfigExtensionRequest { - pub extension: GooseExtension, + 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, } @@ -451,14 +183,11 @@ pub struct RemoveConfigExtensionRequest { pub config_key: String, } -/// Set the `enabled` flag for a persisted extension in the user's global goose config. +/// 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/unstable/config/extensions/set-enabled", - response = EmptyResponse -)] +#[request(method = "_goose/unstable/config/extensions/toggle", response = EmptyResponse)] #[serde(rename_all = "camelCase")] -pub struct SetConfigExtensionEnabledRequest { +pub struct ToggleConfigExtensionRequest { pub config_key: String, pub enabled: bool, } @@ -472,7 +201,7 @@ pub struct GetSessionExtensionsRequest { #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] pub struct GetSessionExtensionsResponse { - pub extensions: Vec, + pub extensions: Vec, } /// Read allowlisted user preferences. Empty `keys` means all supported preferences. @@ -502,58 +231,11 @@ pub struct PreferencesRemoveRequest { pub keys: Vec, } -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/config/read", response = ConfigReadResponse)] -#[serde(rename_all = "camelCase")] -pub struct ConfigReadRequest { - pub key: String, - #[serde(default)] - pub is_secret: bool, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct ConfigReadResponse { - #[serde(default)] - pub value: serde_json::Value, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/config/upsert", response = EmptyResponse)] -#[serde(rename_all = "camelCase")] -pub struct ConfigUpsertRequest { - pub key: String, - pub value: serde_json::Value, - #[serde(default)] - pub is_secret: bool, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/config/remove", response = EmptyResponse)] -#[serde(rename_all = "camelCase")] -pub struct ConfigRemoveRequest { - pub key: String, - #[serde(default)] - pub is_secret: bool, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/config/read-all", response = ConfigReadAllResponse)] -#[serde(rename_all = "camelCase")] -pub struct ConfigReadAllRequest {} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct ConfigReadAllResponse { - pub config: std::collections::HashMap, -} - #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub enum PreferenceKey { #[default] AutoCompactThreshold, - GooseThinkingEffort, VoiceAutoSubmitPhrases, VoiceDictationProvider, VoiceDictationPreferredMic, @@ -596,12 +278,6 @@ pub struct DefaultsSaveRequest { pub model_id: Option, } -/// Clear Goose default provider and model configuration. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/defaults/clear", response = DefaultsReadResponse)] -#[serde(rename_all = "camelCase")] -pub struct DefaultsClearRequest {} - /// Sources that onboarding knows how to discover and import. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -695,35 +371,6 @@ pub struct DictationSecretDeleteRequest { pub provider: String, } -/// Return list-style metadata for a single session without loading the conversation. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/session/info", - response = GetSessionInfoResponse -)] -#[serde(rename_all = "camelCase")] -pub struct GetSessionInfoRequest { - pub session_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct GetSessionInfoResponse { - pub session: SessionInfo, -} - -/// Truncate a session conversation from the given message timestamp onward. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/session/conversation/truncate", - response = EmptyResponse -)] -#[serde(rename_all = "camelCase")] -pub struct TruncateSessionConversationRequest { - pub session_id: String, - pub truncate_from: i64, -} - /// Update the project association for a session. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/unstable/session/project/update", response = EmptyResponse)] @@ -772,42 +419,11 @@ pub struct ExportSessionResponse { pub data: String, } -/// Import a session from a JSON string or share link. +/// Import a session from a JSON string. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/unstable/session/import", response = ImportSessionResponse)] -#[serde(rename_all = "camelCase")] pub struct ImportSessionRequest { - pub input: String, - pub source: SessionImportSource, -} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum SessionImportSource { - #[default] - Auto, - Json, - Nostr, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/session/share/nostr", - response = ShareSessionNostrResponse -)] -#[serde(rename_all = "camelCase")] -pub struct ShareSessionNostrRequest { - pub session_id: String, - pub relays: Vec, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct ShareSessionNostrResponse { - pub deeplink: String, - pub nevent: String, - pub event_id: String, - pub relays: Vec, + pub data: String, } /// Import session response โ€” metadata about the newly created session. @@ -937,107 +553,6 @@ pub struct ProviderConfigChangeResponse { pub refresh: RefreshProviderInventoryResponse, } -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum ProviderSecretStorageDto { - #[default] - SecretStore, - ProviderCache, -} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum ProviderSecretStatusDto { - Valid, - Expired, - #[default] - Unknown, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct ProviderSecretDto { - pub id: String, - pub provider: String, - pub provider_display_name: String, - pub name: String, - pub storage: ProviderSecretStorageDto, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub expires_at: Option, - pub status: ProviderSecretStatusDto, - pub configured: bool, - pub has_secret: bool, - pub can_delete: bool, - pub can_configure: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub configure_provider: Option, -} - -/// List provider credentials stored locally by Goose. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/providers/secrets/list", - response = ProviderSecretsListResponse -)] -#[serde(rename_all = "camelCase")] -pub struct ProviderSecretsListRequest {} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct ProviderSecretsListResponse { - pub secrets: Vec, -} - -/// Delete a locally stored provider credential by id. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/providers/secrets/delete", - response = EmptyResponse -)] -#[serde(rename_all = "camelCase")] -pub struct ProviderSecretDeleteRequest { - pub id: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct CanonicalModelInfoDto { - pub provider: String, - pub model: String, - pub context_limit: usize, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_output_tokens: Option, - pub reasoning: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub input_token_cost: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub output_token_cost: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cache_read_token_cost: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cache_write_token_cost: Option, - pub currency: String, -} - -/// Look up canonical (bundled-registry) model info for a provider/model pair. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/providers/canonical-model-info", - response = CanonicalModelInfoResponse -)] -#[serde(rename_all = "camelCase")] -pub struct CanonicalModelInfoRequest { - pub provider: String, - pub model: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct CanonicalModelInfoResponse { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model_info: Option, -} - #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct ProviderTemplateCatalogEntryDto { @@ -1461,58 +976,6 @@ pub struct ListSourcesResponse { pub sources: Vec, } -/// A user-facing `@` mention target backed by an agent, recipe, or subrecipe source. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct AgentMention { - pub name: String, - pub description: String, - pub source_type: SourceType, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_path: Option, - pub mention: String, -} - -/// List user-facing agent mention targets for `@` autocomplete. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/agent-mentions/list", - response = ListAgentMentionsResponse -)] -#[serde(rename_all = "camelCase")] -pub struct ListAgentMentionsRequest { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_id: Option, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct ListAgentMentionsResponse { - pub agents: Vec, -} - -/// List slash commands available for `/` autocomplete. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/slash-commands/list", - response = ListSlashCommandsResponse -)] -#[serde(rename_all = "camelCase")] -pub struct ListSlashCommandsRequest { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_id: Option, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct ListSlashCommandsResponse { - pub available_commands: Vec, -} - /// Update an existing source's name, description, and content by absolute path. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/unstable/sources/update", response = UpdateSourceResponse)] @@ -1784,363 +1247,6 @@ pub struct ProviderInventoryEntryDto { pub model_selection_hint: Option, } -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum LocalInferenceToolCallingMode { - #[default] - Auto, - ForceNative, - ForceEmulated, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum LocalInferenceChatTemplate { - #[default] - Embedded, - Builtin { - name: String, - }, - CustomInline { - template: String, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "type", rename_all_fields = "camelCase")] -pub enum LocalInferenceSamplingConfig { - Greedy, - Temperature { - temperature: f32, - top_k: i32, - top_p: f32, - min_p: f32, - #[serde(default, skip_serializing_if = "Option::is_none")] - seed: Option, - }, - MirostatV2 { - tau: f32, - eta: f32, - #[serde(default, skip_serializing_if = "Option::is_none")] - seed: Option, - }, -} - -impl Default for LocalInferenceSamplingConfig { - fn default() -> Self { - Self::Temperature { - temperature: 0.8, - top_k: 40, - top_p: 0.95, - min_p: 0.05, - seed: None, - } - } -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceModelSettingsDto { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub backend_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub context_size: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_output_tokens: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub draft_model: Option, - #[serde(default)] - pub sampling: LocalInferenceSamplingConfig, - pub repeat_penalty: f32, - pub repeat_last_n: i32, - pub frequency_penalty: f32, - pub presence_penalty: f32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub n_batch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub n_gpu_layers: Option, - pub use_mlock: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub flash_attention: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub n_threads: Option, - #[serde(default)] - pub tool_calling: LocalInferenceToolCallingMode, - #[serde(default)] - pub chat_template: LocalInferenceChatTemplate, - pub enable_thinking: bool, - pub vision_capable: bool, - pub image_token_estimate: usize, - pub mmproj_size_bytes: u64, -} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub enum LocalInferenceDownloadState { - #[default] - NotDownloaded, - Downloading, - Downloaded, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceModelDownloadStatusDto { - pub state: LocalInferenceDownloadState, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub progress_percent: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bytes_downloaded: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub total_bytes: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub speed_bps: Option, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceDownloadProgressDto { - pub model_id: String, - pub status: String, - pub bytes_downloaded: u64, - pub total_bytes: u64, - pub progress_percent: f32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub speed_bps: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub eta_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option, - pub task_exited: bool, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceModelDto { - pub id: String, - pub repo_id: String, - pub filename: String, - pub quantization: String, - pub size_bytes: u64, - pub status: LocalInferenceModelDownloadStatusDto, - pub recommended: bool, - pub is_loaded: bool, - pub settings: LocalInferenceModelSettingsDto, - pub vision_capable: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mmproj_status: Option, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceHfModelVariantDto { - pub variant_id: String, - pub label: String, - pub backend_id: String, - pub format: String, - pub model_id: String, - pub download_id: String, - pub size_bytes: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub filename: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub download_url: Option, - pub description: String, - pub quality_rank: u8, - pub sharded: bool, - pub supported: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub unsupported_reason: Option, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceHfGgufFileDto { - pub filename: String, - pub size_bytes: u64, - pub quantization: String, - pub download_url: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceHfModelInfoDto { - pub repo_id: String, - pub author: String, - pub model_name: String, - pub downloads: u64, - #[serde(default)] - pub gguf_files: Vec, - #[serde(default)] - pub variants: Vec, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/local-inference/models/list", - response = LocalInferenceModelsListResponse -)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceModelsListRequest {} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceModelsListResponse { - pub models: Vec, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/local-inference/models/download", - response = LocalInferenceModelDownloadResponse -)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceModelDownloadRequest { - pub spec: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub backend_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub variant_id: Option, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceModelDownloadResponse { - pub model_id: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/local-inference/models/download/progress", - response = LocalInferenceModelDownloadProgressResponse -)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceModelDownloadProgressRequest { - pub model_id: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceModelDownloadProgressResponse { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub progress: Option, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/local-inference/models/download/cancel", - response = EmptyResponse -)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceModelDownloadCancelRequest { - pub model_id: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/local-inference/models/delete", - response = EmptyResponse -)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceModelDeleteRequest { - pub model_id: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/local-inference/models/evict", - response = EmptyResponse -)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceModelEvictRequest { - pub model_id: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/local-inference/models/settings/read", - response = LocalInferenceModelSettingsReadResponse -)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceModelSettingsReadRequest { - pub model_id: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceModelSettingsReadResponse { - pub settings: LocalInferenceModelSettingsDto, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/local-inference/models/settings/update", - response = LocalInferenceModelSettingsUpdateResponse -)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceModelSettingsUpdateRequest { - pub model_id: String, - pub settings: LocalInferenceModelSettingsDto, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceModelSettingsUpdateResponse { - pub settings: LocalInferenceModelSettingsDto, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/local-inference/huggingface/search", - response = LocalInferenceHuggingFaceSearchResponse -)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceHuggingFaceSearchRequest { - pub query: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub limit: Option, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceHuggingFaceSearchResponse { - pub models: Vec, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/local-inference/huggingface/repo/variants", - response = LocalInferenceHuggingFaceRepoVariantsResponse -)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceHuggingFaceRepoVariantsRequest { - pub repo_id: String, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceHuggingFaceRepoVariantsResponse { - pub variants: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub recommended_index: Option, - pub available_memory_bytes: u64, - pub downloaded_quants: Vec, - pub downloaded_variants: Vec, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/local-inference/chat-templates/builtin/list", - response = LocalInferenceBuiltinChatTemplatesListResponse -)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceBuiltinChatTemplatesListRequest {} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct LocalInferenceBuiltinChatTemplatesListResponse { - pub templates: Vec, -} - /// Empty success response for operations that return no data. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] pub struct EmptyResponse {} @@ -2169,7 +1275,6 @@ pub struct DictationLocalModelStatus { pub size_mb: u32, pub downloaded: bool, pub download_in_progress: bool, - pub recommended: bool, } /// Kick off a background download of a local Whisper model. @@ -2233,32 +1338,3 @@ pub struct DictationModelSelectRequest { pub provider: String, pub model_id: String, } - -/// Permission level for a tool. -#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum ToolPermissionLevel { - AlwaysAllow, - #[default] - AskBefore, - NeverAllow, -} - -/// A single tool permission entry. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct ToolPermissionEntry { - pub tool_name: String, - pub permission: ToolPermissionLevel, -} - -/// Set permission levels for one or more tools. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/tools/permissions/set", response = SetToolPermissionsResponse)] -#[serde(rename_all = "camelCase")] -pub struct SetToolPermissionsRequest { - pub tool_permissions: Vec, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct SetToolPermissionsResponse {} diff --git a/crates/goose-sdk/src/lib.rs b/crates/goose-sdk/src/lib.rs index 4742ea2de8..6c1c1bf50c 100644 --- a/crates/goose-sdk/src/lib.rs +++ b/crates/goose-sdk/src/lib.rs @@ -1,19 +1 @@ -//! Goose SDK. -//! -//! With default features this crate re-exports the shared SDK wire types from -//! `goose-sdk-types` so you can build an Agent Client Protocol (ACP) client -//! that talks to `goose acp` over stdio. -//! -//! With `--features uniffi` the crate additionally compiles as a -//! `cdylib`/`staticlib` and exposes an in-process API to Python and Kotlin via -//! [uniffi-rs](https://github.com/mozilla/uniffi-rs). The current uniffi surface -//! lets callers construct declarative providers from JSON and stream provider -//! completions. - -pub use goose_sdk_types::{custom_notifications, custom_requests}; - -#[cfg(feature = "uniffi")] -uniffi::setup_scaffolding!("goose"); - -#[cfg(feature = "uniffi")] -pub mod bindings; +pub mod custom_requests; diff --git a/crates/goose-sdk/uniffi.toml b/crates/goose-sdk/uniffi.toml deleted file mode 100644 index aef1f592c9..0000000000 --- a/crates/goose-sdk/uniffi.toml +++ /dev/null @@ -1,2 +0,0 @@ -[bindings.kotlin] -package_name = "io.aaif.goose" diff --git a/crates/goose-server/ALLOWLIST.md b/crates/goose-server/ALLOWLIST.md new file mode 100644 index 0000000000..5634adfdb6 --- /dev/null +++ b/crates/goose-server/ALLOWLIST.md @@ -0,0 +1,60 @@ +IMPORTANT: currently GOOSE_ALLOWLIST is used in main.ts in ui/desktop, and not in goose-server. The following is for reference when it is used on the server side for launch time enforcement. + +# goose Extension Allowlist + +The allowlist feature provides a security mechanism for controlling which MCP commands can be used by goose. +By default, goose will let you run any MCP via any command, which isn't always desired. + +## How It Works + +1. When enabled, goose will only allow execution of commands that match entries in the allowlist +2. Commands not in the allowlist will be rejected with an error message +3. The allowlist is fetched from a URL specified by the `GOOSE_ALLOWLIST` environment variable and cached while running. + +## Setup + +Set the `GOOSE_ALLOWLIST` environment variable to the URL of your allowlist YAML file: + +```bash +export GOOSE_ALLOWLIST=https://example.com/goose-allowlist.yaml +``` + +If this environment variable is not set, no allowlist restrictions will be applied (all commands will be allowed). + +## Bypassing the Allowlist + +In certain development or testing scenarios, you may need to bypass the allowlist restrictions. You can do this by setting the `GOOSE_ALLOWLIST_BYPASS` environment variable to `true`: + +```bash +# For the GUI, you can have it show a warning instead of blocking (but it will always show a warning): +export GOOSE_ALLOWLIST_WARNING=true +``` + + +When this environment variable is set to `true` (case-insensitive), the allowlist check will be bypassed and all commands will be allowed, even if the `GOOSE_ALLOWLIST` environment variable is set. + +## Allowlist File Format + +The allowlist file should be a YAML file with the following structure: + +```yaml +extensions: + - id: extension-id-1 + command: command-name-1 + - id: extension-id-2 + command: command-name-2 +``` + +Example: + +```yaml +extensions: + - id: slack + command: uvx mcp_slack + - id: github + command: uvx mcp_github + - id: jira + command: uvx mcp_jira +``` + +Note that the command should be the full command to launch the MCP (environment variables are provided for context by goose). Additional arguments will be rejected (to avoid injection attacks) \ No newline at end of file diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml new file mode 100644 index 0000000000..c1f04fc6dd --- /dev/null +++ b/crates/goose-server/Cargo.toml @@ -0,0 +1,112 @@ +[package] +name = "goose-server" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description.workspace = true + +[lints] +workspace = true + +[features] +default = [ + "code-mode", + "local-inference", + "aws-providers", + "telemetry", + "nostr", + "otel", + "rustls-tls", + "system-keyring", +] +code-mode = ["goose/code-mode"] +local-inference = ["goose/local-inference"] +aws-providers = ["goose/aws-providers"] +cuda = ["goose/cuda", "local-inference"] +vulkan = ["goose/vulkan", "local-inference"] +telemetry = ["goose/telemetry"] +nostr = ["goose/nostr"] +otel = ["goose/otel"] +system-keyring = ["goose/system-keyring"] +portable-default = ["rustls-tls", "aws-providers", "telemetry", "otel"] +rustls-tls = [ + "reqwest/rustls", + "tokio-tungstenite/rustls-tls-native-roots", + "axum-server/tls-rustls", + "dep:rustls", + "dep:aws-lc-rs", + "goose/rustls-tls", + "goose-mcp/rustls-tls", +] +native-tls = [ + "reqwest/native-tls", + "tokio-tungstenite/native-tls", + "axum-server/tls-openssl", + "dep:openssl", + "goose/native-tls", + "goose-mcp/native-tls", +] + +[dependencies] +goose = { path = "../goose", default-features = false } +goose-mcp = { path = "../goose-mcp", default-features = false } +rmcp = { workspace = true } +axum = { workspace = true, features = ["ws", "macros"] } +tokio = { workspace = true } +chrono = { workspace = true } +tower-http = { workspace = true, features = ["cors"] } +serde = { workspace = true } +serde_json = { workspace = true, features = ["preserve_order"] } +futures = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json", "time"] } +tokio-stream = { workspace = true } +anyhow = { workspace = true } +bytes = { workspace = true } +http = { workspace = true } +base64 = { workspace = true } +config = { version = "0.15", default-features = false, features = ["toml"] } +thiserror = { workspace = true } +clap = { workspace = true } +serde_yaml = { workspace = true } +utoipa = { workspace = true, features = ["axum_extras", "chrono"] } +reqwest = { workspace = true, features = ["json", "blocking", "multipart", "system-proxy"] } +tokio-util = { workspace = true } +serde_path_to_error = { version = "0.1.8", default-features = false } +tokio-tungstenite = { version = "0.29", default-features = false, features = ["connect"] } +url = { workspace = true } +rand = { workspace = true } +hex = { version = "0.4.3", default-features = false, features = ["std"] } +subtle = { version = "2.5", default-features = false, features = ["std"] } +socket2 = { version = "0.6", default-features = false } +fs2 = { workspace = true } +rustls = { workspace = true, optional = true } +uuid = { workspace = true } +rcgen = { version = "0.14", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] } +axum-server = { version = "0.8", default-features = false } +aws-lc-rs = { version = "1.17", default-features = false, optional = true } +openssl = { version = "0.10.66", default-features = false, optional = true } +pem = { version = "3.0.2", default-features = false, features = ["std"] } + +[target.'cfg(windows)'.dependencies] +winreg = { version = "0.56", default-features = false } + +[[bin]] +name = "goosed" +path = "src/main.rs" + +[[bin]] +name = "generate_schema" +path = "src/bin/generate_schema.rs" + +[dev-dependencies] +tower = { version = "0.5.2", default-features = false } + +[package.metadata.cargo-machete] +ignored = [ + # Used only in windows + "winreg", +] diff --git a/crates/goose-server/build.rs b/crates/goose-server/build.rs new file mode 100644 index 0000000000..23a0fa399a --- /dev/null +++ b/crates/goose-server/build.rs @@ -0,0 +1,4 @@ +// We'll generate the schema at runtime since we need access to the complete application context +fn main() { + println!("cargo:rerun-if-changed=src/"); +} diff --git a/crates/goose/src/acp/transport/auth.rs b/crates/goose-server/src/auth.rs similarity index 55% rename from crates/goose/src/acp/transport/auth.rs rename to crates/goose-server/src/auth.rs index 9becc6e3b1..7503b4dae1 100644 --- a/crates/goose/src/acp/transport/auth.rs +++ b/crates/goose-server/src/auth.rs @@ -6,12 +6,37 @@ use axum::{ }; use subtle::ConstantTimeEq; -pub fn token_matches(candidate: Option<&str>, expected: &str) -> bool { +fn token_matches(candidate: Option<&str>, expected: &str) -> bool { candidate .map(|key| bool::from(key.as_bytes().ct_eq(expected.as_bytes()))) .unwrap_or(false) } +pub async fn check_token( + State(state): State, + request: Request, + next: Next, +) -> Result { + if request.uri().path() == "/status" + || request.uri().path() == "/features" + || request.uri().path() == "/mcp-ui-proxy" + || request.uri().path() == "/mcp-app-proxy" + || request.uri().path() == "/mcp-app-guest" + { + return Ok(next.run(request).await); + } + let secret_key = request + .headers() + .get("X-Secret-Key") + .and_then(|value| value.to_str().ok()); + + if token_matches(secret_key, &state) { + Ok(next.run(request).await) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} + pub async fn check_acp_token( State(state): State, request: Request, diff --git a/crates/goose-server/src/bin/generate_schema.rs b/crates/goose-server/src/bin/generate_schema.rs new file mode 100644 index 0000000000..ec5bd33aad --- /dev/null +++ b/crates/goose-server/src/bin/generate_schema.rs @@ -0,0 +1,30 @@ +use goose_server::openapi; +use std::env; +use std::fs; +use std::path::PathBuf; + +fn main() { + let schema = openapi::generate_schema(); + + let package_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let output_path = PathBuf::from(package_dir) + .join("..") + .join("..") + .join("ui") + .join("desktop") + .join("openapi.json"); + + // Ensure parent directory exists + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + + fs::write(&output_path, format!("{schema}\n")).unwrap(); + eprintln!( + "Successfully generated OpenAPI schema at {}", + output_path.canonicalize().unwrap().display() + ); + + // Output the schema to stdout for piping + println!("{}", schema); +} diff --git a/crates/goose-server/src/commands/agent.rs b/crates/goose-server/src/commands/agent.rs new file mode 100644 index 0000000000..ef9be3ac70 --- /dev/null +++ b/crates/goose-server/src/commands/agent.rs @@ -0,0 +1,166 @@ +use crate::configuration; +use crate::state; +use anyhow::Result; +use axum::middleware; +use axum_server::Handle; +use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig}; +use goose::acp::transport::create_acp_router; +use goose::agents::GoosePlatform; +use goose::config::paths::Paths; +use goose_server::auth::{check_acp_token, check_token}; +#[cfg(any(feature = "rustls-tls", feature = "native-tls"))] +use goose_server::tls::setup_tls; +use std::sync::Arc; +use tower_http::cors::{Any, CorsLayer}; +use tracing::info; + +fn boot_marker(message: &str) { + eprintln!("GOOSED_BOOT: {message}"); +} + +#[cfg(unix)] +async fn shutdown_signal() { + use tokio::signal::unix::{signal, SignalKind}; + + let mut sigint = signal(SignalKind::interrupt()).expect("failed to install SIGINT handler"); + let mut sigterm = signal(SignalKind::terminate()).expect("failed to install SIGTERM handler"); + + tokio::select! { + _ = sigint.recv() => {}, + _ = sigterm.recv() => {}, + } +} + +#[cfg(not(unix))] +async fn shutdown_signal() { + let _ = tokio::signal::ctrl_c().await; +} + +pub async fn run() -> Result<()> { + // Install the rustls crypto provider early, before any spawned tasks (tunnel, + // gateways, etc.) try to open TLS connections. Both `ring` and `aws-lc-rs` + // features are enabled on rustls (via different transitive deps), so rustls + // cannot auto-detect a provider โ€” we must pick one explicitly. + #[cfg(feature = "rustls-tls")] + let _ = rustls::crypto::ring::default_provider().install_default(); + + boot_marker("main entered"); + crate::logging::setup_logging(Some("goosed"))?; + + goose::security::set_security_defaults(); + + let settings = configuration::Settings::new()?; + + let secret_key = std::env::var("GOOSE_SERVER__SECRET_KEY") + .unwrap_or_else(|_| hex::encode(rand::random::<[u8; 32]>())); + + boot_marker("appstate init start"); + let app_state = state::AppState::new(settings.tls).await?; + + // Share the server secret with the tunnel manager so it uses the same + // key for forwarded requests, without mutating the process environment. + app_state + .tunnel_manager + .set_server_secret(secret_key.clone()) + .await; + + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + // TODO(acp-migration): When ui/desktop launches `goose serve` directly, + // move any goosed-only ACP setup into the goose serve path before deleting + // this bridge. In particular, verify everything ACP currently gets from + // goosed startup/AppState initialization, including builtin extension + // registration and the desktop platform identity. + let acp_server = Arc::new(AcpServer::new(AcpServerFactoryConfig { + builtins: vec!["developer".to_string()], + data_dir: Paths::data_dir(), + config_dir: Paths::config_dir(), + goose_platform: GoosePlatform::GooseDesktop, + additional_source_roots: Vec::new(), + })); + + let rest_router = crate::routes::configure(app_state.clone(), secret_key.clone()).layer( + middleware::from_fn_with_state(secret_key.clone(), check_token), + ); + let acp_router = create_acp_router(acp_server).layer(middleware::from_fn_with_state( + secret_key.clone(), + check_acp_token, + )); + + let app = rest_router.merge(acp_router).layer(cors); + + let addr = settings.socket_addr(); + + let tunnel_manager = app_state.tunnel_manager.clone(); + tokio::spawn(async move { + tunnel_manager.check_auto_start().await; + }); + + let gateway_manager = app_state.gateway_manager.clone(); + tokio::spawn(async move { + gateway_manager.check_auto_start().await; + }); + + if settings.tls { + #[cfg(any(feature = "rustls-tls", feature = "native-tls"))] + { + boot_marker("tls setup start"); + let tls_setup = setup_tls( + settings.tls_cert_path.as_deref(), + settings.tls_key_path.as_deref(), + ) + .await?; + + let handle = Handle::new(); + let shutdown_handle = handle.clone(); + tokio::spawn(async move { + shutdown_signal().await; + shutdown_handle.graceful_shutdown(None); + }); + + info!("listening on https://{}", addr); + boot_marker("listening"); + + #[cfg(feature = "rustls-tls")] + axum_server::bind_rustls(addr, tls_setup.config) + .handle(handle) + .serve(app.into_make_service()) + .await?; + + #[cfg(feature = "native-tls")] + axum_server::bind_openssl(addr, tls_setup.config) + .handle(handle) + .serve(app.into_make_service()) + .await?; + } + + #[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))] + { + anyhow::bail!( + "TLS was requested but no TLS backend is enabled. \ + Enable the `rustls-tls` or `native-tls` feature." + ); + } + } else { + boot_marker("tcp bind start"); + let listener = tokio::net::TcpListener::bind(addr).await?; + + info!("listening on http://{}", addr); + boot_marker("listening"); + + axum::serve(listener, app) + .with_graceful_shutdown(async { shutdown_signal().await }) + .await?; + } + + #[cfg(feature = "otel")] + if goose::otel::otlp::is_otlp_initialized() { + goose::otel::otlp::shutdown_otlp(); + } + + info!("server shutdown complete"); + Ok(()) +} diff --git a/crates/goose-server/src/commands/mod.rs b/crates/goose-server/src/commands/mod.rs new file mode 100644 index 0000000000..f17bc55db8 --- /dev/null +++ b/crates/goose-server/src/commands/mod.rs @@ -0,0 +1 @@ +pub mod agent; diff --git a/crates/goose-server/src/configuration.rs b/crates/goose-server/src/configuration.rs new file mode 100644 index 0000000000..c6f4583671 --- /dev/null +++ b/crates/goose-server/src/configuration.rs @@ -0,0 +1,102 @@ +use crate::error::{to_env_var, ConfigError}; +use config::{Config, Environment}; +use serde::Deserialize; +use std::net::SocketAddr; + +#[derive(Debug, Default, Deserialize)] +pub struct Settings { + #[serde(default = "default_host")] + pub host: String, + #[serde(default = "default_port")] + pub port: u16, + #[serde(default = "default_tls")] + pub tls: bool, + pub tls_cert_path: Option, + pub tls_key_path: Option, +} + +impl Settings { + pub fn socket_addr(&self) -> SocketAddr { + format!("{}:{}", self.host, self.port) + .parse() + .expect("Failed to parse socket address") + } + + pub fn new() -> Result { + Self::load_and_validate() + } + + fn load_and_validate() -> Result { + // Start with default configuration + let config = Config::builder() + // Server defaults + .set_default("host", default_host())? + .set_default("port", default_port())? + .set_default("tls", default_tls())? + // Layer on the environment variables + .add_source( + Environment::with_prefix("GOOSE") + .prefix_separator("_") + .separator("__") + .try_parsing(true), + ) + .build()?; + + // Try to deserialize the configuration + let result: Result = config.try_deserialize(); + + // Handle missing field errors specially + match result { + Ok(settings) => Ok(settings), + Err(err) => { + tracing::debug!("Configuration error: {:?}", &err); + + // Handle both NotFound and missing field message variants + let error_str = err.to_string(); + if error_str.starts_with("missing field") { + // Extract field name from error message "missing field `type`" + let field = error_str + .trim_start_matches("missing field `") + .trim_end_matches("`"); + let env_var = to_env_var(field); + Err(ConfigError::MissingEnvVar { env_var }) + } else if let config::ConfigError::NotFound(field) = &err { + let env_var = to_env_var(field); + Err(ConfigError::MissingEnvVar { env_var }) + } else { + Err(ConfigError::Other(err)) + } + } + } + } +} + +fn default_host() -> String { + "127.0.0.1".to_string() +} + +fn default_port() -> u16 { + 3000 +} + +fn default_tls() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_socket_addr_conversion() { + let server_settings = Settings { + host: "127.0.0.1".to_string(), + port: 3000, + tls: true, + tls_cert_path: None, + tls_key_path: None, + }; + let addr = server_settings.socket_addr(); + assert_eq!(addr.to_string(), "127.0.0.1:3000"); + } +} diff --git a/crates/goose-server/src/error.rs b/crates/goose-server/src/error.rs new file mode 100644 index 0000000000..5f38f85f1c --- /dev/null +++ b/crates/goose-server/src/error.rs @@ -0,0 +1,40 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("Missing required environment variable: {env_var}")] + MissingEnvVar { env_var: String }, + #[error("Configuration error: {0}")] + Other(#[from] config::ConfigError), +} + +// Helper function to format environment variable names +pub(crate) fn to_env_var(field_path: &str) -> String { + // Handle nested fields by converting dots to double underscores + // If the field is in the provider object, we need to prefix it appropriately + let normalized_path = if field_path == "type" { + "provider.type".to_string() + } else if field_path.starts_with("provider.") { + field_path.to_string() + } else { + format!("provider.{}", field_path) + }; + + format!( + "GOOSE_{}", + normalized_path.replace('.', "__").to_uppercase() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_env_var_conversion() { + assert_eq!(to_env_var("type"), "GOOSE_PROVIDER__TYPE"); + assert_eq!(to_env_var("api_key"), "GOOSE_PROVIDER__API_KEY"); + assert_eq!(to_env_var("provider.host"), "GOOSE_PROVIDER__HOST"); + assert_eq!(to_env_var("provider.api_key"), "GOOSE_PROVIDER__API_KEY"); + } +} diff --git a/crates/goose-server/src/lib.rs b/crates/goose-server/src/lib.rs new file mode 100644 index 0000000000..2323f28774 --- /dev/null +++ b/crates/goose-server/src/lib.rs @@ -0,0 +1,20 @@ +#[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))] +compile_error!("At least one of `rustls-tls` or `native-tls` features must be enabled"); + +#[cfg(all(feature = "rustls-tls", feature = "native-tls"))] +compile_error!("Features `rustls-tls` and `native-tls` are mutually exclusive"); + +pub mod auth; +pub mod configuration; +pub mod error; +pub mod openapi; +pub mod routes; +pub mod session_event_bus; +pub mod state; +#[cfg(any(feature = "rustls-tls", feature = "native-tls"))] +pub mod tls; +pub mod tunnel; + +// Re-export commonly used items +pub use openapi::*; +pub use state::*; diff --git a/crates/goose-server/src/logging.rs b/crates/goose-server/src/logging.rs new file mode 100644 index 0000000000..88af03127c --- /dev/null +++ b/crates/goose-server/src/logging.rs @@ -0,0 +1,17 @@ +use anyhow::Result; +use tracing_subscriber::util::SubscriberInitExt; + +/// Sets up the logging infrastructure for the server. +/// Logs go to a JSON file and a pretty console layer on stderr. +pub fn setup_logging(name: Option<&str>) -> Result<()> { + let config = goose::logging::LoggingConfig { + component: "server", + name, + extra_directives: &["goose_server=info", "tower_http=info"], + console: true, + json: false, + }; + let subscriber = goose::logging::build_logging_subscriber(&config)?; + subscriber.try_init()?; + Ok(()) +} diff --git a/crates/goose-server/src/main.rs b/crates/goose-server/src/main.rs new file mode 100644 index 0000000000..bbb12fe948 --- /dev/null +++ b/crates/goose-server/src/main.rs @@ -0,0 +1,108 @@ +mod commands; +mod configuration; +mod error; +mod logging; +mod openapi; +mod routes; +mod session_event_bus; +mod state; +mod tunnel; + +use std::path::PathBuf; +use std::{backtrace::Backtrace, panic::PanicHookInfo}; + +use clap::{Parser, Subcommand}; +use goose::agents::validate_extensions; +use goose_mcp::{ + mcp_server_runner::{serve, McpCommand}, + AutoVisualiserRouter, ComputerControllerServer, MemoryServer, TutorialServer, +}; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +#[command(propagate_version = true)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Run the agent server + Agent, + /// Run the MCP server + Mcp { + #[arg(value_parser = clap::value_parser!(McpCommand))] + server: McpCommand, + }, + /// Validate a bundled-extensions JSON file + #[command(name = "validate-extensions")] + ValidateExtensions { + /// Path to the bundled-extensions JSON file + path: PathBuf, + }, +} + +fn boot_marker(message: &str) { + eprintln!("GOOSED_BOOT: {message}"); +} + +fn install_panic_hook() { + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info: &PanicHookInfo<'_>| { + let location = panic_info + .location() + .map(|location| format!("{}:{}", location.file(), location.line())) + .unwrap_or_else(|| "unknown".to_string()); + + let payload = panic_info + .payload() + .downcast_ref::<&str>() + .map(|msg| (*msg).to_string()) + .or_else(|| panic_info.payload().downcast_ref::().cloned()) + .unwrap_or_else(|| "unknown panic payload".to_string()); + + eprintln!("GOOSED_BOOT: panic at {location}: {payload}"); + eprintln!("GOOSED_BOOT: backtrace:\n{}", Backtrace::force_capture()); + + default_hook(panic_info); + })); +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + install_panic_hook(); + boot_marker("main entered"); + + let cli = Cli::parse(); + boot_marker(&format!( + "command parsed: {:?}", + std::mem::discriminant(&cli.command) + )); + + match cli.command { + Commands::Agent => { + commands::agent::run().await?; + } + Commands::Mcp { server } => { + logging::setup_logging(Some(&format!("mcp-{}", server.name())))?; + match server { + McpCommand::AutoVisualiser => serve(AutoVisualiserRouter::new()).await?, + McpCommand::ComputerController => serve(ComputerControllerServer::new()).await?, + McpCommand::Memory => serve(MemoryServer::new()).await?, + McpCommand::Tutorial => serve(TutorialServer::new()).await?, + } + } + Commands::ValidateExtensions { path } => { + match validate_extensions::validate_bundled_extensions(&path) { + Ok(msg) => println!("{msg}"), + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + } + } + } + + Ok(()) +} diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs new file mode 100644 index 0000000000..5749b6440a --- /dev/null +++ b/crates/goose-server/src/openapi.rs @@ -0,0 +1,721 @@ +use goose::agents::extension::Envs; +use goose::agents::extension::ToolInfo; +use goose::agents::ExtensionConfig; +use goose::config::permission::PermissionLevel; +use goose::config::ExtensionEntry; +use goose::conversation::Conversation; +use goose::download_manager::{DownloadProgress, DownloadStatus}; +use goose::model::{ModelConfig, ThinkingEffort}; +use goose::permission::permission_confirmation::{Permission, PrincipalType}; +use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata, ProviderType}; +use goose::session::{Session, SessionInsights, SessionType, SystemInfo}; +use rmcp::model::{ + Annotations, Content, EmbeddedResource, Icon, IconTheme, ImageContent, JsonObject, + RawAudioContent, RawContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, + ResourceContents, Role, TaskSupport, TextContent, Tool, ToolAnnotations, ToolExecution, +}; +use utoipa::{OpenApi, ToSchema}; + +use goose::config::declarative_providers::{ + DeclarativeProviderConfig, EnvVarConfig, LoadedProvider, ProviderEngine, +}; +use goose::conversation::message::{ + ActionRequired, ActionRequiredData, FrontendToolRequest, InferenceMetadata, Message, + MessageContent, MessageMetadata, RedactedThinkingContent, SystemNotificationContent, + SystemNotificationType, ThinkingContent, TokenState, ToolConfirmationRequest, ToolRequest, + ToolResponse, +}; + +use crate::routes::recipe_utils::RecipeManifest; +use crate::routes::reply::MessageEvent; +use utoipa::openapi::schema::{ + AdditionalProperties, AnyOfBuilder, ArrayBuilder, ObjectBuilder, OneOfBuilder, Schema, + SchemaFormat, SchemaType, +}; +use utoipa::openapi::{AllOfBuilder, Ref, RefOr}; + +macro_rules! derive_utoipa { + ($inner_type:ident as $schema_name:ident) => { + pub struct $schema_name {} + + impl<'__s> ToSchema<'__s> for $schema_name { + fn schema() -> (&'__s str, utoipa::openapi::RefOr) { + let settings = rmcp::schemars::generate::SchemaSettings::openapi3(); + let generator = settings.into_generator(); + let schema = generator.into_root_schema_for::<$inner_type>(); + let schema = convert_schemars_to_utoipa(schema); + (stringify!($inner_type), schema) + } + + fn aliases() -> Vec<(&'__s str, utoipa::openapi::schema::Schema)> { + Vec::new() + } + } + }; + ($inner_type:ident as $schema_name:ident => $output_name:expr) => { + pub struct $schema_name {} + + impl<'__s> ToSchema<'__s> for $schema_name { + fn schema() -> (&'__s str, utoipa::openapi::RefOr) { + let settings = rmcp::schemars::generate::SchemaSettings::openapi3(); + let generator = settings.into_generator(); + let schema = generator.into_root_schema_for::<$inner_type>(); + let schema = convert_schemars_to_utoipa(schema); + ($output_name, schema) + } + + fn aliases() -> Vec<(&'__s str, utoipa::openapi::schema::Schema)> { + Vec::new() + } + } + }; +} + +fn convert_schemars_to_utoipa(schema: rmcp::schemars::Schema) -> RefOr { + if let Some(true) = schema.as_bool() { + return RefOr::T(Schema::Object(ObjectBuilder::new().build())); + } + + if let Some(false) = schema.as_bool() { + return RefOr::T(Schema::Object(ObjectBuilder::new().build())); + } + + if let Some(obj) = schema.as_object() { + return convert_json_object_to_utoipa(obj); + } + + RefOr::T(Schema::Object(ObjectBuilder::new().build())) +} + +fn convert_json_object_to_utoipa( + obj: &serde_json::Map, +) -> RefOr { + use serde_json::Value; + + if let Some(Value::String(reference)) = obj.get("$ref") { + return RefOr::Ref(Ref::new(reference.clone())); + } + + if let Some(Value::Array(one_of)) = obj.get("oneOf") { + let mut builder = OneOfBuilder::new(); + for item in one_of { + if let Ok(schema) = rmcp::schemars::Schema::try_from(item.clone()) { + builder = builder.item(convert_schemars_to_utoipa(schema)); + } + } + return RefOr::T(Schema::OneOf(builder.build())); + } + + // Handle the discriminated union pattern from schemars: an object with + // `type`, `properties`, `required` AND `allOf` (e.g. each variant of a + // `#[serde(tag = "type")]` enum). We merge the inline object (which carries + // the discriminator property) with the `allOf` refs into a single `allOf`. + if let Some(Value::Array(all_of)) = obj.get("allOf") { + let has_inline_properties = obj.contains_key("properties") || obj.contains_key("type"); + if has_inline_properties { + let mut builder = AllOfBuilder::new(); + // Build an object schema from the inline properties/required + let mut obj_without_allof = obj.clone(); + obj_without_allof.remove("allOf"); + builder = builder.item(convert_json_object_to_utoipa(&obj_without_allof)); + for item in all_of { + if let Ok(schema) = rmcp::schemars::Schema::try_from(item.clone()) { + builder = builder.item(convert_schemars_to_utoipa(schema)); + } + } + return RefOr::T(Schema::AllOf(builder.build())); + } + + let mut builder = AllOfBuilder::new(); + for item in all_of { + if let Ok(schema) = rmcp::schemars::Schema::try_from(item.clone()) { + builder = builder.item(convert_schemars_to_utoipa(schema)); + } + } + return RefOr::T(Schema::AllOf(builder.build())); + } + + if let Some(Value::Array(any_of)) = obj.get("anyOf") { + let mut builder = AnyOfBuilder::new(); + for item in any_of { + if let Ok(schema) = rmcp::schemars::Schema::try_from(item.clone()) { + builder = builder.item(convert_schemars_to_utoipa(schema)); + } + } + return RefOr::T(Schema::AnyOf(builder.build())); + } + + match obj.get("type") { + Some(Value::String(type_str)) => convert_typed_schema(type_str, obj), + Some(Value::Array(types)) => { + let mut builder = AnyOfBuilder::new(); + for type_val in types { + if let Value::String(type_str) = type_val { + builder = builder.item(convert_typed_schema(type_str, obj)); + } + } + RefOr::T(Schema::AnyOf(builder.build())) + } + None => RefOr::T(Schema::Object(ObjectBuilder::new().build())), + _ => RefOr::T(Schema::Object(ObjectBuilder::new().build())), + } +} + +fn convert_typed_schema( + type_str: &str, + obj: &serde_json::Map, +) -> RefOr { + use serde_json::Value; + + match type_str { + "object" => { + let mut object_builder = ObjectBuilder::new(); + + if let Some(Value::Object(properties)) = obj.get("properties") { + for (name, prop_value) in properties { + if let Ok(prop_schema) = rmcp::schemars::Schema::try_from(prop_value.clone()) { + let prop = convert_schemars_to_utoipa(prop_schema); + object_builder = object_builder.property(name, prop); + } + } + } + + if let Some(Value::Array(required)) = obj.get("required") { + for req in required { + if let Value::String(field_name) = req { + object_builder = object_builder.required(field_name); + } + } + } + + if let Some(additional) = obj.get("additionalProperties") { + match additional { + Value::Bool(false) => { + object_builder = object_builder + .additional_properties(Some(AdditionalProperties::FreeForm(false))); + } + Value::Bool(true) => { + object_builder = object_builder + .additional_properties(Some(AdditionalProperties::FreeForm(true))); + } + _ => { + if let Ok(schema) = rmcp::schemars::Schema::try_from(additional.clone()) { + let schema = convert_schemars_to_utoipa(schema); + object_builder = object_builder + .additional_properties(Some(AdditionalProperties::RefOr(schema))); + } + } + } + } + + RefOr::T(Schema::Object(object_builder.build())) + } + "array" => { + let mut array_builder = ArrayBuilder::new(); + + if let Some(items) = obj.get("items") { + match items { + Value::Object(_) | Value::Bool(_) => { + if let Ok(item_schema) = rmcp::schemars::Schema::try_from(items.clone()) { + let item_schema = convert_schemars_to_utoipa(item_schema); + array_builder = array_builder.items(item_schema); + } + } + Value::Array(item_schemas) => { + let mut any_of = AnyOfBuilder::new(); + for item in item_schemas { + if let Ok(schema) = rmcp::schemars::Schema::try_from(item.clone()) { + any_of = any_of.item(convert_schemars_to_utoipa(schema)); + } + } + let any_of_schema = RefOr::T(Schema::AnyOf(any_of.build())); + array_builder = array_builder.items(any_of_schema); + } + _ => {} + } + } + + if let Some(Value::Number(min_items)) = obj.get("minItems") { + if let Some(min) = min_items.as_u64() { + array_builder = array_builder.min_items(Some(min as usize)); + } + } + if let Some(Value::Number(max_items)) = obj.get("maxItems") { + if let Some(max) = max_items.as_u64() { + array_builder = array_builder.max_items(Some(max as usize)); + } + } + + RefOr::T(Schema::Array(array_builder.build())) + } + "string" => { + let mut object_builder = ObjectBuilder::new().schema_type(SchemaType::String); + + if let Some(Value::Array(enum_values)) = obj.get("enum") { + let values: Vec = enum_values + .iter() + .filter_map(|v| { + if let Value::String(s) = v { + Some(Value::String(s.clone())) + } else { + None + } + }) + .collect(); + if !values.is_empty() { + object_builder = object_builder.enum_values(Some(values)); + } + } + + if let Some(Value::Number(min_length)) = obj.get("minLength") { + if let Some(min) = min_length.as_u64() { + object_builder = object_builder.min_length(Some(min as usize)); + } + } + if let Some(Value::Number(max_length)) = obj.get("maxLength") { + if let Some(max) = max_length.as_u64() { + object_builder = object_builder.max_length(Some(max as usize)); + } + } + if let Some(Value::String(pattern)) = obj.get("pattern") { + object_builder = object_builder.pattern(Some(pattern.clone())); + } + if let Some(Value::String(format)) = obj.get("format") { + object_builder = object_builder.format(Some(SchemaFormat::Custom(format.clone()))); + } + + RefOr::T(Schema::Object(object_builder.build())) + } + "number" => { + let mut object_builder = ObjectBuilder::new().schema_type(SchemaType::Number); + + if let Some(Value::Number(minimum)) = obj.get("minimum") { + if let Some(min) = minimum.as_f64() { + object_builder = object_builder.minimum(Some(min)); + } + } + if let Some(Value::Number(maximum)) = obj.get("maximum") { + if let Some(max) = maximum.as_f64() { + object_builder = object_builder.maximum(Some(max)); + } + } + if let Some(Value::Number(exclusive_minimum)) = obj.get("exclusiveMinimum") { + if let Some(min) = exclusive_minimum.as_f64() { + object_builder = object_builder.exclusive_minimum(Some(min)); + } + } + if let Some(Value::Number(exclusive_maximum)) = obj.get("exclusiveMaximum") { + if let Some(max) = exclusive_maximum.as_f64() { + object_builder = object_builder.exclusive_maximum(Some(max)); + } + } + if let Some(Value::Number(multiple_of)) = obj.get("multipleOf") { + if let Some(mult) = multiple_of.as_f64() { + object_builder = object_builder.multiple_of(Some(mult)); + } + } + + RefOr::T(Schema::Object(object_builder.build())) + } + "integer" => { + let mut object_builder = ObjectBuilder::new().schema_type(SchemaType::Integer); + + if let Some(Value::Number(minimum)) = obj.get("minimum") { + if let Some(min) = minimum.as_f64() { + object_builder = object_builder.minimum(Some(min)); + } + } + if let Some(Value::Number(maximum)) = obj.get("maximum") { + if let Some(max) = maximum.as_f64() { + object_builder = object_builder.maximum(Some(max)); + } + } + if let Some(Value::Number(exclusive_minimum)) = obj.get("exclusiveMinimum") { + if let Some(min) = exclusive_minimum.as_f64() { + object_builder = object_builder.exclusive_minimum(Some(min)); + } + } + if let Some(Value::Number(exclusive_maximum)) = obj.get("exclusiveMaximum") { + if let Some(max) = exclusive_maximum.as_f64() { + object_builder = object_builder.exclusive_maximum(Some(max)); + } + } + if let Some(Value::Number(multiple_of)) = obj.get("multipleOf") { + if let Some(mult) = multiple_of.as_f64() { + object_builder = object_builder.multiple_of(Some(mult)); + } + } + + RefOr::T(Schema::Object(object_builder.build())) + } + "boolean" => RefOr::T(Schema::Object( + ObjectBuilder::new() + .schema_type(SchemaType::Boolean) + .build(), + )), + "null" => RefOr::T(Schema::Object( + ObjectBuilder::new().schema_type(SchemaType::String).build(), + )), + _ => RefOr::T(Schema::Object(ObjectBuilder::new().build())), + } +} + +derive_utoipa!(Role as RoleSchema); +derive_utoipa!(Content as ContentSchema); +derive_utoipa!(RawContent as ContentBlockSchema => "ContentBlock"); +derive_utoipa!(EmbeddedResource as EmbeddedResourceSchema); +derive_utoipa!(ImageContent as ImageContentSchema); +derive_utoipa!(TextContent as TextContentSchema); +derive_utoipa!(RawTextContent as RawTextContentSchema); +derive_utoipa!(RawImageContent as RawImageContentSchema); +derive_utoipa!(RawAudioContent as RawAudioContentSchema); +derive_utoipa!(RawEmbeddedResource as RawEmbeddedResourceSchema); +derive_utoipa!(RawResource as RawResourceSchema); +derive_utoipa!(Tool as ToolSchema); +derive_utoipa!(ToolAnnotations as ToolAnnotationsSchema); +derive_utoipa!(ToolExecution as ToolExecutionSchema); +derive_utoipa!(TaskSupport as TaskSupportSchema); +derive_utoipa!(Annotations as AnnotationsSchema); +derive_utoipa!(ResourceContents as ResourceContentsSchema); +derive_utoipa!(JsonObject as JsonObjectSchema); +derive_utoipa!(Icon as IconSchema); +derive_utoipa!(IconTheme as IconThemeSchema); + +#[derive(OpenApi)] +#[openapi( + paths( + super::routes::status::status, + super::routes::status::system_info, + super::routes::status::diagnostics, + super::routes::mcp_ui_proxy::mcp_ui_proxy, + super::routes::config_management::validate_config, + super::routes::config_management::upsert_config, + super::routes::config_management::remove_config, + super::routes::config_management::read_config, + super::routes::config_management::add_extension, + super::routes::config_management::remove_extension, + super::routes::config_management::get_extensions, + super::routes::config_management::read_all_config, + super::routes::config_management::providers, + super::routes::config_management::get_provider_models, + super::routes::config_management::get_provider_model_info, + super::routes::config_management::get_slash_commands, + super::routes::config_management::upsert_permissions, + super::routes::config_management::create_custom_provider, + super::routes::config_management::get_custom_provider, + super::routes::config_management::update_custom_provider, + super::routes::config_management::remove_custom_provider, + super::routes::config_management::get_provider_catalog, + super::routes::config_management::get_provider_catalog_template, + super::routes::config_management::cleanup_provider_cache, + super::routes::config_management::check_provider, + super::routes::config_management::set_config_provider, + super::routes::config_management::configure_provider_oauth, + super::routes::config_management::get_canonical_model_info, + super::routes::prompts::get_prompts, + super::routes::prompts::get_prompt, + super::routes::prompts::save_prompt, + super::routes::prompts::reset_prompt, + super::routes::agent::start_agent, + super::routes::agent::resume_agent, + super::routes::agent::stop_agent, + super::routes::agent::restart_agent, + super::routes::agent::update_working_dir, + super::routes::agent::get_tools, + super::routes::agent::read_resource, + super::routes::agent::call_tool, + super::routes::agent::list_apps, + super::routes::agent::export_app, + super::routes::agent::import_app, + super::routes::agent::update_from_session, + super::routes::agent::agent_add_extension, + super::routes::agent::agent_remove_extension, + super::routes::agent::update_agent_provider, + super::routes::agent::update_session, + super::routes::action_required::confirm_tool_action, + super::routes::reply::reply, + super::routes::session_events::session_events, + super::routes::session_events::session_reply, + super::routes::session_events::session_cancel, + super::routes::session::list_sessions, + super::routes::session::search_sessions, + super::routes::session::get_session, + super::routes::session::get_session_insights, + super::routes::session::update_session_name, + super::routes::session::delete_session, + super::routes::session::export_session, + super::routes::session::import_session, + super::routes::session::share_session_nostr, + super::routes::session::import_session_nostr, + super::routes::session::update_session_user_recipe_values, + super::routes::session::fork_session, + super::routes::session::get_session_extensions, + super::routes::schedule::create_schedule, + super::routes::schedule::list_schedules, + super::routes::schedule::delete_schedule, + super::routes::schedule::update_schedule, + super::routes::schedule::run_now_handler, + super::routes::schedule::pause_schedule, + super::routes::schedule::unpause_schedule, + super::routes::schedule::kill_running_job, + super::routes::schedule::inspect_running_job, + super::routes::schedule::sessions_handler, + super::routes::recipe::create_recipe, + super::routes::recipe::encode_recipe, + super::routes::recipe::decode_recipe, + super::routes::recipe::scan_recipe, + super::routes::recipe::list_recipes, + super::routes::recipe::delete_recipe, + super::routes::recipe::schedule_recipe, + super::routes::recipe::set_recipe_slash_command, + super::routes::recipe::save_recipe, + super::routes::recipe::parse_recipe, + super::routes::recipe::recipe_to_yaml, + super::routes::setup::start_openrouter_setup, + super::routes::setup::start_tetrate_setup, + super::routes::setup::start_nanogpt_setup, + super::routes::tunnel::start_tunnel, + super::routes::tunnel::stop_tunnel, + super::routes::tunnel::get_tunnel_status, + super::routes::telemetry::send_telemetry_event, + super::routes::dictation::transcribe_dictation, + super::routes::dictation::get_dictation_config, + super::routes::features::get_features, + ), + components(schemas( + super::routes::config_management::UpsertConfigQuery, + super::routes::config_management::ConfigKeyQuery, + super::routes::config_management::ConfigResponse, + super::routes::config_management::ProvidersResponse, + super::routes::config_management::ProviderDetails, + super::routes::config_management::SlashCommandsResponse, + super::routes::config_management::SlashCommand, + super::routes::config_management::CommandType, + super::routes::config_management::ExtensionResponse, + super::routes::config_management::ExtensionQuery, + super::routes::config_management::ToolPermission, + super::routes::config_management::UpsertPermissionsQuery, + super::routes::config_management::UpdateCustomProviderRequest, + goose::providers::catalog::ProviderCatalogEntry, + goose::providers::catalog::ProviderTemplate, + goose::providers::catalog::ModelTemplate, + goose::providers::catalog::ModelCapabilities, + super::routes::config_management::CreateCustomProviderResponse, + super::routes::config_management::CheckProviderRequest, + super::routes::config_management::SetProviderRequest, + super::routes::config_management::ModelInfoQuery, + super::routes::config_management::ModelInfoResponse, + super::routes::config_management::ModelInfoData, + super::routes::prompts::PromptsListResponse, + super::routes::prompts::PromptContentResponse, + super::routes::prompts::SavePromptRequest, + goose::prompt_template::Template, + super::routes::action_required::ConfirmToolActionRequest, + super::routes::reply::ChatRequest, + super::routes::session_events::SessionReplyRequest, + super::routes::session_events::SessionReplyResponse, + super::routes::session_events::CancelRequest, + super::routes::session::ImportSessionRequest, + super::routes::session::ShareSessionNostrRequest, + super::routes::session::ShareSessionNostrResponse, + super::routes::session::ImportSessionNostrRequest, + super::routes::session::SessionListResponse, + super::routes::session::UpdateSessionNameRequest, + super::routes::session::UpdateSessionUserRecipeValuesRequest, + super::routes::session::UpdateSessionUserRecipeValuesResponse, + super::routes::session::ForkRequest, + super::routes::session::ForkResponse, + super::routes::session::SessionExtensionsResponse, + Message, + MessageContent, + MessageMetadata, + InferenceMetadata, + TokenState, + ContentSchema, + EmbeddedResourceSchema, + ImageContentSchema, + AnnotationsSchema, + TextContentSchema, + RawTextContentSchema, + RawImageContentSchema, + RawAudioContentSchema, + RawEmbeddedResourceSchema, + RawResourceSchema, + ToolResponse, + ToolRequest, + ToolConfirmationRequest, + ActionRequired, + ActionRequiredData, + ThinkingContent, + RedactedThinkingContent, + FrontendToolRequest, + ResourceContentsSchema, + SystemNotificationType, + SystemNotificationContent, + MessageEvent, + JsonObjectSchema, + RoleSchema, + ProviderMetadata, + ProviderType, + LoadedProvider, + ProviderEngine, + DeclarativeProviderConfig, + EnvVarConfig, + ExtensionEntry, + ExtensionConfig, + ConfigKey, + Envs, + RecipeManifest, + ToolSchema, + ToolAnnotationsSchema, + ToolExecutionSchema, + TaskSupportSchema, + ToolInfo, + PermissionLevel, + Permission, + PrincipalType, + ModelInfo, + ModelConfig, + ThinkingEffort, + super::routes::config_management::ProviderModelInfoQuery, + Session, + goose::config::goose_mode::GooseMode, + SessionInsights, + SessionType, + SystemInfo, + Conversation, + IconSchema, + IconThemeSchema, + goose::session::extension_data::ExtensionData, + super::routes::schedule::CreateScheduleRequest, + super::routes::schedule::UpdateScheduleRequest, + super::routes::schedule::KillJobResponse, + super::routes::schedule::InspectJobResponse, + goose::scheduler::ScheduledJob, + super::routes::schedule::RunNowResponse, + super::routes::schedule::ListSchedulesResponse, + super::routes::schedule::SessionsQuery, + super::routes::schedule::SessionDisplayInfo, + super::routes::recipe::CreateRecipeRequest, + super::routes::recipe::AuthorRequest, + super::routes::recipe::CreateRecipeResponse, + super::routes::recipe::EncodeRecipeRequest, + super::routes::recipe::EncodeRecipeResponse, + super::routes::recipe::DecodeRecipeRequest, + super::routes::recipe::DecodeRecipeResponse, + super::routes::recipe::ScanRecipeRequest, + super::routes::recipe::ScanRecipeResponse, + super::routes::recipe::ListRecipeResponse, + super::routes::recipe::ScheduleRecipeRequest, + super::routes::recipe::SetSlashCommandRequest, + super::routes::recipe::DeleteRecipeRequest, + super::routes::recipe::SaveRecipeRequest, + super::routes::recipe::SaveRecipeResponse, + super::routes::errors::ErrorResponse, + super::routes::recipe::ParseRecipeRequest, + super::routes::recipe::ParseRecipeResponse, + super::routes::recipe::RecipeToYamlRequest, + super::routes::recipe::RecipeToYamlResponse, + goose::recipe::Recipe, + goose::recipe::Author, + goose::recipe::Settings, + goose::recipe::RecipeParameter, + goose::recipe::RecipeParameterInputType, + goose::recipe::RecipeParameterRequirement, + goose::recipe::Response, + goose::recipe::SubRecipe, + goose::agents::types::RetryConfig, + goose::agents::types::SuccessCheck, + super::routes::agent::UpdateProviderRequest, + super::routes::agent::UpdateSessionRequest, + super::routes::agent::GetToolsQuery, + super::routes::agent::ReadResourceRequest, + super::routes::agent::ReadResourceResponse, + super::routes::agent::CallToolRequest, + super::routes::agent::CallToolResponse, + ContentBlockSchema, + super::routes::agent::ListAppsRequest, + super::routes::agent::ListAppsResponse, + super::routes::agent::ImportAppRequest, + super::routes::agent::ImportAppResponse, + super::routes::agent::StartAgentRequest, + super::routes::agent::ResumeAgentRequest, + super::routes::agent::StopAgentRequest, + super::routes::agent::RestartAgentRequest, + super::routes::agent::UpdateWorkingDirRequest, + super::routes::agent::UpdateFromSessionRequest, + super::routes::agent::AddExtensionRequest, + super::routes::agent::RemoveExtensionRequest, + super::routes::agent::ResumeAgentResponse, + super::routes::agent::RestartAgentResponse, + goose::agents::ExtensionLoadResult, + super::routes::setup::SetupResponse, + super::tunnel::TunnelInfo, + super::tunnel::TunnelState, + super::routes::telemetry::TelemetryEventRequest, + goose::goose_apps::GooseApp, + goose::goose_apps::WindowProps, + goose::goose_apps::McpAppResource, + goose::goose_apps::CspMetadata, + goose::goose_apps::PermissionsMetadata, + goose::goose_apps::UiMetadata, + goose::goose_apps::ResourceMetadata, + super::routes::dictation::TranscribeRequest, + super::routes::dictation::TranscribeResponse, + goose::dictation::providers::DictationProvider, + super::routes::dictation::DictationProviderStatus, + super::routes::features::FeaturesResponse, + DownloadProgress, + DownloadStatus, + )) +)] +pub struct ApiDoc; + +#[cfg(feature = "local-inference")] +#[derive(OpenApi)] +#[openapi( + paths( + super::routes::dictation::list_models, + super::routes::dictation::download_model, + super::routes::dictation::get_download_progress, + super::routes::dictation::cancel_download, + super::routes::dictation::delete_model, + super::routes::local_inference::list_local_models, + super::routes::local_inference::sync_featured_models, + super::routes::local_inference::search_hf_models, + super::routes::local_inference::list_builtin_chat_templates, + super::routes::local_inference::get_repo_files, + super::routes::local_inference::download_hf_model, + super::routes::local_inference::get_local_model_download_progress, + super::routes::local_inference::cancel_local_model_download, + super::routes::local_inference::delete_local_model, + super::routes::local_inference::get_model_settings, + super::routes::local_inference::update_model_settings, + ), + components(schemas( + super::routes::dictation::WhisperModelResponse, + super::routes::local_inference::LocalModelResponse, + super::routes::local_inference::ModelDownloadStatus, + super::routes::local_inference::DownloadModelRequest, + goose::providers::local_inference::hf_models::HfModelInfo, + goose::providers::local_inference::hf_models::HfGgufFile, + goose::providers::local_inference::hf_models::HfQuantVariant, + super::routes::local_inference::RepoVariantsResponse, + goose::providers::local_inference::local_model_registry::ModelSettings, + goose::providers::local_inference::local_model_registry::ChatTemplate, + goose::providers::local_inference::local_model_registry::SamplingConfig, + goose::providers::local_inference::local_model_registry::ToolCallingMode, + )) +)] +pub struct LocalInferenceApiDoc; + +#[allow(dead_code)] // Used by generate_schema binary +pub fn generate_schema() -> String { + #[allow(unused_mut)] + let mut api_doc = ApiDoc::openapi(); + + #[cfg(feature = "local-inference")] + api_doc.merge(LocalInferenceApiDoc::openapi()); + + serde_json::to_string_pretty(&api_doc).unwrap() +} diff --git a/crates/goose-server/src/routes/action_required.rs b/crates/goose-server/src/routes/action_required.rs new file mode 100644 index 0000000000..1301f41c40 --- /dev/null +++ b/crates/goose-server/src/routes/action_required.rs @@ -0,0 +1,100 @@ +use crate::routes::errors::ErrorResponse; +use crate::state::AppState; +use axum::{extract::State, routing::post, Json, Router}; +use goose::permission::permission_confirmation::PrincipalType; +use goose::permission::{Permission, PermissionConfirmation}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::sync::Arc; +use utoipa::ToSchema; + +#[derive(Debug, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ConfirmToolActionRequest { + id: String, + #[serde(default = "default_principal_type")] + principal_type: PrincipalType, + action: Permission, + session_id: String, +} + +fn default_principal_type() -> PrincipalType { + PrincipalType::Tool +} + +#[utoipa::path( + post, + path = "/action-required/tool-confirmation", + request_body = ConfirmToolActionRequest, + responses( + (status = 200, description = "Tool confirmation action is confirmed", body = Value), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn confirm_tool_action( + State(state): State>, + Json(request): Json, +) -> Result, ErrorResponse> { + let agent = state.get_agent_for_route(request.session_id).await?; + + agent + .handle_confirmation( + request.id.clone(), + PermissionConfirmation { + principal_type: request.principal_type, + permission: request.action, + }, + ) + .await; + + Ok(Json(Value::Object(serde_json::Map::new()))) +} + +pub fn routes(state: Arc) -> Router { + Router::new() + .route( + "/action-required/tool-confirmation", + post(confirm_tool_action), + ) + .with_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + + mod integration_tests { + use super::*; + use axum::{body::Body, http::Request}; + use http::StatusCode; + use tower::ServiceExt; + + #[tokio::test(flavor = "multi_thread")] + async fn test_tool_confirmation_endpoint() { + let state = AppState::new(true).await.unwrap(); + + let app = routes(state); + + let request = Request::builder() + .uri("/action-required/tool-confirmation") + .method("POST") + .header("content-type", "application/json") + .header("x-secret-key", "test-secret") + .body(Body::from( + serde_json::to_string(&ConfirmToolActionRequest { + id: "test-id".to_string(), + principal_type: PrincipalType::Tool, + action: Permission::AllowOnce, + session_id: "test-session".to_string(), + }) + .unwrap(), + )) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + } +} diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs new file mode 100644 index 0000000000..25b27eecf9 --- /dev/null +++ b/crates/goose-server/src/routes/agent.rs @@ -0,0 +1,1466 @@ +use crate::routes::config_management::resolve_provider_model_info; +use crate::routes::errors::ErrorResponse; +use crate::routes::recipe_utils::{ + apply_recipe_to_agent, build_recipe_with_parameter_values, load_recipe_by_id, validate_recipe, +}; +use crate::state::AppState; +use axum::response::IntoResponse; +use axum::{ + extract::{Query, State}, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; +use goose::agents::{Container, ExtensionLoadResult}; +use goose::goose_apps::{fetch_mcp_apps, GooseApp, McpAppCache}; + +use base64::Engine; +use goose::agents::reply_parts::is_tool_visible_to_app; +use goose::agents::ExtensionConfig; +use goose::config::resolve_extensions_for_new_session; +use goose::config::{Config, GooseMode}; +use goose::model::ModelConfig; +use goose::providers::create; +use goose::recipe::Recipe; +use goose::recipe_deeplink; +use goose::session::session_manager::SessionType; +use goose::session::{EnabledExtensionsState, ExtensionState, Session}; +use goose::{ + agents::{extension::ToolInfo, extension_manager::get_parameter_names}, + config::permission::PermissionLevel, +}; +use rmcp::model::CallToolRequestParams; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; +use tracing::{error, warn}; + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct UpdateFromSessionRequest { + session_id: String, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct UpdateProviderRequest { + provider: String, + model: Option, + session_id: String, + context_limit: Option, + request_params: Option>, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct UpdateSessionRequest { + session_id: String, + goose_mode: Option, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct GetToolsQuery { + extension_name: Option, + session_id: String, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct StartAgentRequest { + working_dir: String, + #[serde(default)] + recipe: Option, + #[serde(default)] + recipe_id: Option, + #[serde(default)] + recipe_deeplink: Option, + #[serde(default)] + extension_overrides: Option>, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct StopAgentRequest { + session_id: String, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct RestartAgentRequest { + session_id: String, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct UpdateWorkingDirRequest { + session_id: String, + working_dir: String, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct ResumeAgentRequest { + session_id: String, + load_model_and_extensions: bool, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct AddExtensionRequest { + session_id: String, + config: ExtensionConfig, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct RemoveExtensionRequest { + name: String, + session_id: String, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct SetContainerRequest { + session_id: String, + container_id: Option, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct ReadResourceRequest { + session_id: String, + extension_name: String, + uri: String, +} + +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ReadResourceResponse { + uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + mime_type: Option, + text: String, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + meta: Option>, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct CallToolRequest { + session_id: String, + name: String, + arguments: Value, +} + +/// Ref-only alias so utoipa emits `$ref: "#/components/schemas/ContentBlock"`. +/// The actual schema is registered via `derive_utoipa!(RawContent as ContentBlockSchema => "ContentBlock")`. +#[allow(dead_code)] +pub enum ContentBlock {} + +impl<'s> utoipa::ToSchema<'s> for ContentBlock { + fn schema() -> ( + &'s str, + utoipa::openapi::RefOr, + ) { + // Delegate to the auto-generated schema + crate::openapi::ContentBlockSchema::schema() + } +} + +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CallToolResponse { + #[schema(value_type = Vec)] + content: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + structured_content: Option, + is_error: bool, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "_meta")] + _meta: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct ResumeAgentResponse { + pub session: Session, + #[serde(skip_serializing_if = "Option::is_none")] + pub extension_results: Option>, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct RestartAgentResponse { + pub extension_results: Vec, +} + +#[utoipa::path( + post, + path = "/agent/start", + request_body = StartAgentRequest, + responses( + (status = 200, description = "Agent started successfully", body = Session), + (status = 400, description = "Bad request", body = ErrorResponse), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 500, description = "Internal server error", body = ErrorResponse) + ) +)] +#[allow(clippy::too_many_lines)] +async fn start_agent( + State(state): State>, + Json(payload): Json, +) -> Result, ErrorResponse> { + #[cfg(feature = "telemetry")] + goose::posthog::set_session_context("desktop", false); + + let StartAgentRequest { + working_dir, + recipe, + recipe_id, + recipe_deeplink, + extension_overrides, + } = payload; + + let original_recipe = if let Some(deeplink) = recipe_deeplink { + match recipe_deeplink::decode(&deeplink) { + Ok(recipe) => Some(recipe), + Err(err) => { + error!("Failed to decode recipe deeplink: {}", err); + #[cfg(feature = "telemetry")] + goose::posthog::emit_error("recipe_deeplink_decode_failed", &err.to_string()); + return Err(ErrorResponse { + message: err.to_string(), + status: StatusCode::BAD_REQUEST, + }); + } + } + } else if let Some(id) = recipe_id { + match load_recipe_by_id(state.as_ref(), &id).await { + Ok(recipe) => Some(recipe), + Err(err) => return Err(err), + } + } else { + recipe + }; + + if let Some(ref recipe) = original_recipe { + if let Err(err) = validate_recipe(recipe) { + return Err(ErrorResponse { + message: err.message, + status: err.status, + }); + } + } + + let name = "New Chat".to_string(); + + let manager = state.session_manager(); + let config = Config::global(); + let current_mode = config.get_goose_mode().unwrap_or_default(); + + let mut session = manager + .create_session( + PathBuf::from(&working_dir), + name, + SessionType::User, + current_mode, + ) + .await + .map_err(|err| { + error!("Failed to create session: {}", err); + #[cfg(feature = "telemetry")] + goose::posthog::emit_error("session_create_failed", &err.to_string()); + ErrorResponse { + message: format!("Failed to create session: {}", err), + status: StatusCode::BAD_REQUEST, + } + })?; + + let recipe_extensions = original_recipe + .as_ref() + .and_then(|r| r.extensions.as_deref()); + let extensions_to_use = + resolve_extensions_for_new_session(recipe_extensions, extension_overrides); + + let mut extension_data = session.extension_data.clone(); + let extensions_state = EnabledExtensionsState::new(extensions_to_use); + if let Err(e) = extensions_state.to_extension_data(&mut extension_data) { + tracing::warn!("Failed to initialize session with extensions: {}", e); + } else { + manager + .update(&session.id) + .extension_data(extension_data.clone()) + .apply() + .await + .map_err(|err| { + error!("Failed to save initial extension state: {}", err); + ErrorResponse { + message: format!("Failed to save initial extension state: {}", err), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; + } + + if let Some(recipe) = original_recipe { + let mut update = manager.update(&session.id).recipe(Some(recipe.clone())); + + if let Some(ref settings) = recipe.settings { + if let Some(ref provider) = settings.goose_provider { + update = update.provider_name(provider); + + if let Some(ref model) = settings.goose_model { + if let Ok(model_config) = ModelConfig::new(model) { + update = update.model_config(model_config); + } + } + } + } + + update.apply().await.map_err(|err| { + error!("Failed to update session with recipe: {}", err); + ErrorResponse { + message: format!("Failed to update session with recipe: {}", err), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; + } + + // Refetch session to get all updates + session = manager + .get_session(&session.id, false) + .await + .map_err(|err| { + error!("Failed to get updated session: {}", err); + ErrorResponse { + message: format!("Failed to get updated session: {}", err), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; + + // Eagerly start loading extensions in the background + let session_for_spawn = session.clone(); + let state_for_spawn = state.clone(); + let session_id_for_task = session.id.clone(); + let task = tokio::spawn(async move { + match state_for_spawn + .get_agent(session_for_spawn.id.clone()) + .await + { + Ok(agent) => { + let results = agent.load_extensions_from_session(&session_for_spawn).await; + tracing::debug!( + "Background extension loading completed for session {}", + session_for_spawn.id + ); + results + } + Err(e) => { + tracing::warn!( + "Failed to create agent for background extension loading: {}", + e + ); + vec![] + } + } + }); + + state + .set_extension_loading_task(session_id_for_task, task) + .await; + + Ok(Json(session)) +} + +#[utoipa::path( + post, + path = "/agent/resume", + request_body = ResumeAgentRequest, + responses( + (status = 200, description = "Agent started successfully", body = ResumeAgentResponse), + (status = 400, description = "Bad request - invalid working directory"), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 500, description = "Internal server error") + ) +)] +async fn resume_agent( + State(state): State>, + Json(payload): Json, +) -> Result, ErrorResponse> { + #[cfg(feature = "telemetry")] + goose::posthog::set_session_context("desktop", true); + + let session = state + .session_manager() + .get_session(&payload.session_id, true) + .await + .map_err(|err| { + error!("Failed to resume session {}: {}", payload.session_id, err); + #[cfg(feature = "telemetry")] + goose::posthog::emit_error("session_resume_failed", &err.to_string()); + ErrorResponse { + message: format!("Failed to resume session: {}", err), + status: StatusCode::NOT_FOUND, + } + })?; + + let (extension_results, session) = if payload.load_model_and_extensions { + let agent = state + .get_agent_for_route(payload.session_id.clone()) + .await + .map_err(|code| ErrorResponse { + message: "Failed to get agent for route".into(), + status: code, + })?; + + let provider_changed = agent + .restore_provider_from_session(&session) + .await + .map_err(|e| ErrorResponse { + message: e.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + let session = if provider_changed { + state + .session_manager() + .get_session(&payload.session_id, true) + .await + .map_err(|err| ErrorResponse { + message: format!("Failed to re-fetch session: {}", err), + status: StatusCode::INTERNAL_SERVER_ERROR, + })? + } else { + session + }; + + let extension_results = + if let Some(results) = state.take_extension_loading_task(&payload.session_id).await { + tracing::debug!( + "Using background extension loading results for session {}", + payload.session_id + ); + state + .remove_extension_loading_task(&payload.session_id) + .await; + results + } else { + tracing::debug!( + "No background task found, loading extensions for session {}", + payload.session_id + ); + agent.load_extensions_from_session(&session).await + }; + + (Some(extension_results), session) + } else { + (None, session) + }; + + Ok(Json(ResumeAgentResponse { + session, + extension_results, + })) +} + +#[utoipa::path( + post, + path = "/agent/update_from_session", + request_body = UpdateFromSessionRequest, + responses( + (status = 200, description = "Update agent from session data successfully"), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 424, description = "Agent not initialized"), + ), +)] +async fn update_from_session( + State(state): State>, + Json(payload): Json, +) -> Result { + let agent = state + .get_agent_for_route(payload.session_id.clone()) + .await + .map_err(|status| ErrorResponse { + message: format!("Failed to get agent: {}", status), + status, + })?; + let session = state + .session_manager() + .get_session(&payload.session_id, false) + .await + .map_err(|err| ErrorResponse { + message: format!("Failed to get session: {}", err), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + if let Some(recipe) = session.recipe { + if session.session_type == SessionType::Scheduled { + if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, true).await { + agent + .extend_system_prompt("recipe".to_string(), prompt) + .await; + } + } else { + match build_recipe_with_parameter_values( + &recipe, + session.user_recipe_values.unwrap_or_default(), + ) + .await + { + Ok(Some(recipe)) => { + if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, true).await { + agent + .extend_system_prompt("recipe".to_string(), prompt) + .await; + } + } + Ok(None) => { + // Recipe has missing parameters + } + Err(e) => { + return Err(ErrorResponse { + message: e.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR, + }); + } + } + } + } + + Ok(StatusCode::OK) +} + +#[utoipa::path( + get, + path = "/agent/tools", + params( + ("extension_name" = Option, Query, description = "Optional extension name to filter tools"), + ("session_id" = String, Query, description = "Required session ID to scope tools to a specific session") + ), + responses( + (status = 200, description = "Tools retrieved successfully", body = Vec), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 424, description = "Agent not initialized"), + (status = 500, description = "Internal server error") + ) +)] +async fn get_tools( + State(state): State>, + Query(query): Query, +) -> Result>, StatusCode> { + let session_id = query.session_id; + let agent = state.get_agent_for_route(session_id.clone()).await?; + let goose_mode = agent.goose_mode().await; + let permission_manager = agent.config.permission_manager.clone(); + + let mut tools: Vec = agent + .list_tools(&session_id, query.extension_name) + .await + .into_iter() + .map(|tool| { + let permission = permission_manager + .get_user_permission(&tool.name) + .or_else(|| { + if goose_mode == GooseMode::SmartApprove { + permission_manager.get_smart_approve_permission(&tool.name) + } else if goose_mode == GooseMode::Approve { + Some(PermissionLevel::AskBefore) + } else { + None + } + }); + + ToolInfo::new( + &tool.name, + tool.description + .as_ref() + .map(|d| d.as_ref()) + .unwrap_or_default(), + get_parameter_names(&tool), + permission, + ) + .with_input_schema(serde_json::Value::Object( + tool.input_schema.as_ref().clone(), + )) + }) + .collect::>(); + tools.sort_by(|a, b| a.name.cmp(&b.name)); + + Ok(Json(tools)) +} + +#[utoipa::path( + post, + path = "/agent/update_provider", + request_body = UpdateProviderRequest, + responses( + (status = 200, description = "Provider updated successfully"), + (status = 400, description = "Bad request - missing or invalid parameters"), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 424, description = "Agent not initialized"), + (status = 500, description = "Internal server error") + ) +)] +async fn update_agent_provider( + State(state): State>, + Json(payload): Json, +) -> Result<(), impl IntoResponse> { + let agent = state + .get_agent_for_route(payload.session_id.clone()) + .await + .map_err(|e| (e, "No agent for session id".to_owned()))?; + + let config = Config::global(); + let model = match payload.model.or_else(|| config.get_goose_model().ok()) { + Some(m) => m, + None => { + return Err((StatusCode::BAD_REQUEST, "No model specified".to_owned())); + } + }; + + let mut model_config = ModelConfig::new(&model) + .map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("Invalid model config: {}", e), + ) + })? + .with_canonical_limits(&payload.provider) + .with_context_limit(payload.context_limit); + + if let Some(request_params) = payload.request_params { + model_config = model_config.with_merged_request_params(request_params); + } + let model_info = resolve_provider_model_info(&payload.provider, &model) + .await + .map_err(|e| (e.status, e.message))?; + model_config.reasoning = Some(model_info.reasoning); + + let extensions = + EnabledExtensionsState::for_session(state.session_manager(), &payload.session_id, config) + .await; + + let new_provider = create(&payload.provider, model_config, extensions) + .await + .map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("Failed to create {} provider: {}", &payload.provider, e), + ) + })?; + + agent + .update_provider(new_provider, &payload.session_id) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to update provider: {}", e), + ) + })?; + + // Propagate session mode to the new provider + let mode = agent.goose_mode().await; + agent + .update_goose_mode(mode, &payload.session_id) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to propagate mode to provider: {}", e), + ) + })?; + + Ok(()) +} + +#[utoipa::path( + post, + path = "/agent/update_session", + request_body = UpdateSessionRequest, + responses( + (status = 200, description = "Session updated"), + (status = 400, description = "Invalid request"), + (status = 500, description = "Internal error") + ) +)] +async fn update_session( + State(state): State>, + Json(payload): Json, +) -> Result<(), (StatusCode, String)> { + let agent = state + .get_agent_for_route(payload.session_id.clone()) + .await + .map_err(|e| (e, "No agent for session id".to_owned()))?; + + if let Some(mode_str) = payload.goose_mode { + let mode: GooseMode = mode_str.parse().map_err(|_| { + ( + StatusCode::BAD_REQUEST, + format!("Invalid mode: {}", mode_str), + ) + })?; + + agent + .update_goose_mode(mode, &payload.session_id) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to update mode: {}", e), + ) + })?; + } + + Ok(()) +} + +#[utoipa::path( + post, + path = "/agent/add_extension", + request_body = AddExtensionRequest, + responses( + (status = 200, description = "Extension added", body = String), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 424, description = "Agent not initialized"), + (status = 500, description = "Internal server error") + ) +)] +async fn agent_add_extension( + State(state): State>, + Json(request): Json, +) -> Result { + #[cfg(feature = "telemetry")] + let extension_name = request.config.name(); + + let agent = state.get_agent(request.session_id.clone()).await?; + + agent + .add_extension(request.config, &request.session_id) + .await + .map_err(|e| { + #[cfg(feature = "telemetry")] + goose::posthog::emit_error( + "extension_add_failed", + &format!("{}: {}", extension_name, e), + ); + ErrorResponse::internal(format!("Failed to add extension: {}", e)) + })?; + + Ok(StatusCode::OK) +} + +#[utoipa::path( + post, + path = "/agent/remove_extension", + request_body = RemoveExtensionRequest, + responses( + (status = 200, description = "Extension removed", body = String), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 424, description = "Agent not initialized"), + (status = 500, description = "Internal server error") + ) +)] +async fn agent_remove_extension( + State(state): State>, + Json(request): Json, +) -> Result { + let agent = state.get_agent(request.session_id.clone()).await?; + + agent + .remove_extension(&request.name, &request.session_id) + .await + .map_err(|e| { + error!("Failed to remove extension: {}", e); + ErrorResponse { + message: format!("Failed to remove extension: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; + + Ok(StatusCode::OK) +} + +#[utoipa::path( + post, + path = "/agent/set_container", + request_body = SetContainerRequest, + responses( + (status = 200, description = "Container set successfully"), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 424, description = "Agent not initialized"), + (status = 500, description = "Internal server error") + ) +)] +async fn set_container( + State(state): State>, + Json(request): Json, +) -> Result { + let agent = state.get_agent(request.session_id.clone()).await?; + + let container = request.container_id.map(Container::new); + agent.set_container(container).await; + + Ok(StatusCode::OK) +} + +#[utoipa::path( + post, + path = "/agent/stop", + request_body = StopAgentRequest, + responses( + (status = 200, description = "Agent stopped successfully", body = String), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 404, description = "Session not found"), + (status = 500, description = "Internal server error") + ) +)] +async fn stop_agent( + State(state): State>, + Json(payload): Json, +) -> Result { + let session_id = payload.session_id; + state + .agent_manager + .remove_session(&session_id) + .await + .map_err(|e| ErrorResponse { + message: format!("Failed to stop agent for session {}: {}", session_id, e), + status: StatusCode::NOT_FOUND, + })?; + + Ok(StatusCode::OK) +} + +async fn restart_agent_internal( + state: &Arc, + session_id: &str, + session: &Session, +) -> Result, ErrorResponse> { + // Remove existing agent (ignore error if not found) + let _ = state.agent_manager.remove_session(session_id).await; + + let agent = state + .get_agent_for_route(session_id.to_string()) + .await + .map_err(|code| ErrorResponse { + message: "Failed to create new agent during restart".into(), + status: code, + })?; + + let provider_future = agent.restore_provider_from_session(session); + let extensions_future = agent.load_extensions_from_session(session); + + let (provider_result, extension_results) = tokio::join!(provider_future, extensions_future); + provider_result.map_err(|e| ErrorResponse { + message: e.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + if let Some(ref recipe) = session.recipe { + if session.session_type == SessionType::Scheduled { + if let Some(prompt) = apply_recipe_to_agent(&agent, recipe, true).await { + agent + .extend_system_prompt("recipe".to_string(), prompt) + .await; + } + } else { + match build_recipe_with_parameter_values( + recipe, + session.user_recipe_values.clone().unwrap_or_default(), + ) + .await + { + Ok(Some(recipe)) => { + if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, true).await { + agent + .extend_system_prompt("recipe".to_string(), prompt) + .await; + } + } + Ok(None) => { + // Recipe has missing parameters + } + Err(e) => { + return Err(ErrorResponse { + message: e.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR, + }); + } + } + } + } + + Ok(extension_results) +} + +#[utoipa::path( + post, + path = "/agent/restart", + request_body = RestartAgentRequest, + responses( + (status = 200, description = "Agent restarted successfully", body = RestartAgentResponse), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 404, description = "Session not found"), + (status = 500, description = "Internal server error") + ) +)] +async fn restart_agent( + State(state): State>, + Json(payload): Json, +) -> Result, ErrorResponse> { + let session_id = payload.session_id.clone(); + + let session = state + .session_manager() + .get_session(&session_id, false) + .await + .map_err(|err| { + error!("Failed to get session during restart: {}", err); + ErrorResponse { + message: format!("Failed to get session: {}", err), + status: StatusCode::NOT_FOUND, + } + })?; + + let extension_results = restart_agent_internal(&state, &session_id, &session).await?; + + Ok(Json(RestartAgentResponse { extension_results })) +} + +#[utoipa::path( + post, + path = "/agent/update_working_dir", + request_body = UpdateWorkingDirRequest, + responses( + (status = 200, description = "Working directory updated and agent restarted successfully"), + (status = 400, description = "Bad request - invalid directory path"), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 404, description = "Session not found"), + (status = 500, description = "Internal server error") + ) +)] +async fn update_working_dir( + State(state): State>, + Json(payload): Json, +) -> Result { + let session_id = payload.session_id.clone(); + let working_dir = payload.working_dir.trim(); + + if working_dir.is_empty() { + return Err(ErrorResponse { + message: "Working directory cannot be empty".into(), + status: StatusCode::BAD_REQUEST, + }); + } + + let path = PathBuf::from(working_dir); + if !path.exists() || !path.is_dir() { + return Err(ErrorResponse { + message: "Invalid directory path".into(), + status: StatusCode::BAD_REQUEST, + }); + } + + // Update the session's working directory + state + .session_manager() + .update(&session_id) + .working_dir(path) + .apply() + .await + .map_err(|e| { + error!("Failed to update session working directory: {}", e); + ErrorResponse { + message: format!("Failed to update working directory: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; + + // Get the updated session and restart the agent + let session = state + .session_manager() + .get_session(&session_id, false) + .await + .map_err(|err| { + error!("Failed to get session after working dir update: {}", err); + ErrorResponse { + message: format!("Failed to get session: {}", err), + status: StatusCode::NOT_FOUND, + } + })?; + + restart_agent_internal(&state, &session_id, &session).await?; + + Ok(StatusCode::OK) +} + +async fn ensure_extensions_loaded(state: &AppState, session_id: &str) { + if let Some(_results) = state.take_extension_loading_task(session_id).await { + tracing::debug!( + "Awaited background extension loading for session {} before serving request", + session_id + ); + state.remove_extension_loading_task(session_id).await; + } +} + +#[utoipa::path( + post, + path = "/agent/read_resource", + request_body = ReadResourceRequest, + responses( + (status = 200, description = "Resource read successfully", body = ReadResourceResponse), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 424, description = "Agent not initialized"), + (status = 404, description = "Resource not found"), + (status = 500, description = "Internal server error") + ) +)] +async fn read_resource( + State(state): State>, + Json(payload): Json, +) -> Result, StatusCode> { + use rmcp::model::ResourceContents; + + ensure_extensions_loaded(&state, &payload.session_id).await; + + let agent = state + .get_agent_for_route(payload.session_id.clone()) + .await?; + + let read_result = agent + .extension_manager + .read_resource( + &payload.session_id, + &payload.uri, + &payload.extension_name, + CancellationToken::default(), + ) + .await + .map_err(|e| { + tracing::error!( + "read_resource failed for session={}, uri={}, extension={}: {:?}", + payload.session_id, + payload.uri, + payload.extension_name, + e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let content = read_result + .contents + .into_iter() + .next() + .ok_or(StatusCode::NOT_FOUND)?; + + let (uri, mime_type, text, meta) = match content { + ResourceContents::TextResourceContents { + uri, + mime_type, + text, + meta, + } => (uri, mime_type, text, meta), + ResourceContents::BlobResourceContents { + uri, + mime_type, + blob, + meta, + } => { + let decoded = match base64::engine::general_purpose::STANDARD.decode(&blob) { + Ok(bytes) => { + String::from_utf8(bytes).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + } + Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), + }; + (uri, mime_type, decoded, meta) + } + }; + + let meta_map = meta.map(|m| m.0); + + Ok(Json(ReadResourceResponse { + uri, + mime_type, + text, + meta: meta_map, + })) +} + +#[utoipa::path( + post, + path = "/agent/call_tool", + request_body = CallToolRequest, + responses( + (status = 200, description = "Resource read successfully", body = CallToolResponse), + (status = 403, description = "Forbidden - tool is not app-visible", body = ErrorResponse), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 424, description = "Frontend tool execution requires the frontend host", body = ErrorResponse), + (status = 404, description = "Resource not found", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse) + ) +)] +async fn call_tool( + State(state): State>, + Json(payload): Json, +) -> Result, ErrorResponse> { + ensure_extensions_loaded(&state, &payload.session_id).await; + + let agent = state + .get_agent_for_route(payload.session_id.clone()) + .await?; + + // Check app-side visibility: reject calls to tools that exclude "app" + let tools = agent.list_tools(&payload.session_id, None).await; + if let Some(tool) = tools.iter().find(|t| *t.name == payload.name) { + if !is_tool_visible_to_app(tool) { + warn!( + tool = %payload.name, + "Rejected app call to model-only tool" + ); + return Err(ErrorResponse { + message: format!("Tool '{}' cannot be called by the app", payload.name), + status: StatusCode::FORBIDDEN, + }); + } + } + + if agent.is_frontend_tool(&payload.name).await { + return Err(ErrorResponse { + message: format!( + "Tool '{}' is provided by the frontend and must be executed by the frontend host", + payload.name + ), + status: StatusCode::FAILED_DEPENDENCY, + }); + } + + let arguments = match payload.arguments { + Value::Object(map) => Some(map), + _ => None, + }; + + let tool_call = { + let mut params = CallToolRequestParams::new(payload.name); + if let Some(args) = arguments { + params = params.with_arguments(args); + } + params + }; + + let ctx = goose::agents::ToolCallContext::new(payload.session_id.clone(), None, None); + let tool_result = agent + .extension_manager + .dispatch_tool_call(&ctx, tool_call, CancellationToken::default()) + .await + .map_err(ErrorResponse::from)?; + + let result = tool_result.result.await.map_err(|err| ErrorResponse { + message: err.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + let content = result + .content + .into_iter() + .map(serde_json::to_value) + .collect::, _>>() + .map_err(ErrorResponse::from)?; + + Ok(Json(CallToolResponse { + content, + structured_content: result.structured_content, + is_error: result.is_error.unwrap_or(false), + _meta: result.meta.and_then(|m| serde_json::to_value(m).ok()), + })) +} + +#[derive(Deserialize, utoipa::IntoParams, utoipa::ToSchema)] +pub struct ListAppsRequest { + session_id: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ListAppsResponse { + pub apps: Vec, +} + +#[utoipa::path( + get, + path = "/agent/list_apps", + params( + ListAppsRequest + ), + responses( + (status = 200, description = "List of apps retrieved successfully", body = ListAppsResponse), + (status = 401, description = "Unauthorized - Invalid or missing API key", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ), + security( + ("api_key" = []) + ), + tag = "Agent" +)] +async fn list_apps( + State(state): State>, + Query(params): Query, +) -> Result, ErrorResponse> { + let cache = McpAppCache::new().ok(); + + let Some(session_id) = params.session_id else { + let apps = cache + .as_ref() + .and_then(|c| c.list_apps().ok()) + .unwrap_or_default(); + return Ok(Json(ListAppsResponse { apps })); + }; + + let agent = state + .get_agent_for_route(session_id.clone()) + .await + .map_err(|status| ErrorResponse { + message: "Failed to get agent".to_string(), + status, + })?; + + let apps = fetch_mcp_apps(&agent.extension_manager, &session_id) + .await + .map_err(|e| ErrorResponse { + message: format!("Failed to list apps: {}", e.message), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + if let Some(cache) = cache.as_ref() { + let active_extensions: HashSet = apps + .iter() + .flat_map(|app| app.mcp_servers.iter().cloned()) + .collect(); + + for extension_name in active_extensions { + if let Err(e) = cache.delete_extension_apps(&extension_name) { + warn!( + "Failed to clean cache for extension {}: {}", + extension_name, e + ); + } + } + + for app in &apps { + if let Err(e) = cache.store_app(app) { + warn!("Failed to cache app {}: {}", app.resource.name, e); + } + } + } + + Ok(Json(ListAppsResponse { apps })) +} + +#[utoipa::path( + get, + path = "/agent/export_app/{name}", + params( + ("name" = String, Path, description = "Name of the app to export") + ), + responses( + (status = 200, description = "App HTML exported successfully", body = String), + (status = 404, description = "App not found", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ), + security( + ("api_key" = []) + ), + tag = "Agent" +)] +async fn export_app( + axum::extract::Path(name): axum::extract::Path, +) -> Result { + let cache = McpAppCache::new().map_err(|e| ErrorResponse { + message: format!("Failed to access app cache: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + let apps = cache.list_apps().map_err(|e| ErrorResponse { + message: format!("Failed to list apps: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + let app = apps + .into_iter() + .find(|a| a.resource.name == name) + .ok_or_else(|| ErrorResponse { + message: format!("App '{}' not found", name), + status: StatusCode::NOT_FOUND, + })?; + + let html = app.to_html().map_err(|e| ErrorResponse { + message: format!("Failed to generate HTML: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + Ok(html) +} + +#[derive(Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ImportAppRequest { + pub html: String, +} + +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ImportAppResponse { + pub name: String, + pub message: String, +} + +#[utoipa::path( + post, + path = "/agent/import_app", + request_body = ImportAppRequest, + responses( + (status = 201, description = "App imported successfully", body = ImportAppResponse), + (status = 400, description = "Bad request - Invalid HTML", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ), + security( + ("api_key" = []) + ), + tag = "Agent" +)] +async fn import_app( + Json(body): Json, +) -> Result<(StatusCode, Json), ErrorResponse> { + let cache = McpAppCache::new().map_err(|e| ErrorResponse { + message: format!("Failed to access app cache: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + let mut app = GooseApp::from_html(&body.html).map_err(|e| ErrorResponse { + message: format!("Invalid Goose App HTML: {}", e), + status: StatusCode::BAD_REQUEST, + })?; + + let original_name = app.resource.name.clone(); + let mut counter = 1; + + let existing_apps = cache.list_apps().unwrap_or_default(); + let existing_names: HashSet = existing_apps + .iter() + .map(|a| a.resource.name.clone()) + .collect(); + + while existing_names.contains(&app.resource.name) { + app.resource.name = format!("{}_{}", original_name, counter); + app.resource.uri = format!("ui://apps/{}", app.resource.name); + counter += 1; + } + + app.mcp_servers = vec!["apps".to_string()]; + + cache.store_app(&app).map_err(|e| ErrorResponse { + message: format!("Failed to store app: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + Ok(( + StatusCode::CREATED, + Json(ImportAppResponse { + name: app.resource.name.clone(), + message: format!("App '{}' imported successfully", app.resource.name), + }), + )) +} + +pub fn routes(state: Arc) -> Router { + Router::new() + .route("/agent/start", post(start_agent)) + .route("/agent/resume", post(resume_agent)) + .route("/agent/restart", post(restart_agent)) + .route("/agent/update_working_dir", post(update_working_dir)) + .route("/agent/tools", get(get_tools)) + .route("/agent/read_resource", post(read_resource)) + .route("/agent/call_tool", post(call_tool)) + .route("/agent/list_apps", get(list_apps)) + .route("/agent/export_app/{name}", get(export_app)) + .route("/agent/import_app", post(import_app)) + .route("/agent/update_provider", post(update_agent_provider)) + .route("/agent/update_session", post(update_session)) + .route("/agent/update_from_session", post(update_from_session)) + .route("/agent/add_extension", post(agent_add_extension)) + .route("/agent/remove_extension", post(agent_remove_extension)) + .route("/agent/set_container", post(set_container)) + .route("/agent/stop", post(stop_agent)) + .with_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + use goose::config::GooseMode; + use goose::session::session_manager::SessionType; + use rmcp::model::Tool; + use rmcp::object; + + fn frontend_extension() -> ExtensionConfig { + ExtensionConfig::Frontend { + name: "frontend-e2e".to_string(), + description: "Frontend test extension".to_string(), + tools: vec![Tool::new( + "frontend__echo".to_string(), + "Echo a string from the frontend".to_string(), + object!({ + "type": "object", + "properties": { + "message": { "type": "string" } + }, + "required": ["message"] + }), + )], + instructions: Some("Use the frontend echo tool.".to_string()), + bundled: None, + available_tools: vec![], + } + } + + #[tokio::test] + async fn frontend_extensions_are_listed_and_rejected_cleanly_by_call_tool() { + let state = AppState::new(true).await.unwrap(); + let session = state + .session_manager() + .create_session( + std::env::current_dir().unwrap(), + "frontend-route-test".to_string(), + SessionType::Hidden, + GooseMode::default(), + ) + .await + .unwrap(); + + agent_add_extension( + State(state.clone()), + Json(AddExtensionRequest { + session_id: session.id.clone(), + config: frontend_extension(), + }), + ) + .await + .unwrap(); + + let Json(tools) = get_tools( + State(state.clone()), + Query(GetToolsQuery { + extension_name: None, + session_id: session.id.clone(), + }), + ) + .await + .unwrap(); + + assert!(tools.iter().any(|tool| tool.name == "frontend__echo")); + + let error = match call_tool( + State(state), + Json(CallToolRequest { + session_id: session.id, + name: "frontend__echo".to_string(), + arguments: Value::Object(serde_json::Map::new()), + }), + ) + .await + { + Ok(_) => panic!("frontend tools should not be callable through /agent/call_tool"), + Err(error) => error, + }; + + assert_eq!(error.status, StatusCode::FAILED_DEPENDENCY); + assert!(error.message.contains("frontend host")); + } +} diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs new file mode 100644 index 0000000000..69ed487d64 --- /dev/null +++ b/crates/goose-server/src/routes/config_management.rs @@ -0,0 +1,1034 @@ +use crate::routes::errors::ErrorResponse; +use crate::routes::utils::check_provider_configured; +use crate::state::AppState; +use axum::routing::put; +use axum::{ + extract::Path, + routing::{delete, get, post}, + Json, Router, +}; +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::{ModelInfo, ProviderMetadata, ProviderType}; +use goose::providers::canonical::maybe_get_canonical_model; +use goose::providers::catalog::{ + get_provider_template, get_providers_by_format, ProviderCatalogEntry, ProviderFormat, + ProviderTemplate, +}; +use goose::providers::create_with_default_model; +use goose::providers::providers as get_providers; +use goose::{ + agents::execute_commands, agents::ExtensionConfig, config::permission::PermissionLevel, + slash_commands::recipe_slash_command, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use serde_yaml; +use std::{collections::HashMap, sync::Arc}; +use utoipa::ToSchema; + +#[derive(Serialize, ToSchema)] +pub struct ExtensionResponse { + pub extensions: Vec, + #[serde(default)] + pub warnings: Vec, +} + +#[derive(Deserialize, ToSchema)] +pub struct ExtensionQuery { + pub name: String, + pub config: ExtensionConfig, + pub enabled: bool, +} + +#[derive(Deserialize, ToSchema)] +pub struct UpsertConfigQuery { + pub key: String, + pub value: Value, + pub is_secret: bool, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct ConfigKeyQuery { + pub key: String, + pub is_secret: bool, +} + +#[derive(Serialize, ToSchema)] +pub struct ConfigResponse { + pub config: HashMap, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ProviderDetails { + pub name: String, + pub metadata: ProviderMetadata, + pub is_configured: bool, + pub provider_type: ProviderType, + #[serde(skip_serializing_if = "Option::is_none")] + pub saved_model: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ProvidersResponse { + pub providers: Vec, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ToolPermission { + pub tool_name: String, + pub permission: PermissionLevel, +} + +#[derive(Deserialize, ToSchema)] +pub struct UpsertPermissionsQuery { + pub tool_permissions: Vec, +} + +#[derive(Deserialize, ToSchema)] +pub struct UpdateCustomProviderRequest { + pub engine: String, + pub display_name: String, + pub api_url: String, + pub api_key: String, + pub models: Vec, + pub supports_streaming: Option, + pub headers: Option>, + #[serde(default = "default_requires_auth")] + pub requires_auth: bool, + #[serde(default)] + pub catalog_provider_id: Option, + #[serde(default)] + pub base_path: Option, + #[serde(default)] + pub preserves_thinking: Option, +} + +fn default_requires_auth() -> bool { + true +} + +fn normalize_custom_provider_api_key(api_key: String) -> Option { + let api_key = api_key.trim().to_string(); + (!api_key.is_empty()).then_some(api_key) +} + +#[derive(Deserialize, ToSchema)] +pub struct CheckProviderRequest { + pub provider: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct SetProviderRequest { + pub provider: String, + pub model: String, +} + +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct MaskedSecret { + pub masked_value: String, +} + +#[derive(Serialize, ToSchema)] +#[serde(untagged)] +pub enum ConfigValueResponse { + Value(Value), + MaskedValue(MaskedSecret), +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub enum CommandType { + Builtin, + Recipe, + Skill, + Agent, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct SlashCommand { + pub command: String, + pub help: String, + pub command_type: CommandType, +} +#[derive(Serialize, ToSchema)] +pub struct SlashCommandsResponse { + pub commands: Vec, +} + +#[utoipa::path( + post, + path = "/config/upsert", + request_body = UpsertConfigQuery, + responses( + (status = 200, description = "Configuration value upserted successfully", body = String), + (status = 500, description = "Internal server error") + ) +)] +pub async fn upsert_config( + Json(query): Json, +) -> Result, ErrorResponse> { + let config = Config::global(); + + // Intercept legacy keys to write structured provider config + if query.key == "GOOSE_PROVIDER" { + if let Some(name) = query.value.as_str() { + // Preserve the target provider's saved model rather than copying + // the current active provider's model into the new entry. + let model = goose::config::get_provider_entry(config, name) + .map(|e| e.model) + .or_else(|| config.get_goose_model().ok()) + .unwrap_or_default(); + goose::config::set_active_provider(config, name, &model)?; + return Ok(Json(Value::String(format!("Upserted key {}", query.key)))); + } + } + if query.key == "GOOSE_MODEL" { + if let Some(model) = query.value.as_str() { + if let Ok(provider) = config.get_goose_provider() { + goose::config::set_active_provider(config, &provider, model)?; + return Ok(Json(Value::String(format!("Upserted key {}", query.key)))); + } + } + } + + config.set(&query.key, &query.value, query.is_secret)?; + Ok(Json(Value::String(format!("Upserted key {}", query.key)))) +} + +#[utoipa::path( + post, + path = "/config/remove", + request_body = ConfigKeyQuery, + responses( + (status = 200, description = "Configuration value removed successfully", body = String), + (status = 404, description = "Configuration key not found"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn remove_config( + Json(query): Json, +) -> Result, ErrorResponse> { + let config = Config::global(); + + if query.is_secret { + config.delete_secret(&query.key)?; + } else if query.key == "GOOSE_PROVIDER" || query.key == "active_provider" { + config.delete("active_provider")?; + config.delete("GOOSE_PROVIDER")?; + } else if query.key == "GOOSE_MODEL" { + if let Ok(provider) = config.get_goose_provider() { + goose::config::set_active_provider(config, &provider, "")?; + } + config.delete("GOOSE_MODEL")?; + } else { + config.delete(&query.key)?; + } + + Ok(Json(format!("Removed key {}", query.key))) +} + +const SECRET_MASK_SHOW_LEN: usize = 8; + +fn mask_secret(secret: Value) -> String { + let as_string = match secret { + Value::String(s) => s, + _ => serde_json::to_string(&secret).unwrap_or_else(|_| secret.to_string()), + }; + + let chars: Vec<_> = as_string.chars().collect(); + let show_len = std::cmp::min(chars.len() / 2, SECRET_MASK_SHOW_LEN); + let visible: String = chars.iter().take(show_len).collect(); + let mask = "*".repeat(chars.len() - show_len); + + format!("{}{}", visible, mask) +} + +fn is_valid_provider_name(provider_name: &str) -> bool { + !provider_name.is_empty() + && provider_name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + +#[utoipa::path( + post, + path = "/config/read", + request_body = ConfigKeyQuery, + responses( + (status = 200, description = "Configuration value retrieved successfully", body = Value), + (status = 500, description = "Unable to get the configuration value"), + ) +)] +pub async fn read_config( + Json(query): Json, +) -> Result, ErrorResponse> { + let config = Config::global(); + + // Intercept legacy keys to return structured provider config + if query.key == "GOOSE_PROVIDER" || query.key == "active_provider" { + if let Ok(val) = config.get_goose_provider() { + return Ok(Json(ConfigValueResponse::Value(Value::String(val)))); + } + return Ok(Json(ConfigValueResponse::Value(Value::Null))); + } + if query.key == "GOOSE_MODEL" { + if let Ok(val) = config.get_goose_model() { + return Ok(Json(ConfigValueResponse::Value(Value::String(val)))); + } + return Ok(Json(ConfigValueResponse::Value(Value::Null))); + } + + let response_value = match config.get(&query.key, query.is_secret) { + Ok(value) => { + if query.is_secret { + ConfigValueResponse::MaskedValue(MaskedSecret { + masked_value: mask_secret(value), + }) + } else { + ConfigValueResponse::Value(value) + } + } + Err(ConfigError::NotFound(_)) => ConfigValueResponse::Value(Value::Null), + Err(e) => return Err(e.into()), + }; + Ok(Json(response_value)) +} + +#[utoipa::path( + get, + path = "/config/extensions", + responses( + (status = 200, description = "All extensions retrieved successfully", body = ExtensionResponse), + (status = 500, description = "Internal server error") + ) +)] +pub async fn get_extensions() -> Result, ErrorResponse> { + let extensions = goose::config::get_all_extensions() + .into_iter() + .filter(|ext| !goose::agents::extension_manager::is_hidden_extension(&ext.config.name())) + .collect(); + let warnings = goose::config::get_warnings(); + Ok(Json(ExtensionResponse { + extensions, + warnings, + })) +} + +#[utoipa::path( + post, + path = "/config/extensions", + request_body = ExtensionQuery, + responses( + (status = 200, description = "Extension added or updated successfully", body = String), + (status = 400, description = "Invalid request"), + (status = 422, description = "Could not serialize config.yaml"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn add_extension( + Json(extension_query): Json, +) -> Result, ErrorResponse> { + let extensions = goose::config::get_all_extensions(); + let key = goose::config::extensions::name_to_key(&extension_query.name); + + let is_update = extensions.iter().any(|e| e.config.key() == key); + + goose::config::set_extension(ExtensionEntry { + enabled: extension_query.enabled, + config: extension_query.config, + }); + + if is_update { + Ok(Json(format!("Updated extension {}", extension_query.name))) + } else { + Ok(Json(format!("Added extension {}", extension_query.name))) + } +} + +#[utoipa::path( + delete, + path = "/config/extensions/{name}", + responses( + (status = 200, description = "Extension removed successfully", body = String), + (status = 404, description = "Extension not found"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn remove_extension(Path(name): Path) -> Result, ErrorResponse> { + let key = goose::config::extensions::name_to_key(&name); + goose::config::remove_extension(&key); + Ok(Json(format!("Removed extension {}", name))) +} + +#[utoipa::path( + get, + path = "/config", + responses( + (status = 200, description = "All configuration values retrieved successfully", body = ConfigResponse) + ) +)] +pub async fn read_all_config() -> Result, ErrorResponse> { + let config = Config::global(); + let values = config + .all_values() + .map_err(|e| ErrorResponse::unprocessable(e.to_string()))?; + Ok(Json(ConfigResponse { config: values })) +} + +#[utoipa::path( + get, + path = "/config/providers", + responses( + (status = 200, description = "All configuration values retrieved successfully", body = [ProviderDetails]) + ) +)] +pub async fn providers() -> Result>, ErrorResponse> { + let config = Config::global(); + let providers = get_providers().await; + let providers_response: Vec = providers + .into_iter() + .map(|(metadata, provider_type)| { + let is_configured = check_provider_configured(&metadata, provider_type); + let saved_model = goose::config::get_provider_entry(config, &metadata.name) + .map(|e| e.model) + .filter(|m| !m.is_empty()); + + ProviderDetails { + name: metadata.name.clone(), + metadata, + is_configured, + provider_type, + saved_model, + } + }) + .collect(); + + Ok(Json(providers_response)) +} + +#[utoipa::path( + get, + path = "/config/providers/{name}/models", + params( + ("name" = String, Path, description = "Provider name (e.g., openai)") + ), + responses( + (status = 200, description = "Models fetched successfully", body = [ModelInfo]), + (status = 400, description = "Unknown provider, provider not configured, or authentication error"), + (status = 429, description = "Rate limit exceeded"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn get_provider_models( + Path(name): Path, +) -> Result>, ErrorResponse> { + let all = get_providers().await.into_iter().collect::>(); + let Some((metadata, provider_type)) = all.into_iter().find(|(m, _)| m.name == name) else { + return Err(ErrorResponse::bad_request(format!( + "Unknown provider: {}", + name + ))); + }; + if !check_provider_configured(&metadata, provider_type) { + return Err(ErrorResponse::bad_request(format!( + "Provider '{}' is not configured", + name + ))); + } + + let model_config = ModelConfig::new(&metadata.default_model)?.with_canonical_limits(&name); + let provider = goose::providers::create(&name, model_config, Vec::new()).await?; + + let models_result = provider.fetch_recommended_model_info().await; + + match models_result { + Ok(models) => Ok(Json(models)), + Err(provider_error) => Err(provider_error.into()), + } +} + +#[derive(Deserialize, ToSchema)] +pub struct ProviderModelInfoQuery { + pub model: String, +} + +pub async fn resolve_provider_model_info( + name: &str, + model: &str, +) -> Result { + let all = get_providers().await.into_iter().collect::>(); + let Some((metadata, provider_type)) = all.into_iter().find(|(m, _)| m.name == name) else { + return Err(ErrorResponse::bad_request(format!( + "Unknown provider: {}", + name + ))); + }; + if !check_provider_configured(&metadata, provider_type) { + return Err(ErrorResponse::bad_request(format!( + "Provider '{}' is not configured", + name + ))); + } + + let model_config = ModelConfig::new(model)?.with_canonical_limits(name); + let provider = goose::providers::create(name, model_config.clone(), Vec::new()).await?; + match provider.fetch_model_info(model).await { + Ok(info) => Ok(info), + Err(error) => { + let mut info = ModelInfo::new(model, model_config.context_limit()); + info.reasoning = model_config.is_reasoning_model(); + tracing::debug!( + provider = name, + model, + error = %error, + "Falling back to local model metadata" + ); + Ok(info) + } + } +} + +#[utoipa::path( + post, + path = "/config/providers/{name}/model-info", + params( + ("name" = String, Path, description = "Provider name (e.g., openai)") + ), + request_body = ProviderModelInfoQuery, + responses( + (status = 200, description = "Model metadata fetched successfully", body = ModelInfo), + (status = 400, description = "Unknown provider, provider not configured, or authentication error"), + (status = 429, description = "Rate limit exceeded"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn get_provider_model_info( + Path(name): Path, + Json(query): Json, +) -> Result, ErrorResponse> { + resolve_provider_model_info(&name, &query.model) + .await + .map(Json) +} + +#[derive(Deserialize, utoipa::IntoParams)] +pub struct SlashCommandsQuery { + /// Optional working directory to discover local skills from + pub working_dir: Option, +} + +#[utoipa::path( + get, + path = "/config/slash_commands", + params(SlashCommandsQuery), + responses( + (status = 200, description = "Slash commands retrieved successfully", body = SlashCommandsResponse) + ) +)] +pub async fn get_slash_commands( + axum::extract::Query(query): axum::extract::Query, +) -> Result, ErrorResponse> { + let mut commands: Vec<_> = recipe_slash_command::list_commands() + .iter() + .map(|command| SlashCommand { + command: command.command.clone(), + help: command.recipe_path.clone(), + command_type: CommandType::Recipe, + }) + .collect(); + + for cmd_def in execute_commands::list_commands() { + commands.push(SlashCommand { + command: cmd_def.name.to_string(), + help: cmd_def.description.to_string(), + command_type: CommandType::Builtin, + }); + } + + let working_dir = query.working_dir.map(std::path::PathBuf::from); + 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, + }); + } + + 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, + }); + } + } + + Ok(Json(SlashCommandsResponse { commands })) +} + +#[derive(Serialize, ToSchema)] +pub struct ModelInfoData { + pub provider: String, + pub model: String, + pub context_limit: usize, + pub max_output_tokens: Option, + pub reasoning: bool, + pub input_token_cost: Option, + pub output_token_cost: Option, + pub cache_read_token_cost: Option, + pub cache_write_token_cost: Option, + pub currency: String, +} + +#[derive(Serialize, ToSchema)] +pub struct ModelInfoResponse { + pub model_info: Option, + pub source: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct ModelInfoQuery { + pub provider: String, + pub model: String, +} + +#[utoipa::path( + post, + path = "/config/canonical-model-info", + request_body = ModelInfoQuery, + responses( + (status = 200, description = "Model information retrieved successfully", body = ModelInfoResponse) + ) +)] +pub async fn get_canonical_model_info( + Json(query): Json, +) -> Json { + let canonical_model = maybe_get_canonical_model(&query.provider, &query.model); + + let model_info = canonical_model.map(|canonical_model| ModelInfoData { + provider: query.provider.clone(), + model: query.model.clone(), + context_limit: canonical_model.limit.context, + max_output_tokens: canonical_model.limit.output, + reasoning: canonical_model + .reasoning + .unwrap_or_else(|| ModelConfig::new_or_fail(&query.model).is_reasoning_model()), + // Costs are per million tokens - client handles division for display + input_token_cost: canonical_model.cost.input, + output_token_cost: canonical_model.cost.output, + cache_read_token_cost: canonical_model.cost.cache_read, + cache_write_token_cost: canonical_model.cost.cache_write, + currency: "$".to_string(), + }); + + Json(ModelInfoResponse { + model_info, + source: "canonical".to_string(), + }) +} + +#[utoipa::path( + post, + path = "/config/permissions", + request_body = UpsertPermissionsQuery, + responses( + (status = 200, description = "Permission update completed", body = String), + (status = 400, description = "Invalid request"), + ) +)] +pub async fn upsert_permissions( + Json(query): Json, +) -> Result, ErrorResponse> { + let permission_manager = goose::config::PermissionManager::instance(); + + for tool_permission in &query.tool_permissions { + permission_manager.update_user_permission( + &tool_permission.tool_name, + tool_permission.permission.clone(), + ); + } + + Ok(Json("Permissions updated successfully".to_string())) +} + +#[utoipa::path( + get, + path = "/config/validate", + responses( + (status = 200, description = "Config validation result", body = String), + (status = 422, description = "Config file is corrupted") + ) +)] +pub async fn validate_config() -> Result, ErrorResponse> { + let config_path = Paths::config_dir().join("config.yaml"); + + if !config_path.exists() { + return Ok(Json("Config file does not exist".to_string())); + } + + let content = std::fs::read_to_string(&config_path)?; + serde_yaml::from_str::(&content) + .map_err(|e| ErrorResponse::unprocessable(format!("Config file is corrupted: {}", e)))?; + + Ok(Json("Config file is valid".to_string())) +} +#[derive(Serialize, ToSchema)] +pub struct CreateCustomProviderResponse { + pub provider_name: String, +} + +#[utoipa::path( + post, + path = "/config/custom-providers", + request_body = UpdateCustomProviderRequest, + responses( + (status = 200, description = "Custom provider created successfully", body = CreateCustomProviderResponse), + (status = 400, description = "Invalid request"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn create_custom_provider( + Json(request): Json, +) -> Result, ErrorResponse> { + let config = goose::config::declarative_providers::create_custom_provider( + goose::config::declarative_providers::CreateCustomProviderParams { + engine: request.engine, + display_name: request.display_name, + api_url: request.api_url, + api_key: normalize_custom_provider_api_key(request.api_key), + models: request.models, + supports_streaming: request.supports_streaming, + headers: request.headers, + requires_auth: request.requires_auth, + catalog_provider_id: request.catalog_provider_id, + base_path: request.base_path, + preserves_thinking: request.preserves_thinking, + }, + )?; + + goose::providers::refresh_custom_providers().await?; + + Ok(Json(CreateCustomProviderResponse { + provider_name: config.id().to_string(), + })) +} + +#[utoipa::path( + get, + path = "/config/custom-providers/{id}", + responses( + (status = 200, description = "Custom provider retrieved successfully", body = LoadedProvider), + (status = 404, description = "Provider not found"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn get_custom_provider( + Path(id): Path, +) -> Result, ErrorResponse> { + let loaded_provider = goose::config::declarative_providers::load_provider(id.as_str()) + .map_err(|e| { + ErrorResponse::not_found(format!("Custom provider '{}' not found: {}", id, e)) + })?; + + Ok(Json(loaded_provider)) +} + +#[utoipa::path( + delete, + path = "/config/custom-providers/{id}", + responses( + (status = 200, description = "Custom provider removed successfully", body = String), + (status = 404, description = "Provider not found"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn remove_custom_provider(Path(id): Path) -> Result, ErrorResponse> { + goose::config::declarative_providers::remove_custom_provider(&id)?; + + goose::providers::refresh_custom_providers().await?; + + Ok(Json(format!("Removed custom provider: {}", id))) +} + +#[utoipa::path( + post, + path = "/config/providers/{name}/cleanup", + params( + ("name" = String, Path, description = "Provider name (e.g., githubcopilot)") + ), + responses( + (status = 200, description = "Provider cache cleaned up successfully", body = String), + (status = 500, description = "Internal server error") + ) +)] +pub async fn cleanup_provider_cache( + Path(name): Path, +) -> Result, ErrorResponse> { + goose::providers::cleanup_provider(&name).await?; + Ok(Json(format!("Cleaned up provider cache: {}", name))) +} + +#[utoipa::path( + put, + path = "/config/custom-providers/{id}", + request_body = UpdateCustomProviderRequest, + responses( + (status = 200, description = "Custom provider updated successfully", body = String), + (status = 404, description = "Provider not found"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn update_custom_provider( + Path(id): Path, + Json(request): Json, +) -> Result, ErrorResponse> { + goose::config::declarative_providers::update_custom_provider( + goose::config::declarative_providers::UpdateCustomProviderParams { + id: id.clone(), + engine: request.engine, + display_name: request.display_name, + api_url: request.api_url, + api_key: normalize_custom_provider_api_key(request.api_key), + models: request.models, + supports_streaming: request.supports_streaming, + headers: request.headers, + requires_auth: request.requires_auth, + catalog_provider_id: request.catalog_provider_id, + base_path: request.base_path, + preserves_thinking: request.preserves_thinking, + }, + )?; + + goose::providers::refresh_custom_providers().await?; + + Ok(Json(format!("Updated custom provider: {}", id))) +} + +#[utoipa::path( + post, + path = "/config/check_provider", + request_body = CheckProviderRequest, +)] +pub async fn check_provider( + Json(CheckProviderRequest { provider }): Json, +) -> Result<(), ErrorResponse> { + // Provider check does not use extensions. + create_with_default_model(&provider, Vec::new()) + .await + .map_err(|err| { + ErrorResponse::bad_request(format!("Provider '{}' check failed: {}", provider, err)) + })?; + Ok(()) +} + +#[utoipa::path( + post, + path = "/config/set_provider", + request_body = SetProviderRequest, +)] +pub async fn set_config_provider( + Json(SetProviderRequest { provider, model }): Json, +) -> Result<(), ErrorResponse> { + // Provider validation does not use extensions. + create_with_default_model(&provider, Vec::new()) + .await + .and_then(|_| { + let config = Config::global(); + goose::config::set_active_provider(config, &provider, &model) + .map_err(|e| anyhow::anyhow!(e)) + }) + .map_err(|err| { + ErrorResponse::bad_request(format!( + "Failed to set provider to '{}' with model '{}': {}", + provider, model, err + )) + })?; + Ok(()) +} + +#[utoipa::path( + get, + path = "/config/provider-catalog", + params( + ("format" = Option, Query, description = "Filter by provider format (openai, anthropic, ollama)") + ), + responses( + (status = 200, description = "Provider catalog retrieved successfully", body = [ProviderCatalogEntry]), + (status = 400, description = "Invalid format parameter") + ) +)] +pub async fn get_provider_catalog( + axum::extract::Query(params): axum::extract::Query>, +) -> Result>, ErrorResponse> { + let format_str = params.get("format").map(|s| s.as_str()).unwrap_or("openai"); + + let format = format_str.parse::().map_err(|_| { + ErrorResponse::bad_request(format!( + "Invalid format '{}'. Must be one of: openai, anthropic, ollama", + format_str + )) + })?; + + let providers = get_providers_by_format(format).await; + Ok(Json(providers)) +} + +#[utoipa::path( + get, + path = "/config/provider-catalog/{id}", + params( + ("id" = String, Path, description = "Provider ID from models.dev") + ), + responses( + (status = 200, description = "Provider template retrieved successfully", body = ProviderTemplate), + (status = 404, description = "Provider not found in catalog") + ) +)] +pub async fn get_provider_catalog_template( + Path(id): Path, +) -> Result, ErrorResponse> { + let template = get_provider_template(&id).ok_or_else(|| { + ErrorResponse::not_found(format!("Provider '{}' not found in catalog", id)) + })?; + + Ok(Json(template)) +} + +#[utoipa::path( + post, + path = "/config/providers/{name}/oauth", + params( + ("name" = String, Path, description = "Provider name") + ), + responses( + (status = 200, description = "OAuth configuration completed"), + (status = 400, description = "OAuth configuration failed") + ) +)] +pub async fn configure_provider_oauth( + Path(provider_name): Path, +) -> Result, ErrorResponse> { + use goose::model::ModelConfig; + use goose::providers::create; + + if !is_valid_provider_name(&provider_name) { + return Err(ErrorResponse::bad_request(format!( + "Invalid provider name: '{}'", + provider_name + ))); + } + + let temp_model = ModelConfig::new("temp") + .map_err(|e| { + ErrorResponse::bad_request(format!("Failed to create temporary model config: {}", e)) + })? + .with_canonical_limits(&provider_name); + + // OAuth configuration does not use extensions. + let provider = create(&provider_name, temp_model, Vec::new()) + .await + .map_err(|e| { + ErrorResponse::bad_request(format!( + "Failed to create provider '{}': {}", + provider_name, e + )) + })?; + + provider.configure_oauth().await.map_err(|e| { + ErrorResponse::bad_request(format!( + "OAuth configuration failed for provider '{}': {}", + provider_name, e + )) + })?; + + // Mark the provider as configured after successful OAuth + let config = goose::config::Config::global(); + if let Some(mut entry) = goose::config::get_provider_entry(config, &provider_name) { + entry.configured = true; + goose::config::set_provider_entry(config, &provider_name, &entry)?; + } else { + let model = if goose::config::get_active_provider(config).as_deref() + == Some(provider_name.as_str()) + { + config.get_goose_model().unwrap_or_default() + } else { + String::new() + }; + goose::config::set_provider_entry( + config, + &provider_name, + &goose::config::ProviderEntry { + enabled: true, + model, + configured: true, + }, + )?; + } + + Ok(Json("OAuth configuration completed".to_string())) +} + +pub fn routes(state: Arc) -> Router { + Router::new() + .route("/config", get(read_all_config)) + .route("/config/upsert", post(upsert_config)) + .route("/config/remove", post(remove_config)) + .route("/config/read", post(read_config)) + .route("/config/extensions", get(get_extensions)) + .route("/config/extensions", post(add_extension)) + .route("/config/extensions/{name}", delete(remove_extension)) + .route("/config/providers", get(providers)) + .route("/config/providers/{name}/models", get(get_provider_models)) + .route( + "/config/providers/{name}/model-info", + post(get_provider_model_info), + ) + .route("/config/provider-catalog", get(get_provider_catalog)) + .route( + "/config/provider-catalog/{id}", + get(get_provider_catalog_template), + ) + .route( + "/config/providers/{name}/cleanup", + post(cleanup_provider_cache), + ) + .route("/config/slash_commands", get(get_slash_commands)) + .route( + "/config/canonical-model-info", + post(get_canonical_model_info), + ) + .route("/config/validate", get(validate_config)) + .route("/config/permissions", post(upsert_permissions)) + .route("/config/custom-providers", post(create_custom_provider)) + .route( + "/config/custom-providers/{id}", + delete(remove_custom_provider), + ) + .route("/config/custom-providers/{id}", put(update_custom_provider)) + .route("/config/custom-providers/{id}", get(get_custom_provider)) + .route("/config/check_provider", post(check_provider)) + .route("/config/set_provider", post(set_config_provider)) + .route( + "/config/providers/{name}/oauth", + post(configure_provider_oauth), + ) + .with_state(state) +} + +#[cfg(test)] +mod tests {} diff --git a/crates/goose-server/src/routes/dictation.rs b/crates/goose-server/src/routes/dictation.rs new file mode 100644 index 0000000000..e7c8111451 --- /dev/null +++ b/crates/goose-server/src/routes/dictation.rs @@ -0,0 +1,374 @@ +use crate::routes::errors::ErrorResponse; +use crate::state::AppState; +use axum::{ + extract::DefaultBodyLimit, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; +#[cfg(feature = "local-inference")] +use axum::{extract::Path, routing::delete}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +#[cfg(feature = "local-inference")] +use goose::dictation::providers::transcribe_local; +use goose::dictation::providers::{ + all_providers, is_configured, transcribe_with_provider, DictationProvider, +}; +#[cfg(feature = "local-inference")] +use goose::dictation::whisper; +#[cfg(feature = "local-inference")] +use goose::download_manager::{get_download_manager, DownloadProgress}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use utoipa::ToSchema; + +const MAX_AUDIO_SIZE_BYTES: usize = 50 * 1024 * 1024; + +#[cfg(feature = "local-inference")] +#[derive(Debug, Serialize, ToSchema)] +pub struct WhisperModelResponse { + #[serde(flatten)] + #[schema(inline)] + model: &'static whisper::WhisperModel, + downloaded: bool, + recommended: bool, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct TranscribeRequest { + /// Base64 encoded audio data + pub audio: String, + /// MIME type of the audio (e.g., "audio/webm", "audio/wav") + pub mime_type: String, + /// Transcription provider to use + pub provider: DictationProvider, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct TranscribeResponse { + /// Transcribed text from the audio + pub text: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct DictationProviderStatus { + /// Whether the provider is fully configured and ready to use + pub configured: bool, + /// Custom host URL if configured (only for providers that support it) + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + /// Description of what this provider does + pub description: String, + /// Whether this provider uses the main provider config (true) or has its own key (false) + pub uses_provider_config: bool, + /// Path to settings if uses_provider_config is true + #[serde(skip_serializing_if = "Option::is_none")] + pub settings_path: Option, + /// Config key name if uses_provider_config is false + #[serde(skip_serializing_if = "Option::is_none")] + pub config_key: Option, +} + +fn validate_audio(audio: &str, mime_type: &str) -> Result<(Vec, &'static str), ErrorResponse> { + let audio_bytes = BASE64 + .decode(audio) + .map_err(|_| ErrorResponse::bad_request("Invalid base64 audio data"))?; + + let extension = match mime_type { + "audio/webm" | "audio/webm;codecs=opus" => "webm", + "audio/mp4" => "mp4", + "audio/mpeg" | "audio/mpga" => "mp3", + "audio/m4a" => "m4a", + "audio/wav" | "audio/x-wav" => "wav", + _ => { + return Err(ErrorResponse { + message: format!("Unsupported audio format: {}", mime_type), + status: StatusCode::UNSUPPORTED_MEDIA_TYPE, + }) + } + }; + + Ok((audio_bytes, extension)) +} + +fn convert_error(e: anyhow::Error) -> ErrorResponse { + let error_msg = e.to_string(); + + if error_msg.contains("Invalid API key") { + ErrorResponse { + message: error_msg, + status: StatusCode::UNAUTHORIZED, + } + } else if error_msg.contains("Rate limit exceeded") || error_msg.contains("quota") { + ErrorResponse { + message: error_msg, + status: StatusCode::TOO_MANY_REQUESTS, + } + } else if error_msg.contains("not configured") { + ErrorResponse { + message: error_msg, + status: StatusCode::PRECONDITION_FAILED, + } + } else if error_msg.contains("timeout") { + ErrorResponse { + message: error_msg, + status: StatusCode::GATEWAY_TIMEOUT, + } + } else if error_msg.contains("API error") { + ErrorResponse { + message: error_msg, + status: StatusCode::BAD_GATEWAY, + } + } else { + ErrorResponse::internal(error_msg) + } +} + +#[utoipa::path( + post, + path = "/dictation/transcribe", + request_body = TranscribeRequest, + responses( + (status = 200, description = "Audio transcribed successfully", body = TranscribeResponse), + (status = 400, description = "Invalid request (bad base64 or unsupported format)"), + (status = 401, description = "Invalid API key"), + (status = 412, description = "Provider not configured"), + (status = 413, description = "Audio file too large (max 50MB)"), + (status = 429, description = "Rate limit exceeded"), + (status = 500, description = "Internal server error"), + (status = 502, description = "Provider API error"), + (status = 503, description = "Service unavailable"), + (status = 504, description = "Request timeout") + ) +)] +pub async fn transcribe_dictation( + Json(request): Json, +) -> Result, ErrorResponse> { + let (audio_bytes, extension) = validate_audio(&request.audio, &request.mime_type)?; + + let text = match request.provider { + DictationProvider::OpenAI => transcribe_with_provider( + DictationProvider::OpenAI, + "model".to_string(), + "whisper-1".to_string(), + audio_bytes, + extension, + &request.mime_type, + ) + .await + .map_err(convert_error)?, + DictationProvider::Groq => transcribe_with_provider( + DictationProvider::Groq, + "model".to_string(), + "whisper-large-v3-turbo".to_string(), + audio_bytes, + extension, + &request.mime_type, + ) + .await + .map_err(convert_error)?, + DictationProvider::ElevenLabs => transcribe_with_provider( + DictationProvider::ElevenLabs, + "model_id".to_string(), + "scribe_v1".to_string(), + audio_bytes, + extension, + &request.mime_type, + ) + .await + .map_err(convert_error)?, + #[cfg(feature = "local-inference")] + DictationProvider::Local => transcribe_local(audio_bytes).await.map_err(convert_error)?, + }; + + Ok(Json(TranscribeResponse { text })) +} + +#[utoipa::path( + get, + path = "/dictation/config", + responses( + (status = 200, description = "Audio transcription provider configurations", body = HashMap) + ) +)] +pub async fn get_dictation_config( +) -> Result>, ErrorResponse> { + let config = goose::config::Config::global(); + let mut providers = HashMap::new(); + + for def in all_providers() { + let provider = def.provider; + let configured = is_configured(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 + }; + + providers.insert( + provider, + DictationProviderStatus { + configured, + 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 + }, + }, + ); + } + + Ok(Json(providers)) +} + +#[cfg(feature = "local-inference")] +#[utoipa::path( + get, + path = "/dictation/models", + responses( + (status = 200, description = "List of available Whisper models", body = Vec) + ) +)] +pub async fn list_models() -> Result>, ErrorResponse> { + let recommended_id = whisper::recommend_model(); + let models = whisper::available_models() + .iter() + .map(|m| WhisperModelResponse { + model: m, + downloaded: m.is_downloaded(), + recommended: m.id == recommended_id, + }) + .collect(); + + Ok(Json(models)) +} + +#[cfg(feature = "local-inference")] +#[utoipa::path( + post, + path = "/dictation/models/{model_id}/download", + responses( + (status = 202, description = "Download started"), + (status = 400, description = "Download already in progress"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn download_model(Path(model_id): Path) -> Result { + let model = whisper::get_model(&model_id) + .ok_or_else(|| ErrorResponse::bad_request("Model not found"))?; + + 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 _ = goose::config::Config::global() + .set_param(whisper::LOCAL_WHISPER_MODEL_CONFIG_KEY, model_id_for_config); + })), + ) + .await + .map_err(convert_error)?; + + Ok(StatusCode::ACCEPTED) +} + +#[cfg(feature = "local-inference")] +#[utoipa::path( + get, + path = "/dictation/models/{model_id}/download", + responses( + (status = 200, description = "Download progress", body = DownloadProgress), + (status = 404, description = "Download not found") + ) +)] +pub async fn get_download_progress( + Path(model_id): Path, +) -> Result, ErrorResponse> { + let manager = get_download_manager(); + let progress = manager + .get_progress(&model_id) + .ok_or_else(|| ErrorResponse::bad_request("Download not found"))?; + + Ok(Json(progress)) +} + +#[cfg(feature = "local-inference")] +#[utoipa::path( + delete, + path = "/dictation/models/{model_id}/download", + responses( + (status = 200, description = "Download cancelled"), + (status = 404, description = "Download not found") + ) +)] +pub async fn cancel_download(Path(model_id): Path) -> Result { + let manager = get_download_manager(); + manager.cancel_download(&model_id).map_err(convert_error)?; + Ok(StatusCode::OK) +} + +#[cfg(feature = "local-inference")] +#[utoipa::path( + delete, + path = "/dictation/models/{model_id}", + responses( + (status = 200, description = "Model deleted"), + (status = 404, description = "Model not found or not downloaded"), + (status = 500, description = "Failed to delete model") + ) +)] +pub async fn delete_model(Path(model_id): Path) -> Result { + let model = whisper::get_model(&model_id) + .ok_or_else(|| ErrorResponse::bad_request("Model not found"))?; + + let path = model.local_path(); + + if !path.exists() { + return Err(ErrorResponse::bad_request("Model not downloaded")); + } + + tokio::fs::remove_file(&path) + .await + .map_err(|e| ErrorResponse::internal(format!("Failed to delete model: {}", e)))?; + + Ok(StatusCode::OK) +} + +pub fn routes(state: Arc) -> Router { + let router = Router::new() + .route("/dictation/transcribe", post(transcribe_dictation)) + .route("/dictation/config", get(get_dictation_config)); + + #[cfg(feature = "local-inference")] + let router = router + .route("/dictation/models", get(list_models)) + .route( + "/dictation/models/{model_id}/download", + post(download_model), + ) + .route( + "/dictation/models/{model_id}/download", + get(get_download_progress), + ) + .route( + "/dictation/models/{model_id}/download", + delete(cancel_download), + ) + .route("/dictation/models/{model_id}", delete(delete_model)); + + router + .layer(DefaultBodyLimit::max(MAX_AUDIO_SIZE_BYTES)) + .with_state(state) +} diff --git a/crates/goose-server/src/routes/errors.rs b/crates/goose-server/src/routes/errors.rs new file mode 100644 index 0000000000..0866f81293 --- /dev/null +++ b/crates/goose-server/src/routes/errors.rs @@ -0,0 +1,136 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use goose::config::ConfigError; +use goose::model::ConfigError as ModelConfigError; +use goose::providers::errors::ProviderError; +use serde::Serialize; +use utoipa::ToSchema; + +#[derive(Debug, Serialize, ToSchema)] +pub struct ErrorResponse { + pub message: String, + #[serde(skip)] + pub status: StatusCode, +} + +impl ErrorResponse { + pub(crate) fn internal(message: impl Into) -> Self { + Self { + message: message.into(), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + } + + pub(crate) fn bad_request(message: impl Into) -> Self { + Self { + message: message.into(), + status: StatusCode::BAD_REQUEST, + } + } + + pub(crate) fn not_found(message: impl Into) -> Self { + Self { + message: message.into(), + status: StatusCode::NOT_FOUND, + } + } + + pub(crate) fn unprocessable(message: impl Into) -> Self { + Self { + message: message.into(), + status: StatusCode::UNPROCESSABLE_ENTITY, + } + } +} + +impl IntoResponse for ErrorResponse { + fn into_response(self) -> Response { + if self.status.is_server_error() { + tracing::error!(status = %self.status, message = %self.message, "server error response"); + } else if self.status.is_client_error() { + tracing::warn!(status = %self.status, message = %self.message, "client error response"); + } + + let body = Json(serde_json::json!({ + "message": self.message, + })); + + (self.status, body).into_response() + } +} + +impl From for ErrorResponse { + fn from(err: anyhow::Error) -> Self { + Self::internal(err.to_string()) + } +} + +impl From for ErrorResponse { + fn from(err: ConfigError) -> Self { + match err { + ConfigError::NotFound(key) => Self::not_found(format!("Config key not found: {}", key)), + _ => Self::internal(err.to_string()), + } + } +} + +impl From for ErrorResponse { + fn from(err: ModelConfigError) -> Self { + Self::internal(format!("Model configuration error: {}", err)) + } +} + +impl From for ErrorResponse { + fn from(status: StatusCode) -> Self { + let message = status.canonical_reason().unwrap_or("Unknown error"); + Self { + message: message.to_string(), + status, + } + } +} + +impl From for ErrorResponse { + fn from(err: std::io::Error) -> Self { + Self::internal(format!("IO error: {}", err)) + } +} + +impl From for ErrorResponse { + fn from(err: serde_json::Error) -> Self { + Self::internal(format!("JSON serialization error: {}", err)) + } +} + +impl From for ErrorResponse { + fn from(err: serde_yaml::Error) -> Self { + Self::unprocessable(format!("YAML parsing error: {}", err)) + } +} + +impl From for ErrorResponse { + fn from(err: ProviderError) -> Self { + let (status, message) = match err { + ProviderError::Authentication(_) => ( + StatusCode::BAD_REQUEST, + format!("Authentication failed: {}", err), + ), + ProviderError::UsageError(_) => { + (StatusCode::BAD_REQUEST, format!("Usage error: {}", err)) + } + ProviderError::RateLimitExceeded { .. } => ( + StatusCode::TOO_MANY_REQUESTS, + format!("Rate limit exceeded: {}", err), + ), + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Provider error: {}", err), + ), + }; + + Self { message, status } + } +} diff --git a/crates/goose-server/src/routes/features.rs b/crates/goose-server/src/routes/features.rs new file mode 100644 index 0000000000..f974df9fa9 --- /dev/null +++ b/crates/goose-server/src/routes/features.rs @@ -0,0 +1,33 @@ +use axum::{routing::get, Json, Router}; +use serde::Serialize; +use std::collections::HashMap; +use utoipa::ToSchema; + +#[derive(Serialize, ToSchema)] +pub struct FeaturesResponse { + /// Map of feature name to enabled status + pub features: HashMap, +} + +#[utoipa::path( + get, + path = "/features", + responses( + (status = 200, description = "Compile-time feature flags", body = FeaturesResponse), + ) +)] +pub async fn get_features() -> Json { + let mut features = HashMap::new(); + + features.insert( + "local-inference".to_string(), + cfg!(feature = "local-inference"), + ); + features.insert("code-mode".to_string(), cfg!(feature = "code-mode")); + + Json(FeaturesResponse { features }) +} + +pub fn routes() -> Router { + Router::new().route("/features", get(get_features)) +} diff --git a/crates/goose-server/src/routes/gateway.rs b/crates/goose-server/src/routes/gateway.rs new file mode 100644 index 0000000000..e49299ae4b --- /dev/null +++ b/crates/goose-server/src/routes/gateway.rs @@ -0,0 +1,225 @@ +use crate::routes::errors::ErrorResponse; +use crate::state::AppState; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{delete, get, post}, + Json, Router, +}; +use goose::gateway::manager::GatewayStatus; +use goose::gateway::GatewayConfig; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use utoipa::ToSchema; + +#[derive(Deserialize, ToSchema)] +pub struct StartGatewayRequest { + pub gateway_type: String, + pub platform_config: serde_json::Value, + #[serde(default)] + pub max_sessions: usize, +} + +#[derive(Deserialize, ToSchema)] +pub struct StopGatewayRequest { + pub gateway_type: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct RestartGatewayRequest { + pub gateway_type: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct RemoveGatewayRequest { + pub gateway_type: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct CreatePairingRequest { + pub gateway_type: String, +} + +#[derive(Serialize, ToSchema)] +pub struct PairingCodeResponse { + pub code: String, + pub expires_at: i64, +} + +#[utoipa::path( + post, + path = "/gateway/start", + request_body = StartGatewayRequest, + responses( + (status = 200, description = "Gateway started"), + (status = 400, description = "Bad request", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse) + ) +)] +pub async fn start_gateway( + State(state): State>, + Json(request): Json, +) -> Response { + let mut config = GatewayConfig { + gateway_type: request.gateway_type, + platform_config: request.platform_config, + max_sessions: request.max_sessions, + }; + + let gw = match goose::gateway::create_gateway(&mut config) { + Ok(gw) => gw, + Err(e) => return ErrorResponse::bad_request(e.to_string()).into_response(), + }; + + match state.gateway_manager.start_gateway(config, gw).await { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ErrorResponse::bad_request(e.to_string()).into_response(), + } +} + +#[utoipa::path( + post, + path = "/gateway/stop", + request_body = StopGatewayRequest, + responses( + (status = 200, description = "Gateway stopped"), + (status = 404, description = "Gateway not found", body = ErrorResponse) + ) +)] +pub async fn stop_gateway( + State(state): State>, + Json(request): Json, +) -> Response { + match state + .gateway_manager + .stop_gateway(&request.gateway_type) + .await + { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ErrorResponse::not_found(e.to_string()).into_response(), + } +} + +#[utoipa::path( + post, + path = "/gateway/restart", + request_body = RestartGatewayRequest, + responses( + (status = 200, description = "Gateway restarted"), + (status = 400, description = "Bad request", body = ErrorResponse), + (status = 404, description = "No saved config", body = ErrorResponse) + ) +)] +pub async fn restart_gateway( + State(state): State>, + Json(request): Json, +) -> Response { + match state + .gateway_manager + .restart_gateway(&request.gateway_type) + .await + { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ErrorResponse::bad_request(e.to_string()).into_response(), + } +} + +#[utoipa::path( + post, + path = "/gateway/remove", + request_body = RemoveGatewayRequest, + responses( + (status = 200, description = "Gateway removed"), + (status = 500, description = "Internal server error", body = ErrorResponse) + ) +)] +pub async fn remove_gateway( + State(state): State>, + Json(request): Json, +) -> Response { + match state + .gateway_manager + .remove_gateway(&request.gateway_type) + .await + { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ErrorResponse::internal(e.to_string()).into_response(), + } +} + +#[utoipa::path( + get, + path = "/gateway/status", + responses( + (status = 200, description = "Gateway statuses", body = Vec) + ) +)] +pub async fn gateway_status(State(state): State>) -> Json> { + Json(state.gateway_manager.status().await) +} + +#[utoipa::path( + post, + path = "/gateway/pair", + request_body = CreatePairingRequest, + responses( + (status = 200, description = "Pairing code generated", body = PairingCodeResponse), + (status = 500, description = "Internal server error", body = ErrorResponse) + ) +)] +pub async fn create_pairing_code( + State(state): State>, + Json(request): Json, +) -> Response { + match state + .gateway_manager + .generate_pairing_code(&request.gateway_type) + .await + { + Ok((code, expires_at)) => ( + StatusCode::OK, + Json(PairingCodeResponse { code, expires_at }), + ) + .into_response(), + Err(e) => ErrorResponse::internal(e.to_string()).into_response(), + } +} + +#[utoipa::path( + delete, + path = "/gateway/pair/{platform}/{user_id}", + params( + ("platform" = String, Path, description = "Platform name"), + ("user_id" = String, Path, description = "Platform user ID") + ), + responses( + (status = 200, description = "User unpaired"), + (status = 404, description = "Pairing not found", body = ErrorResponse) + ) +)] +pub async fn unpair_user( + State(state): State>, + Path((platform, user_id)): Path<(String, String)>, +) -> Response { + match state.gateway_manager.unpair_user(&platform, &user_id).await { + Ok(true) => StatusCode::OK.into_response(), + Ok(false) => { + ErrorResponse::not_found(format!("No pairing found for {}/{}", platform, user_id)) + .into_response() + } + Err(e) => ErrorResponse::internal(e.to_string()).into_response(), + } +} + +pub fn routes(state: Arc) -> Router { + Router::new() + .route("/gateway/start", post(start_gateway)) + .route("/gateway/stop", post(stop_gateway)) + .route("/gateway/restart", post(restart_gateway)) + .route("/gateway/remove", post(remove_gateway)) + .route("/gateway/status", get(gateway_status)) + .route("/gateway/pair", post(create_pairing_code)) + .route("/gateway/pair/{platform}/{user_id}", delete(unpair_user)) + .with_state(state) +} diff --git a/crates/goose-server/src/routes/local_inference.rs b/crates/goose-server/src/routes/local_inference.rs new file mode 100644 index 0000000000..8ee9bc4eeb --- /dev/null +++ b/crates/goose-server/src/routes/local_inference.rs @@ -0,0 +1,792 @@ +use std::path::PathBuf; + +use crate::routes::errors::ErrorResponse; +use crate::state::AppState; +use axum::{ + extract::{Path, Query}, + http::StatusCode, + routing::{delete, get, post}, + Json, Router, +}; +use futures::future::join_all; +use goose::config::paths::Paths; +use goose::download_manager::{get_download_manager, DownloadProgress}; +use goose::providers::local_inference::hf_models::{self, HfModelInfo, HfQuantVariant}; +use goose::providers::local_inference::{ + available_inference_memory_bytes, builtin_chat_template_names, + hf_models::{resolve_model_spec_full, HfGgufFile}, + local_model_registry::{ + default_settings_for_model, get_registry, is_featured_model, mmproj_local_path, + model_id_from_repo, LocalModelEntry, ModelDownloadStatus as RegistryDownloadStatus, + ModelSettings, ShardFile, FEATURED_MODELS, + }, + recommend_local_model, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tracing::debug; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(tag = "state")] +pub enum ModelDownloadStatus { + NotDownloaded, + Downloading { + progress_percent: f32, + bytes_downloaded: u64, + total_bytes: u64, + speed_bps: Option, + }, + Downloaded, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct LocalModelResponse { + pub id: String, + pub repo_id: String, + pub filename: String, + pub quantization: String, + pub size_bytes: u64, + pub status: ModelDownloadStatus, + pub recommended: bool, + pub settings: ModelSettings, + pub vision_capable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub mmproj_status: Option, +} + +async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> { + let mut mmproj_downloads_needed: Vec<(String, String, PathBuf)> = Vec::new(); + + struct PendingResolve { + spec: &'static str, + repo_id: String, + quantization: String, + model_id: String, + } + let mut to_resolve = Vec::new(); + + for featured in FEATURED_MODELS { + let (repo_id, quantization) = match hf_models::parse_model_spec(featured.spec) { + Ok(parts) => parts, + Err(_) => continue, + }; + + let model_id = model_id_from_repo(&repo_id, &quantization); + + { + let registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + if let Some(existing) = registry.get_model(&model_id) { + if let Some(path) = &existing.mmproj_path { + if existing.is_downloaded() && !path.exists() { + if let Some(url) = &existing.mmproj_source_url { + mmproj_downloads_needed.push(( + model_id.clone(), + url.clone(), + path.clone(), + )); + } + } + continue; + } + } + } + + to_resolve.push(PendingResolve { + spec: featured.spec, + repo_id, + quantization, + model_id, + }); + } + + let resolved: Vec<(PendingResolve, HfGgufFile, Option)> = + join_all(to_resolve.into_iter().map(|pending| async move { + let (hf_file, mmproj) = match resolve_model_spec_full(pending.spec).await { + Ok((_repo, resolved)) => (resolved.files[0].clone(), resolved.mmproj), + Err(_) => { + let filename = format!( + "{}-{}.gguf", + pending.repo_id.split('/').next_back().unwrap_or("model"), + pending.quantization + ); + ( + HfGgufFile { + filename: filename.clone(), + size_bytes: 0, + quantization: pending.quantization.to_string(), + download_url: format!( + "https://huggingface.co/{}/resolve/main/{}", + pending.repo_id, filename + ), + }, + None, + ) + } + }; + (pending, hf_file, mmproj) + })) + .await; + + let entries_to_add: Vec = resolved + .into_iter() + .map(|(pending, hf_file, mmproj)| { + let local_path = Paths::in_data_dir("models").join(&hf_file.filename); + let settings = default_settings_for_model(&pending.model_id); + let mmproj_path = mmproj + .as_ref() + .map(|mmproj| mmproj_local_path(&pending.repo_id, &mmproj.filename)); + let mmproj_source_url = mmproj.as_ref().map(|mmproj| mmproj.download_url.clone()); + let mmproj_size_bytes = mmproj.as_ref().map_or(0, |mmproj| mmproj.size_bytes); + let mmproj_checked = mmproj.is_some(); + LocalModelEntry { + id: pending.model_id, + repo_id: pending.repo_id, + filename: hf_file.filename, + quantization: pending.quantization, + local_path, + source_url: hf_file.download_url, + settings, + size_bytes: hf_file.size_bytes, + mmproj_path, + mmproj_source_url, + mmproj_size_bytes, + mmproj_checked, + shard_files: vec![], + } + }) + .collect(); + + { + let mut registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + + if !entries_to_add.is_empty() { + registry.sync_with_featured(entries_to_add); + } + } + + let to_backfill: Vec<(String, String, String)> = { + let registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + + registry + .list_models() + .iter() + .filter(|model| model.is_downloaded()) + .filter(|model| model.mmproj_path.is_none()) + .filter(|model| !model.mmproj_checked) + .map(|model| { + ( + model.id.clone(), + model.repo_id.clone(), + model.quantization.clone(), + ) + }) + .collect() + }; + + let mmproj_backfills: Vec<(String, String, Option>)> = join_all( + to_backfill + .into_iter() + .map(|(id, repo_id, quantization)| async move { + let spec = format!("{repo_id}:{quantization}"); + let mmproj = resolve_model_spec_full(&spec) + .await + .ok() + .map(|(_, resolved)| resolved.mmproj); + (id, repo_id, mmproj) + }), + ) + .await; + + { + let mut registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + + for (model_id, repo_id, mmproj_result) in mmproj_backfills { + if let Some(model) = registry + .list_models_mut() + .iter_mut() + .find(|model| model.id == model_id) + { + let Some(mmproj) = mmproj_result else { + continue; + }; + + model.mmproj_checked = true; + if let Some(mmproj) = mmproj { + model.mmproj_path = Some(mmproj_local_path(&repo_id, &mmproj.filename)); + model.mmproj_source_url = Some(mmproj.download_url); + model.mmproj_size_bytes = mmproj.size_bytes; + } + model.refresh_mmproj_metadata(); + } + } + + for model in registry.list_models_mut() { + model.refresh_mmproj_metadata(); + if model.is_downloaded() { + if let Some(path) = &model.mmproj_path { + if !path.exists() { + if let Some(url) = &model.mmproj_source_url { + mmproj_downloads_needed.push(( + model.id.clone(), + url.clone(), + path.clone(), + )); + } + } + } + } + } + let _ = registry.save(); + } + + // Auto-download mmproj files for models that are already downloaded. + // Deduplicate by path since multiple quants share one mmproj file. + let dm = get_download_manager(); + let mut started_paths = std::collections::HashSet::new(); + for (model_id, url, path) in mmproj_downloads_needed { + if !path.exists() && started_paths.insert(path.clone()) { + let download_id = format!("{}-mmproj", model_id); + let dominated_by_active = dm + .get_progress(&download_id) + .is_some_and(|p| p.status == goose::download_manager::DownloadStatus::Downloading); + if !dominated_by_active { + tracing::info!(model_id = %model_id, "Auto-downloading vision encoder for existing model"); + if let Err(e) = dm.download_model(download_id, url, path, None).await { + tracing::warn!(model_id = %model_id, error = %e, "Failed to start mmproj download"); + } + } + } + } + + Ok(()) +} + +#[utoipa::path( + post, + path = "/local-inference/sync-featured", + responses( + (status = 200, description = "Featured models synced to registry") + ) +)] +pub async fn sync_featured_models() -> Result { + ensure_featured_models_in_registry().await?; + Ok(StatusCode::OK) +} + +#[utoipa::path( + get, + path = "/local-inference/models", + responses( + (status = 200, description = "List of available local LLM models", body = Vec) + ) +)] +pub async fn list_local_models( + axum::extract::State(state): axum::extract::State>, +) -> Result>, ErrorResponse> { + let runtime = state.get_inference_runtime()?; + let recommended_id = recommend_local_model(&runtime); + + let registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + + let mut models: Vec = Vec::new(); + + for entry in registry.list_models() { + let goose_status = entry.download_status(); + + let status = match goose_status { + RegistryDownloadStatus::NotDownloaded => ModelDownloadStatus::NotDownloaded, + RegistryDownloadStatus::Downloading { + progress_percent, + bytes_downloaded, + total_bytes, + speed_bps, + } => ModelDownloadStatus::Downloading { + progress_percent, + bytes_downloaded, + total_bytes, + speed_bps: Some(speed_bps), + }, + RegistryDownloadStatus::Downloaded => ModelDownloadStatus::Downloaded, + }; + + let size_bytes = entry.file_size(); + + let vision_capable = entry.settings.vision_capable; + let mmproj_status = if vision_capable { + let ms = entry.mmproj_download_status(); + Some(match ms { + RegistryDownloadStatus::NotDownloaded => ModelDownloadStatus::NotDownloaded, + RegistryDownloadStatus::Downloading { + progress_percent, + bytes_downloaded, + total_bytes, + speed_bps, + } => ModelDownloadStatus::Downloading { + progress_percent, + bytes_downloaded, + total_bytes, + speed_bps: Some(speed_bps), + }, + RegistryDownloadStatus::Downloaded => ModelDownloadStatus::Downloaded, + }) + } else { + None + }; + + models.push(LocalModelResponse { + id: entry.id.clone(), + repo_id: entry.repo_id.clone(), + filename: entry.filename.clone(), + quantization: entry.quantization.clone(), + size_bytes, + status, + recommended: recommended_id == entry.id, + settings: entry.settings.clone(), + vision_capable, + mmproj_status, + }); + } + + models.sort_by(|a, b| { + let a_downloaded = matches!(a.status, ModelDownloadStatus::Downloaded); + let b_downloaded = matches!(b.status, ModelDownloadStatus::Downloaded); + match (b_downloaded, a_downloaded) { + (true, false) => std::cmp::Ordering::Greater, + (false, true) => std::cmp::Ordering::Less, + _ => a.id.cmp(&b.id), + } + }); + + Ok(Json(models)) +} + +#[derive(Debug, Deserialize)] +pub struct SearchQuery { + pub q: String, + pub limit: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct RepoVariantsResponse { + pub variants: Vec, + pub recommended_index: Option, + pub available_memory_bytes: u64, + pub downloaded_quants: Vec, +} + +#[utoipa::path( + get, + path = "/local-inference/search", + params( + ("q" = String, Query, description = "Search query"), + ("limit" = Option, Query, description = "Max results") + ), + responses( + (status = 200, description = "Search results", body = Vec), + (status = 500, description = "Search failed") + ) +)] +pub async fn search_hf_models( + Query(params): Query, +) -> Result>, ErrorResponse> { + let limit = params.limit.unwrap_or(20).min(50); + let results = hf_models::search_gguf_models(¶ms.q, limit) + .await + .map_err(|e| ErrorResponse::internal(format!("Search failed: {}", e)))?; + Ok(Json(results)) +} + +#[utoipa::path( + get, + path = "/local-inference/repo/{author}/{repo}/files", + responses( + (status = 200, description = "GGUF files in the repo", body = RepoVariantsResponse) + ) +)] +pub async fn get_repo_files( + axum::extract::State(state): axum::extract::State>, + Path((author, repo)): Path<(String, String)>, +) -> Result, ErrorResponse> { + let repo_id = format!("{}/{}", author, repo); + let variants = hf_models::get_repo_gguf_variants(&repo_id) + .await + .map_err(|e| ErrorResponse::internal(format!("Failed to fetch repo files: {}", e)))?; + + let runtime = state.get_inference_runtime()?; + let available_memory = available_inference_memory_bytes(&runtime); + let recommended_index = hf_models::recommend_variant(&variants, available_memory); + + let downloaded_quants = { + let registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + registry + .list_models() + .iter() + .filter(|m| m.repo_id == repo_id && m.is_downloaded()) + .map(|m| m.quantization.clone()) + .collect() + }; + + Ok(Json(RepoVariantsResponse { + variants, + recommended_index, + available_memory_bytes: available_memory, + downloaded_quants, + })) +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct DownloadModelRequest { + /// Model spec like "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M" + pub spec: String, +} + +#[utoipa::path( + post, + path = "/local-inference/download", + request_body = DownloadModelRequest, + responses( + (status = 202, description = "Download started", body = String), + (status = 400, description = "Invalid request") + ) +)] +pub async fn download_hf_model( + Json(req): Json, +) -> Result<(StatusCode, Json), ErrorResponse> { + let (repo_id, quantization) = hf_models::parse_model_spec(&req.spec) + .map_err(|e| ErrorResponse::bad_request(format!("Invalid spec format: {e}")))?; + + let (_repo, resolved) = resolve_model_spec_full(&req.spec) + .await + .map_err(|e| ErrorResponse::bad_request(format!("Invalid spec: {}", e)))?; + + let model_id = model_id_from_repo(&repo_id, &quantization); + let models_dir = Paths::in_data_dir("models"); + let first_file = &resolved.files[0]; + let first_local_path = models_dir.join(&first_file.filename); + + let shard_files: Vec = if resolved.files.len() > 1 { + resolved + .files + .iter() + .skip(1) + .map(|f| ShardFile { + filename: f.filename.clone(), + local_path: models_dir.join(&f.filename), + source_url: f.download_url.clone(), + size_bytes: f.size_bytes, + }) + .collect() + } else { + vec![] + }; + + let mmproj_path = resolved + .mmproj + .as_ref() + .map(|mmproj| mmproj_local_path(&repo_id, &mmproj.filename)); + let mmproj_source_url = resolved + .mmproj + .as_ref() + .map(|mmproj| mmproj.download_url.clone()); + let mmproj_size_bytes = resolved + .mmproj + .as_ref() + .map_or(0, |mmproj| mmproj.size_bytes); + let mmproj_checked = true; + + let entry = LocalModelEntry { + id: model_id.clone(), + repo_id, + filename: first_file.filename.clone(), + quantization, + local_path: first_local_path.clone(), + source_url: first_file.download_url.clone(), + settings: default_settings_for_model(&model_id), + size_bytes: resolved.total_size, + mmproj_path, + mmproj_source_url, + mmproj_size_bytes, + mmproj_checked, + shard_files: shard_files.clone(), + }; + + let mmproj_path = { + let mut registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + registry + .add_model(entry) + .map_err(|e| ErrorResponse::internal(format!("{}", e)))?; + registry.get_model(&model_id).and_then(|e| { + e.mmproj_path + .as_ref() + .zip(e.mmproj_source_url.as_ref()) + .map(|(p, u)| (p.clone(), u.clone())) + }) + }; + + let dm = get_download_manager(); + let all_files: Vec<(String, std::path::PathBuf)> = resolved + .files + .iter() + .map(|f| (f.download_url.clone(), models_dir.join(&f.filename))) + .collect(); + + dm.download_model_sharded( + format!("{}-model", model_id), + all_files, + resolved.total_size, + None, + ) + .await + .map_err(|e| ErrorResponse::internal(format!("Download failed: {}", e)))?; + + if let Some((mmproj_path, mmproj_url)) = mmproj_path { + if !mmproj_path.exists() { + dm.download_model( + format!("{}-mmproj", model_id), + mmproj_url, + mmproj_path, + None, + ) + .await + .map_err(|e| ErrorResponse::internal(format!("mmproj download failed: {}", e)))?; + } + } + + Ok((StatusCode::ACCEPTED, Json(model_id))) +} + +#[utoipa::path( + get, + path = "/local-inference/models/{model_id}/download", + responses( + (status = 200, description = "Download progress", body = DownloadProgress), + (status = 404, description = "No active download") + ) +)] +pub async fn get_local_model_download_progress( + Path(model_id): Path, +) -> Result, ErrorResponse> { + let download_id = format!("{}-model", model_id); + debug!(model_id = %model_id, download_id = %download_id, "Getting download progress"); + + let manager = get_download_manager(); + + let model_progress = manager + .get_progress(&download_id) + .ok_or_else(|| ErrorResponse::not_found("No active download"))?; + + Ok(Json(model_progress)) +} + +#[utoipa::path( + delete, + path = "/local-inference/models/{model_id}/download", + responses( + (status = 200, description = "Download cancelled"), + (status = 404, description = "No active download") + ) +)] +pub async fn cancel_local_model_download( + Path(model_id): Path, +) -> Result { + let manager = get_download_manager(); + manager + .cancel_download(&format!("{}-model", model_id)) + .map_err(|e| ErrorResponse::internal(format!("{}", e)))?; + let _ = manager.cancel_download(&format!("{}-mmproj", model_id)); + + Ok(StatusCode::OK) +} + +#[utoipa::path( + delete, + path = "/local-inference/models/{model_id}", + responses( + (status = 200, description = "Model deleted"), + (status = 404, description = "Model not found") + ) +)] +pub async fn delete_local_model(Path(model_id): Path) -> Result { + let (all_paths, primary_path, mmproj_path, other_uses_mmproj) = { + let registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + let entry = registry + .get_model(&model_id) + .ok_or_else(|| ErrorResponse::not_found("Model not found"))?; + let paths: Vec = + entry.all_local_paths().map(|p| p.to_path_buf()).collect(); + let primary = entry.local_path.clone(); + let mp = entry.mmproj_path.clone(); + let shared = mp.as_ref().is_some_and(|target| { + registry.list_models().iter().any(|m| { + m.id != model_id && m.is_downloaded() && m.mmproj_path.as_ref() == Some(target) + }) + }); + (paths, primary, mp, shared) + }; + + for path in &all_paths { + if path.exists() { + tokio::fs::remove_file(path) + .await + .map_err(|e| ErrorResponse::internal(format!("Failed to delete: {}", e)))?; + } + } + + // Clean up empty parent directories (e.g. BF16/ subdirectory) + if let Some(parent) = primary_path.parent() { + let models_dir = Paths::in_data_dir("models"); + if parent != models_dir { + let _ = tokio::fs::remove_dir(parent).await; + } + } + + if !other_uses_mmproj { + if let Some(mmproj) = mmproj_path { + if mmproj.exists() { + let _ = tokio::fs::remove_file(&mmproj).await; + } + } + } + + // Only remove non-featured models from registry (featured ones stay as placeholders) + if !is_featured_model(&model_id) { + let mut registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + registry + .remove_model(&model_id) + .map_err(|e| ErrorResponse::internal(format!("{}", e)))?; + } + + Ok(StatusCode::OK) +} + +#[utoipa::path( + get, + path = "/local-inference/models/{model_id}/settings", + responses( + (status = 200, description = "Model settings", body = ModelSettings), + (status = 404, description = "Model not found") + ) +)] +pub async fn get_model_settings( + Path(model_id): Path, +) -> Result, ErrorResponse> { + let registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + + if let Some(settings) = registry.get_model_settings(&model_id) { + return Ok(Json(settings.clone())); + } + + Err(ErrorResponse::not_found("Model not found")) +} + +#[utoipa::path( + put, + path = "/local-inference/models/{model_id}/settings", + request_body = ModelSettings, + responses( + (status = 200, description = "Settings updated", body = ModelSettings), + (status = 404, description = "Model not found"), + (status = 500, description = "Failed to save settings") + ) +)] +pub async fn update_model_settings( + Path(model_id): Path, + Json(settings): Json, +) -> Result, ErrorResponse> { + let mut registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + + registry + .update_model_settings(&model_id, settings.clone()) + .map_err(|e| ErrorResponse::not_found(format!("{}", e)))?; + + Ok(Json(settings)) +} + +#[utoipa::path( + get, + path = "/local-inference/chat-templates/builtin", + responses( + (status = 200, description = "llama.cpp built-in chat template names", body = Vec) + ) +)] +pub async fn list_builtin_chat_templates() -> Json> { + Json(builtin_chat_template_names()) +} + +pub fn routes(state: Arc) -> Router { + let registered_paths: std::collections::HashSet = get_registry() + .lock() + .map(|reg| { + reg.list_models() + .iter() + .flat_map(|m| { + m.all_local_paths() + .map(|p| p.to_path_buf()) + .chain(m.mmproj_path.as_deref().map(|p| p.to_path_buf())) + }) + .collect() + }) + .unwrap_or_default(); + goose::download_manager::cleanup_partial_downloads( + &Paths::in_data_dir("models"), + ®istered_paths, + ); + + Router::new() + .route("/local-inference/models", get(list_local_models)) + .route("/local-inference/sync-featured", post(sync_featured_models)) + .route("/local-inference/search", get(search_hf_models)) + .route( + "/local-inference/chat-templates/builtin", + get(list_builtin_chat_templates), + ) + .route( + "/local-inference/repo/{author}/{repo}/files", + get(get_repo_files), + ) + .route("/local-inference/download", post(download_hf_model)) + .route( + "/local-inference/models/{model_id}/download", + get(get_local_model_download_progress), + ) + .route( + "/local-inference/models/{model_id}/download", + delete(cancel_local_model_download), + ) + .route( + "/local-inference/models/{model_id}", + delete(delete_local_model), + ) + .route( + "/local-inference/models/{model_id}/settings", + get(get_model_settings), + ) + .route( + "/local-inference/models/{model_id}/settings", + axum::routing::put(update_model_settings), + ) + .with_state(state) +} diff --git a/crates/goose-server/src/routes/mcp_app_proxy.rs b/crates/goose-server/src/routes/mcp_app_proxy.rs new file mode 100644 index 0000000000..2a4a7502cf --- /dev/null +++ b/crates/goose-server/src/routes/mcp_app_proxy.rs @@ -0,0 +1,295 @@ +use axum::{ + extract::Query, + http::{header, StatusCode}, + response::{Html, IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::RwLock; +use uuid::Uuid; + +const GUEST_HTML_TTL_SECS: u64 = 300; // 5 minutes +const GUEST_HTML_MAX_ENTRIES: usize = 64; + +/// In-memory store for guest HTML content. +/// Maps nonce -> (html_content, csp_string, created_at) +/// Entries are consumed on first read and evicted after TTL. +type GuestHtmlStore = Arc>>; + +#[derive(Deserialize)] +struct ProxyQuery { + secret: String, + /// Comma-separated list of domains for connect-src (fetch, XHR, WebSocket) + connect_domains: Option, + /// Comma-separated list of domains for resource loading (scripts, styles, images, fonts, media) + resource_domains: Option, + /// Comma-separated list of origins for nested iframes (frame-src) + frame_domains: Option, + /// Comma-separated list of allowed base URIs (base-uri) + base_uri_domains: Option, + /// Comma-separated list of domains for script-src (external scripts like SDKs) + script_domains: Option, +} + +#[derive(Deserialize)] +struct GuestQuery { + secret: String, + nonce: String, +} + +#[derive(Deserialize)] +struct StoreGuestBody { + secret: String, + html: String, + /// CSP string to apply to the guest page + csp: Option, +} + +const MCP_APP_PROXY_HTML: &str = include_str!("templates/mcp_app_proxy.html"); + +/// Build the outer sandbox CSP based on declared domains. +/// +/// This CSP acts as a ceiling - the inner guest UI iframe cannot exceed these +/// permissions, even if it tried. This is the single source of truth for +/// security policy enforcement. +/// +/// Based on the MCP Apps specification (ext-apps SEP): +/// +fn build_outer_csp( + connect_domains: &[String], + resource_domains: &[String], + frame_domains: &[String], + base_uri_domains: &[String], + script_domains: &[String], +) -> String { + let resources = if resource_domains.is_empty() { + String::new() + } else { + format!(" {}", resource_domains.join(" ")) + }; + + let scripts = if script_domains.is_empty() { + String::new() + } else { + format!(" {}", script_domains.join(" ")) + }; + + let connections = if connect_domains.is_empty() { + String::new() + } else { + format!(" {}", connect_domains.join(" ")) + }; + + // frame-src needs 'self' so the proxy can load the guest iframe from /mcp-app-guest + let frame_src = if frame_domains.is_empty() { + "frame-src 'self'".to_string() + } else { + format!("frame-src 'self' {}", frame_domains.join(" ")) + }; + + let base_uris = if base_uri_domains.is_empty() { + String::new() + } else { + format!(" {}", base_uri_domains.join(" ")) + }; + + format!( + "default-src 'none'; \ + script-src 'self' 'unsafe-inline'{resources}{scripts}; \ + script-src-elem 'self' 'unsafe-inline'{resources}{scripts}; \ + style-src 'self' 'unsafe-inline'{resources}; \ + style-src-elem 'self' 'unsafe-inline'{resources}; \ + connect-src 'self'{connections}; \ + img-src 'self' data: blob:{resources}; \ + font-src 'self'{resources}; \ + media-src 'self' data: blob:{resources}; \ + {frame_src}; \ + object-src 'none'; \ + base-uri 'self'{base_uris}" + ) +} + +/// Parse comma-separated domains, filtering out empty strings +fn parse_domains(domains: Option<&String>) -> Vec { + domains + .map(|d| { + d.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default() +} + +#[derive(Clone)] +struct AppState { + secret_key: String, + guest_store: GuestHtmlStore, +} + +#[utoipa::path( + get, + path = "/mcp-app-proxy", + params( + ("secret" = String, Query, description = "Secret key for authentication"), + ("connect_domains" = Option, Query, description = "Comma-separated domains for connect-src"), + ("resource_domains" = Option, Query, description = "Comma-separated domains for resource loading"), + ("frame_domains" = Option, Query, description = "Comma-separated origins for nested iframes (frame-src)"), + ("base_uri_domains" = Option, Query, description = "Comma-separated allowed base URIs (base-uri)"), + ("script_domains" = Option, Query, description = "Comma-separated domains for script-src") + ), + responses( + (status = 200, description = "MCP App proxy HTML page", content_type = "text/html"), + (status = 401, description = "Unauthorized - invalid or missing secret"), + ) +)] +async fn mcp_app_proxy( + axum::extract::State(state): axum::extract::State, + Query(params): Query, +) -> Response { + if params.secret != state.secret_key { + return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); + } + + // Parse domains from query params + let connect_domains = parse_domains(params.connect_domains.as_ref()); + let resource_domains = parse_domains(params.resource_domains.as_ref()); + let frame_domains = parse_domains(params.frame_domains.as_ref()); + let base_uri_domains = parse_domains(params.base_uri_domains.as_ref()); + let script_domains = parse_domains(params.script_domains.as_ref()); + + // Build the outer CSP based on declared domains + let csp = build_outer_csp( + &connect_domains, + &resource_domains, + &frame_domains, + &base_uri_domains, + &script_domains, + ); + + // Replace the CSP placeholder in the HTML template + let html = MCP_APP_PROXY_HTML.replace("{{OUTER_CSP}}", &csp); + + ( + [ + (header::CONTENT_TYPE, "text/html; charset=utf-8"), + ( + header::HeaderName::from_static("referrer-policy"), + "no-referrer", + ), + ], + Html(html), + ) + .into_response() +} + +/// Store guest HTML and return a nonce for retrieval. +/// The proxy page calls this via fetch, then sets the guest iframe src to /mcp-app-guest?nonce=... +async fn store_guest_html( + axum::extract::State(state): axum::extract::State, + Json(body): Json, +) -> Response { + if body.secret != state.secret_key { + return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); + } + + let nonce = Uuid::new_v4().to_string(); + let csp = body.csp.unwrap_or_default(); + + { + let mut store = state.guest_store.write().await; + + // Evict expired entries + let cutoff = Instant::now() - std::time::Duration::from_secs(GUEST_HTML_TTL_SECS); + store.retain(|_, (_, _, created)| *created > cutoff); + + // If still at capacity, drop the oldest entry + if store.len() >= GUEST_HTML_MAX_ENTRIES { + if let Some(oldest_key) = store + .iter() + .min_by_key(|(_, (_, _, created))| *created) + .map(|(k, _)| k.clone()) + { + store.remove(&oldest_key); + } + } + + store.insert(nonce.clone(), (body.html, csp, Instant::now())); + } + + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/json")], + format!(r#"{{"nonce":"{}"}}"#, nonce), + ) + .into_response() +} + +/// Serve stored guest HTML with a real HTTPS URL. +/// This gives the guest iframe `window.location.protocol === "https:"`, +/// which is required by SDKs like Square Web Payments that check for secure context. +async fn serve_guest_html( + axum::extract::State(state): axum::extract::State, + Query(params): Query, +) -> Response { + if params.secret != state.secret_key { + return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); + } + + // Consume the entry (one-time use) + let entry = { + let mut store = state.guest_store.write().await; + store.remove(¶ms.nonce) + }; + + match entry { + Some((html, csp, _created)) => { + let mut response = Html(html).into_response(); + let headers = response.headers_mut(); + // Use strict-origin so third-party SDKs (e.g. Square Web Payments) + // receive the origin in their requests, which they need for auth. + // no-referrer would cause 401s from SDK servers. + headers.insert( + header::HeaderName::from_static("referrer-policy"), + header::HeaderValue::from_static("strict-origin"), + ); + if !csp.is_empty() { + match csp.parse::() { + Ok(csp_value) => { + headers.insert(header::CONTENT_SECURITY_POLICY, csp_value); + } + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + "Invalid characters in Content-Security-Policy value", + ) + .into_response(); + } + } + } + response + } + None => ( + StatusCode::NOT_FOUND, + "Guest content not found or already consumed", + ) + .into_response(), + } +} + +pub fn routes(secret_key: String) -> Router { + let state = AppState { + secret_key, + guest_store: Arc::new(RwLock::new(HashMap::new())), + }; + + Router::new() + .route("/mcp-app-proxy", get(mcp_app_proxy)) + .route("/mcp-app-guest", get(serve_guest_html)) + .route("/mcp-app-guest", post(store_guest_html)) + .with_state(state) +} diff --git a/crates/goose-server/src/routes/mcp_ui_proxy.rs b/crates/goose-server/src/routes/mcp_ui_proxy.rs new file mode 100644 index 0000000000..2489c8d98f --- /dev/null +++ b/crates/goose-server/src/routes/mcp_ui_proxy.rs @@ -0,0 +1,53 @@ +use axum::{ + extract::Query, + http::{header, StatusCode}, + response::{Html, IntoResponse, Response}, + routing::get, + Router, +}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct ProxyQuery { + secret: String, +} + +const MCP_UI_PROXY_HTML: &str = include_str!("templates/mcp_ui_proxy.html"); + +#[utoipa::path( + get, + path = "/mcp-ui-proxy", + params( + ("secret" = String, Query, description = "Secret key for authentication") + ), + responses( + (status = 200, description = "MCP UI proxy HTML page", content_type = "text/html"), + (status = 401, description = "Unauthorized - invalid or missing secret"), + ) +)] +async fn mcp_ui_proxy( + axum::extract::State(secret_key): axum::extract::State, + Query(params): Query, +) -> Response { + if params.secret != secret_key { + return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); + } + + ( + [ + (header::CONTENT_TYPE, "text/html; charset=utf-8"), + ( + header::HeaderName::from_static("referrer-policy"), + "no-referrer", + ), + ], + Html(MCP_UI_PROXY_HTML), + ) + .into_response() +} + +pub fn routes(secret_key: String) -> Router { + Router::new() + .route("/mcp-ui-proxy", get(mcp_ui_proxy)) + .with_state(secret_key) +} diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs new file mode 100644 index 0000000000..00777d80ac --- /dev/null +++ b/crates/goose-server/src/routes/mod.rs @@ -0,0 +1,57 @@ +pub mod action_required; +pub mod agent; +pub mod config_management; +pub mod dictation; +pub mod errors; +pub mod features; +pub mod gateway; +#[cfg(feature = "local-inference")] +pub mod local_inference; +pub mod mcp_app_proxy; +pub mod mcp_ui_proxy; +pub mod prompts; +pub mod recipe; +pub mod recipe_utils; +pub mod reply; +pub mod sampling; +pub mod schedule; +pub mod session; +pub mod session_events; +pub mod setup; +pub mod status; +pub mod telemetry; +pub mod tunnel; +pub mod utils; + +use std::sync::Arc; + +use axum::Router; + +// Function to configure all routes +pub fn configure(state: Arc, secret_key: String) -> Router { + let router = Router::new() + .merge(status::routes(state.clone())) + .merge(reply::routes(state.clone())) + .merge(action_required::routes(state.clone())) + .merge(agent::routes(state.clone())) + .merge(config_management::routes(state.clone())) + .merge(prompts::routes()) + .merge(recipe::routes(state.clone())) + .merge(session::routes(state.clone())) + .merge(schedule::routes(state.clone())) + .merge(setup::routes(state.clone())) + .merge(telemetry::routes(state.clone())) + .merge(tunnel::routes(state.clone())) + .merge(gateway::routes(state.clone())) + .merge(mcp_ui_proxy::routes(secret_key.clone())) + .merge(mcp_app_proxy::routes(secret_key)) + .merge(session_events::routes(state.clone())) + .merge(sampling::routes(state.clone())) + .merge(dictation::routes(state.clone())) + .merge(features::routes()); + + #[cfg(feature = "local-inference")] + let router = router.merge(local_inference::routes(state)); + + router +} diff --git a/crates/goose-server/src/routes/prompts.rs b/crates/goose-server/src/routes/prompts.rs new file mode 100644 index 0000000000..b807dc5910 --- /dev/null +++ b/crates/goose-server/src/routes/prompts.rs @@ -0,0 +1,132 @@ +use crate::routes::errors::ErrorResponse; +use axum::{ + extract::Path, + routing::{delete, get, put}, + Json, Router, +}; +use goose::prompt_template::{ + get_template, list_templates, reset_template, save_template, Template, +}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Serialize, ToSchema)] +pub struct PromptsListResponse { + pub prompts: Vec