diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 6f464634ad..d58649e65d 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: '' -labels: bug +type: bug assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 5c2d2a4572..3ba13e0cec 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1 @@ 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 fbbcc255b8..2d5bb5e360 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -2,11 +2,14 @@ name: Feature request about: Suggest an idea for this project title: '' -labels: 'enhancement' +type: 'feature' 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 deleted file mode 100644 index 4c632582d0..0000000000 --- a/.github/ISSUE_TEMPLATE/submit-recipe.yml +++ /dev/null @@ -1,99 +0,0 @@ -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 e0c7e4d15d..48ffe753ab 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. 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)). +1. Approve the workflows for this PR (scroll to bottom) to trigger CI and produce the Desktop bundle for testing. 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 51d3018cbb..5d902fbe55 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), `goose-cli` (CLI), `goose-server` (backend), `goose-mcp` (MCP servers) +- Core crates: `goose` (agent logic and ACP server), `goose-cli` (CLI), `goose-mcp` (MCP servers) - Error handling: Use `anyhow::Result`, not `unwrap()` in production code - Async runtime: tokio - MCP protocol implementations require extra scrutiny @@ -56,7 +56,6 @@ - `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 deleted file mode 100644 index 651fd3111c..0000000000 --- a/.github/workflows/autoclose +++ /dev/null @@ -1,23 +0,0 @@ -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-notify.yml b/.github/workflows/build-notify.yml index 8b2bf465eb..552aa275c5 100644 --- a/.github/workflows/build-notify.yml +++ b/.github/workflows/build-notify.yml @@ -7,6 +7,8 @@ on: - Release - Canary types: [completed] + branches-ignore: + - "release/**" jobs: @@ -34,4 +36,4 @@ jobs: "color": 15158332 }] }' \ - "$DISCORD_WEBHOOK_URL" \ No newline at end of file + "$DISCORD_WEBHOOK_URL" diff --git a/.github/workflows/bundle-desktop-intel.yml b/.github/workflows/bundle-desktop-intel.yml index 54d76434ed..c2a26bf352 100644 --- a/.github/workflows/bundle-desktop-intel.yml +++ b/.github/workflows/bundle-desktop-intel.yml @@ -73,11 +73,11 @@ jobs: key: intel-macos-deployment-target-12 - - name: Build goose-server for Intel macOS (x86_64) + - name: Build desktop backend for Intel macOS (x86_64) run: | source ./bin/activate-hermit rustup target add x86_64-apple-darwin - cargo build --release -p goose-server --target x86_64-apple-darwin + cargo build --release -p goose-cli --bin goose --target x86_64-apple-darwin @@ -95,12 +95,16 @@ jobs: # Check disk space after cleanup df -h - - name: Copy binaries into Electron folder + - name: Copy backend binary into Electron folder run: | - cp target/x86_64-apple-darwin/release/goosed ui/desktop/src/bin/goosed + 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/ - name: Cache pnpm dependencies - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ui/desktop/node_modules @@ -152,6 +156,10 @@ 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: | diff --git a/.github/workflows/bundle-desktop-linux.yml b/.github/workflows/bundle-desktop-linux.yml index dd1a88fcc9..ad3b84f194 100644 --- a/.github/workflows/bundle-desktop-linux.yml +++ b/.github/workflows/bundle-desktop-linux.yml @@ -124,7 +124,7 @@ jobs: with: key: linux-${{ matrix.build-on }}-${{ matrix.variant }} - - name: Build goosed binary + - name: Build desktop backend binary env: RUST_LOG: debug RUST_BACKTRACE: 1 @@ -137,19 +137,25 @@ jobs: FEATURE_ARGS=(--features vulkan) fi - cargo build --release --target ${TARGET} -p goose-server "${FEATURE_ARGS[@]}" + cargo build --release --target ${TARGET} -p goose-cli --bin goose "${FEATURE_ARGS[@]}" - - name: Copy binaries into Electron folder + - name: Copy backend binary into Electron folder run: | - echo "Copying binaries to ui/desktop/src/bin/" + echo "Copying backend binary to ui/desktop/src/bin/" export TARGET="x86_64-unknown-linux-gnu" mkdir -p ui/desktop/src/bin - cp target/$TARGET/release/goosed ui/desktop/src/bin/ - chmod +x ui/desktop/src/bin/goosed + rm -f ui/desktop/src/bin/goose + cp target/$TARGET/release/goose ui/desktop/src/bin/ + chmod +x ui/desktop/src/bin/goose 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@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ui/desktop/node_modules diff --git a/.github/workflows/bundle-desktop-windows.yml b/.github/workflows/bundle-desktop-windows.yml index f18af1025d..6ceb29b82f 100644 --- a/.github/workflows/bundle-desktop-windows.yml +++ b/.github/workflows/bundle-desktop-windows.yml @@ -61,7 +61,7 @@ jobs: run: npm install -g pnpm@10.30.3 - name: Cache node_modules - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | node_modules @@ -113,28 +113,33 @@ jobs: env: CUDA_COMPUTE_CAP: ${{ inputs.windows_variant == 'cuda' && '80' || '' }} run: | - 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 + $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 } else { - cargo build --release --target x86_64-pc-windows-msvc -p goose-server + cargo build --release --target x86_64-pc-windows-msvc -p goose-cli --bin goose } + $binaryPath = "./target/x86_64-pc-windows-msvc/release/goose.exe" # Verify build succeeded - if (-not (Test-Path "./target/x86_64-pc-windows-msvc/release/goosed.exe")) { - Write-Error "Windows binary not found." + if (-not (Test-Path $binaryPath)) { + Write-Error "Windows backend binary not found: $binaryPath" Get-ChildItem ./target/x86_64-pc-windows-msvc/release/ -ErrorAction SilentlyContinue exit 1 } - Write-Output "Windows binary found." - Get-Item ./target/x86_64-pc-windows-msvc/release/goosed.exe + Write-Output "Windows backend binary found." + Get-Item $binaryPath - name: Prepare Windows binary shell: bash run: | - if [ ! -f "./target/x86_64-pc-windows-msvc/release/goosed.exe" ]; then - echo "Windows binary not found." + BACKEND_BINARY="./target/x86_64-pc-windows-msvc/release/goose.exe" + + if [ ! -f "$BACKEND_BINARY" ]; then + echo "Windows backend binary not found: $BACKEND_BINARY" exit 1 fi @@ -142,13 +147,14 @@ jobs: rm -rf ./ui/desktop/src/bin mkdir -p ./ui/desktop/src/bin - echo "Copying Windows binary..." - cp -f ./target/x86_64-pc-windows-msvc/release/goosed.exe ./ui/desktop/src/bin/ + echo "Copying Windows backend binary..." + cp -f "$BACKEND_BINARY" ./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 - if [ -f "$file" ] && [ "$(basename "$file")" != "goosed.exe" ]; then + filename="$(basename "$file")" + if [ -f "$file" ] && [ "$filename" != "goose.exe" ]; then cp -f "$file" ./ui/desktop/src/bin/ fi done @@ -229,14 +235,14 @@ jobs: certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }} files: | ${{ github.workspace }}/dist-windows/Goose.exe - ${{ github.workspace }}/dist-windows/resources/bin/goosed.exe + ${{ github.workspace }}/dist-windows/resources/bin/goose.exe - name: Verify signed executables shell: pwsh run: | $files = @( "dist-windows/Goose.exe", - "dist-windows/resources/bin/goosed.exe" + "dist-windows/resources/bin/goose.exe" ) foreach ($file in $files) { Write-Output "Verifying signature: $file" diff --git a/.github/workflows/bundle-desktop.yml b/.github/workflows/bundle-desktop.yml index 4b0073198d..f431a34bc7 100644 --- a/.github/workflows/bundle-desktop.yml +++ b/.github/workflows/bundle-desktop.yml @@ -118,8 +118,10 @@ jobs: key: macos-deployment-target-12 # Build the project - - name: Build goosed - run: source ./bin/activate-hermit && cargo build --release -p goose-server + - name: Build desktop backend + run: | + source ./bin/activate-hermit + cargo build --release -p goose-cli --bin goose # Post-build cleanup to free space - name: Post-build cleanup @@ -134,12 +136,16 @@ jobs: # Check disk space after cleanup df -h - - name: Copy binaries into Electron folder + - name: Copy backend binary into Electron folder run: | - cp target/release/goosed ui/desktop/src/bin/goosed + 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/ - name: Cache pnpm dependencies - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ui/desktop/node_modules @@ -184,6 +190,10 @@ 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: | diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 58ac9fb5a8..f93599880c 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -67,7 +67,7 @@ jobs: path: download_cli.sh # ------------------------------------------------------------ - # 4) Bundle Desktop App (macOS only) - builds goosed and Electron app + # 4) Bundle Desktop App (macOS only) # ------------------------------------------------------------ bundle-desktop: needs: [prepare-version] @@ -80,7 +80,7 @@ jobs: signing: false # ------------------------------------------------------------ - # 5) Bundle Desktop App (macOS Intel) - builds goosed and Electron app + # 5) Bundle Desktop App (macOS Intel) # ------------------------------------------------------------ bundle-desktop-intel: needs: [prepare-version] @@ -93,7 +93,7 @@ jobs: signing: false # ------------------------------------------------------------ - # 6) Bundle Desktop App (Linux) - builds goosed and Electron app + # 6) Bundle Desktop App (Linux) # ------------------------------------------------------------ bundle-desktop-linux: needs: [prepare-version] @@ -102,7 +102,7 @@ jobs: version: ${{ needs.prepare-version.outputs.version }} # ------------------------------------------------------------ - # 6) Bundle Desktop App (Windows) - builds goosed and Electron app + # 6) Bundle Desktop App (Windows) # ------------------------------------------------------------ bundle-desktop-windows: needs: [prepare-version] @@ -138,7 +138,7 @@ jobs: merge-multiple: true - name: Attest build provenance - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-path: | goose-*.tar.bz2 diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index 9c2625b852..0e917b04e8 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -22,7 +22,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.0 # https://github.com/EmbarkStudios/cargo-deny-action v2.0.15 - uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6106534c79..1216340871 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: - name: Checkout Code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - name: Run cargo fmt run: cargo fmt --check @@ -55,7 +55,7 @@ jobs: - name: Checkout Code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - name: Install Dependencies run: | @@ -119,7 +119,7 @@ jobs: echo "msrv=$msrv" >> "$GITHUB_OUTPUT" echo "MSRV: $msrv" - - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 with: toolchain: ${{ steps.msrv.outputs.msrv }} @@ -145,7 +145,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 @@ -167,7 +167,7 @@ jobs: - name: Checkout Code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - name: Install Dependencies run: | @@ -183,12 +183,6 @@ 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 diff --git a/.github/workflows/create-release-branch.yaml b/.github/workflows/create-release-branch.yaml index cb92ae2c31..bca8171ebf 100644 --- a/.github/workflows/create-release-branch.yaml +++ b/.github/workflows/create-release-branch.yaml @@ -23,7 +23,7 @@ jobs: ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 0 - - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Extract version and base branch diff --git a/.github/workflows/create-version-bump-pr.yaml b/.github/workflows/create-version-bump-pr.yaml index 9f1403ff57..0e4d070ded 100644 --- a/.github/workflows/create-version-bump-pr.yaml +++ b/.github/workflows/create-version-bump-pr.yaml @@ -30,7 +30,7 @@ jobs: ref: main fetch-depth: 0 - - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: install dependencies @@ -85,7 +85,7 @@ jobs: **Please follow these steps:** - 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)). + 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. diff --git a/.github/workflows/deploy-docs-and-extensions.yml b/.github/workflows/deploy-docs-and-extensions.yml index 4eed0ff625..d5f4bf2952 100644 --- a/.github/workflows/deploy-docs-and-extensions.yml +++ b/.github/workflows/deploy-docs-and-extensions.yml @@ -32,7 +32,7 @@ jobs: node-version: 20 - name: Cache Node.js modules (documentation) - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ./documentation/node_modules key: ${{ runner.os }}-documentation-${{ hashFiles('./documentation/package-lock.json') }} diff --git a/.github/workflows/docs-update-cli-ref.yml b/.github/workflows/docs-update-cli-ref.yml index cdb4b42534..9ac5cfca7d 100644 --- a/.github/workflows/docs-update-cli-ref.yml +++ b/.github/workflows/docs-update-cli-ref.yml @@ -63,7 +63,7 @@ jobs: sudo apt-get install -y jq ripgrep - name: Set up Rust - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 with: toolchain: stable diff --git a/.github/workflows/docs-update-recipe-ref.yml b/.github/workflows/docs-update-recipe-ref.yml deleted file mode 100644 index e0c9e86a85..0000000000 --- a/.github/workflows/docs-update-recipe-ref.yml +++ /dev/null @@ -1,292 +0,0 @@ -# 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - 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 b75a77d9b6..8ef17abb30 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 server routes, run `just generate-openapi`. + - When changing ACP request/response types, run `just generate-acp-types`. - Your context degrades. The TODO is your memory. Update it after each step. Types: diff --git a/.github/workflows/goose-release-notes.yml b/.github/workflows/goose-release-notes.yml index 866a54cbbd..9a4ffd2c7e 100644 --- a/.github/workflows/goose-release-notes.yml +++ b/.github/workflows/goose-release-notes.yml @@ -15,6 +15,8 @@ on: - Release types: - completed + branches-ignore: + - "release/**" # Allow manual trigger for testing workflow_dispatch: @@ -76,7 +78,7 @@ jobs: generate-release-notes: name: Generate Release Notes runs-on: ubuntu-latest - # For workflow_run: only run if Release succeeded and tag is not 'stable' + # For workflow_run: release branch preflight builds are ignored by the trigger. # For workflow_dispatch: only run if tag is not 'stable' if: | (github.event_name == 'workflow_dispatch' && inputs.tag != 'stable') || diff --git a/.github/workflows/patch-release.yaml b/.github/workflows/patch-release.yaml index 7204206df7..b9936551ca 100644 --- a/.github/workflows/patch-release.yaml +++ b/.github/workflows/patch-release.yaml @@ -24,7 +24,7 @@ jobs: ref: ${{ inputs.target_branch }} fetch-depth: 0 - - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: install dependencies diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml index 405cb8d3ec..ae83ad2afe 100644 --- a/.github/workflows/pr-smoke-test.yml +++ b/.github/workflows/pr-smoke-test.yml @@ -55,7 +55,7 @@ jobs: with: ref: ${{ github.event.inputs.branch || github.ref }} - - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - name: Install Dependencies run: | @@ -67,7 +67,7 @@ jobs: - name: Build Binary for Smoke Tests run: | - cargo build --bin goose --bin goosed + cargo build --bin goose - name: Upload goose binary uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -76,13 +76,6 @@ jobs: path: target/debug/goose retention-days: 1 - - name: Upload goosed binary - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: goosed-binary - path: target/debug/goosed - retention-days: 1 - smoke-tests: name: Smoke Tests runs-on: ubuntu-latest @@ -253,39 +246,3 @@ 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - 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 f821be7aed..56f7bd2aae 100644 --- a/.github/workflows/pr-website-preview.yml +++ b/.github/workflows/pr-website-preview.yml @@ -20,6 +20,10 @@ 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 @@ -47,17 +51,16 @@ 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 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 @@ -76,6 +79,7 @@ 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 2214e66ea4..5bd90040c6 100644 --- a/.github/workflows/publish-ask-ai-bot.yml +++ b/.github/workflows/publish-ask-ai-bot.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to GitHub Container Registry uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 @@ -33,7 +33,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.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@f9f3042f7e2789586610d6e8b85c8f03e5195baf # pin@v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # pin@v7.3.0 with: context: . file: services/ask-ai-bot/Dockerfile diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index ebee5d8d32..c15177fa4a 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to GitHub Container Registry uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 @@ -37,7 +37,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.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@f9f3042f7e2789586610d6e8b85c8f03e5195baf # pin@v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # pin@v7.3.0 with: context: . push: true @@ -65,7 +65,7 @@ jobs: platforms: linux/amd64,linux/arm64 - name: Attest Docker image - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 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 d3a7e1a42d..1653b1dc30 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -32,7 +32,7 @@ jobs: always-auth: true - name: Setup pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: version: 10.30.3 @@ -164,7 +164,7 @@ jobs: always-auth: true - name: Setup pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: version: 10.30.3 diff --git a/.github/workflows/python-sdk-wheels.yml b/.github/workflows/python-sdk-wheels.yml new file mode 100644 index 0000000000..d01d4008c0 --- /dev/null +++ b/.github/workflows/python-sdk-wheels.yml @@ -0,0 +1,118 @@ +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/rebuild-skills-marketplace.yml b/.github/workflows/rebuild-skills-marketplace.yml index 2964d36f19..5aee55c5c0 100644 --- a/.github/workflows/rebuild-skills-marketplace.yml +++ b/.github/workflows/rebuild-skills-marketplace.yml @@ -53,7 +53,7 @@ jobs: node-version: 20 - name: Cache Node.js modules (documentation) - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ./documentation/node_modules key: ${{ runner.os }}-documentation-${{ hashFiles('./documentation/package-lock.json') }} diff --git a/.github/workflows/recipe-security-scanner.yml b/.github/workflows/recipe-security-scanner.yml index 7c4d597888..edcb4dfb7e 100644 --- a/.github/workflows/recipe-security-scanner.yml +++ b/.github/workflows/recipe-security-scanner.yml @@ -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@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Prune Docker caches if: steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ccf2bcfea7..5e4c140bd3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,10 @@ -# This workflow is main release, needs to be manually tagged & pushed. +# This workflow builds release artifacts for release branches and publishes tagged releases. on: push: paths-ignore: - "documentation/**" + branches: + - "release/*" tags: - "v1.*" @@ -15,6 +17,11 @@ 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 @@ -49,8 +56,8 @@ jobs: id-token: write contents: read with: - signing: true - environment: signing + signing: ${{ startsWith(github.ref, 'refs/tags/') }} + environment: ${{ startsWith(github.ref, 'refs/tags/') && 'signing' || '' }} secrets: inherit # ------------------------------------------------------------ @@ -62,8 +69,8 @@ jobs: id-token: write contents: read with: - signing: true - environment: signing + signing: ${{ startsWith(github.ref, 'refs/tags/') }} + environment: ${{ startsWith(github.ref, 'refs/tags/') && 'signing' || '' }} secrets: inherit # ------------------------------------------------------------ @@ -82,7 +89,7 @@ jobs: contents: read actions: read with: - signing: true + signing: ${{ startsWith(github.ref, 'refs/tags/') }} secrets: inherit bundle-desktop-windows-cuda: @@ -92,7 +99,7 @@ jobs: contents: read actions: read with: - signing: true + signing: ${{ startsWith(github.ref, 'refs/tags/') }} windows_variant: cuda secrets: inherit @@ -101,6 +108,7 @@ 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: @@ -108,7 +116,7 @@ jobs: id-token: write # Required for Sigstore OIDC signing attestations: write # Required for SLSA build provenance attestations steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Download all artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -116,10 +124,17 @@ jobs: 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@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-path: | goose-*.tar.bz2 @@ -129,7 +144,6 @@ jobs: *.deb *.rpm *.flatpak - latest-mac.yml download_cli.sh # Create/update the versioned release @@ -145,7 +159,6 @@ jobs: *.deb *.rpm *.flatpak - latest-mac.yml download_cli.sh allowUpdates: true omitBody: true @@ -166,8 +179,15 @@ jobs: *.deb *.rpm *.flatpak - latest-mac.yml download_cli.sh 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/stale.yml b/.github/workflows/stale.yml index dbe008a54e..35b79315df 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@997185467fa4f803885201cee163a9f38240193d # v10.1.1 + uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: # Authentication token with required permissions repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/update-release-pr.yaml b/.github/workflows/update-release-pr.yaml index 06f6508138..0c5e54a877 100644 --- a/.github/workflows/update-release-pr.yaml +++ b/.github/workflows/update-release-pr.yaml @@ -21,7 +21,7 @@ jobs: with: fetch-depth: 0 - - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Extract version from branch name diff --git a/.gitignore b/.gitignore index b82cf5c753..fb5d128862 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ tokenizer_files/ .DS_Store .idea .vscode +.zed/ *.log tmp/ diff --git a/AGENTS.md b/AGENTS.md index e0fda6d5e0..0f01fcde55 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 + openapi +just release-binary # release binary ``` ### Test @@ -33,8 +33,8 @@ 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 ``` @@ -44,12 +44,10 @@ 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 ``` @@ -65,7 +63,6 @@ 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 @@ -75,7 +72,7 @@ ui/desktop/ # Electron app - Error: Use anyhow::Result - Provider: Implement Provider trait see providers/base.rs - MCP: Extensions in crates/goose-mcp/ -- Server: Changes need just generate-openapi +- UI Desktop: Use ACP SDK types or local `src/types/*` types. Do not import generated OpenAPI types/client code from `ui/desktop/src/api` ## Code Quality @@ -107,7 +104,7 @@ remaining space for dynamic text. ## Never -- Never: Edit ui/desktop/openapi.json manually +- Never: Recreate `ui/desktop/src/api` or add `@hey-api/openapi-ts` to `ui/desktop` - 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 @@ -116,6 +113,5 @@ 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 4e1b9c4114..0b242a359f 100644 --- a/BUILDING_LINUX.md +++ b/BUILDING_LINUX.md @@ -59,13 +59,7 @@ cd goose Build Goose CLI: ```bash -cargo build --release -p goose-cli -``` - -Build Goose Server: - -```bash -cargo build --release -p goose-server +cargo build --release -p goose-cli --bin goose ``` This command should give you a list of possible packages in the @@ -80,9 +74,9 @@ cargo test -p cd ui/desktop pnpm install -# Copy the server binary to the expected location +# Copy the goose binary to the expected location mkdir -p src/bin -cp ../../target/release/goosed src/bin/ +cp ../../target/release/goose src/bin/ ``` ### 4. Build the Application @@ -143,10 +137,10 @@ cd /path/to/goose/ui/desktop/out/goose-linux-x64 ./goose 2>&1 | grep -v "GLib-GObject" | grep -v "browser_main_loop" ``` -#### 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/` +#### 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/` 3. Rebuilt the application: `pnpm run make` ### Distribution-Specific Notes @@ -177,7 +171,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-server` and copy the binary +1. **Backend changes**: Rebuild with `cargo build --release -p goose-cli --bin goose` 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 49e3dcd56e..ee36c03dc3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,16 +29,17 @@ It just means the change was too large for a first contribution. Start with some ### Issues -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. +If you spot a bug, 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 -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. +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 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. @@ -184,41 +185,27 @@ cd ui && pnpm install See #8757. -### Regenerating the OpenAPI schema - -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: - -``` -just generate-openapi -``` - -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 Goose server, run it from an IDE. The configuration will depend on the IDE. The command to run is: +To debug the external ACP backend, 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-server --bin goosed -- agent # or: `just run-server` +cargo run --package goose-cli --bin goose -- serve --platform desktop --host 127.0.0.1 --port 3000 ``` -The server listens on port `3000` by default; this can be changed by setting the -`GOOSE_PORT` environment variable. +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. -Once the server is running, start a UI and connect it to the server by running: +Once the backend is running, start a UI and connect it to the backend by running: ``` just debug-ui ``` -The UI connects to the server started in the IDE, allowing breakpoints -and stepping through the server code while interacting with the UI. +The UI connects to the backend started in the IDE, allowing breakpoints +and stepping through the backend code while interacting with the UI. ## Creating a fork diff --git a/CUSTOM_DISTROS.md b/CUSTOM_DISTROS.md index ad59da656e..74ace5fc5b 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-server (goosed) โ”‚ -โ”‚ REST API for all goose functionality โ”‚ +โ”‚ goose serve (ACP) โ”‚ +โ”‚ ACP HTTP/WebSocket server for custom clients โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ–ผ @@ -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-server` REST API | High | +| Build a new UI (web, mobile) | Integrate with `goose serve` over ACP | High | | Create guided workflows | Recipes (YAML-based task definitions) | Low | | Build complex multi-step workflows | Recipes with sub-recipes and subagents | Medium | @@ -304,40 +304,32 @@ export GOOSE_BUNDLE_NAME="InsightStream-goose" **Goal**: Create an entirely new frontend while leveraging goose's backend. -goose provides two integration options for building custom UIs: +goose provides two ACP transport options for building custom clients: -### Option 1: REST API (goose-server) +### Option 1: ACP HTTP/WebSocket (`goose serve`) -Use goose-server for HTTP-based integrations (web apps, simple clients): +Use `goose serve` for process-separated integrations such as web apps, desktop shells, and other clients: ```bash # Start the server -./target/release/goosed +GOOSE_SERVER__SECRET_KEY='a-long-random-secret' goose serve -# API available at http://localhost:3000 +# ACP endpoint available at http://localhost:3284/acp ``` -**Reference the OpenAPI spec** at `ui/desktop/openapi.json` for available endpoints: -- Session management -- Message streaming -- Extension control -- Configuration +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: -**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 +```text +ws://localhost:3284/acp?token=a-long-random-secret ``` -**Handle streaming responses** - goose uses Server-Sent Events (SSE) for real-time responses. +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. -### Option 2: Agent Client Protocol (ACP) +For the ACP protocol and client flow, see [Agent Client Protocol clients](documentation/docs/guides/acp-clients.md). -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. +### Option 2: Agent Client Protocol (ACP) over stdio + +For richer local integrations (IDEs and embedded agents), run goose as an ACP agent over stdio. ACP provides: - **Bidirectional communication**: Agents can request permissions, stream updates, and receive cancellations @@ -410,11 +402,6 @@ 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) @@ -655,10 +642,10 @@ prompt: | - find_patterns: Find similar features to model after ``` -The AI can then invoke these sub-recipes using the `subagent` tool: +Recipes that define `sub_recipes` get the Summon extension automatically. The AI can invoke sub-recipes through Summon's `delegate` tool: ``` -subagent(subrecipe: "find_files", parameters: {"search_term": "authentication"}) +delegate(source: "find_files", parameters: {"search_term": "authentication"}) ``` ### Subagents: Dynamic Task Delegation @@ -678,28 +665,27 @@ prompt: | To complete this task: 1. Spawn a subagent to analyze the frontend code: - subagent(instructions: "Analyze all React components in src/components/ - and list their props and state management patterns") + delegate(instructions: "Analyze all React components in src/components/ and list their props and state management patterns") 2. Spawn another subagent for the backend: - subagent(instructions: "Document all API endpoints in src/api/ - including their request/response schemas") + delegate(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 -Multiple subagent calls in the same message execute in parallel: +Use `async: true` to run delegates in parallel, then collect each result with `load(source: "")`: ```yaml prompt: | - Run these analyses in parallel by making all subagent calls at once: + Run these analyses in parallel: - subagent(instructions: "Count lines of code by language") - subagent(instructions: "Find all TODO comments") - subagent(instructions: "List external dependencies") + 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) + Use load(source: "") for each returned task id. Then combine the results into a codebase health report. ``` @@ -711,22 +697,18 @@ Customize model, provider, or behavior per subagent: prompt: | Use a faster model for simple tasks: - subagent( + delegate( instructions: "List all files modified in the last week", - settings: { - model: "gpt-4o-mini", - max_turns: 3 - } + model: "gpt-4o-mini", + max_turns: 3 ) Use the full model for complex analysis: - subagent( + delegate( instructions: "Review this code for security vulnerabilities", - settings: { - model: "claude-sonnet-4-20250514", - temperature: 0.1 - } + model: "claude-sonnet-4-20250514", + temperature: 0.1 ) ``` @@ -738,7 +720,7 @@ Limit which extensions a subagent can access: prompt: | Create a sandboxed subagent with only file reading capabilities: - subagent( + delegate( instructions: "Analyze the README files in this project", extensions: ["developer"] # Only developer extension, no network access ) @@ -819,7 +801,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 summary mode (default)** - Subagents return concise summaries; use `summary: false` only when you need full conversation history +5. **Use background delegation for independent work** - Pass `async: true` to `delegate`, then collect results with `load(source: "")` 6. **Handle failures gracefully** - Design workflows to continue even if one subagent fails ### Technical Details diff --git a/Cargo.lock b/Cargo.lock index 122aadde02..befb0adfcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,43 +40,58 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.11.1" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af62fb84df2af0f933d8f5fd78b843fa5eb0ec5a48fa1b528c41951d0bbe36c" +checksum = "16302d16c7531355db16593d99c38c8297db0c4653aa7dd80c3556bb17f4cd8c" dependencies = [ "agent-client-protocol-derive", "agent-client-protocol-schema", - "anyhow", + "async-process", + "blocking", "futures", "futures-concurrency", - "jsonrpcmsg", - "rmcp", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "schemars 1.2.1", "serde", "serde_json", - "thiserror 2.0.18", + "shell-words", + "tracing", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5ca63f112bd2459bcaf9eda0683b9ba95fc3b5e5fdd9036ca941c6a09345b1" +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", - "tokio-util", + "tower-http 0.7.0", "tracing", "uuid", ] -[[package]] -name = "agent-client-protocol-derive" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cabdc9d845d08ec7ed2d0c9de1ae4a1b198301407d55855261572761be90ec9f" -dependencies = [ - "quote", - "syn 2.0.117", -] - [[package]] name = "agent-client-protocol-schema" -version = "0.12.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49bae57dad1c28a362fbdcf7bab0583316a02b45a70792109fced55780a3b63c" +checksum = "ac542aba230234b1591ace7286a47c0514fe3efc3037d43296bde31ba7ee5728" dependencies = [ "anyhow", "derive_more", @@ -119,9 +134,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -202,9 +217,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "approx" @@ -217,22 +232,13 @@ dependencies = [ [[package]] name = "ar_archive_writer" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" 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" @@ -252,9 +258,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -267,9 +273,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "ascii" @@ -279,11 +285,11 @@ checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" [[package]] name = "askama" -version = "0.14.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" +checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc" dependencies = [ - "askama_derive", + "askama_macros", "itoa", "percent-encoding", "serde", @@ -292,31 +298,42 @@ dependencies = [ [[package]] name = "askama_derive" -version = "0.14.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" +checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738" dependencies = [ "askama_parser", "basic-toml", + "glob", "memchr", "proc-macro2", "quote", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "serde", "serde_derive", - "syn 2.0.117", + "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.14.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" +checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" dependencies = [ - "memchr", + "rustc-hash 2.1.3", "serde", "serde_derive", - "winnow 0.7.15", + "unicode-ident", + "winnow 1.0.3", ] [[package]] @@ -343,7 +360,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -355,7 +372,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -376,7 +393,19 @@ checksum = "2eb025ef00a6da925cf40870b9c8d008526b6004ece399cb0974209720f0b194" dependencies = [ "quote", "swc_macros_common", - "syn 2.0.117", + "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", ] [[package]] @@ -391,12 +420,77 @@ 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" @@ -416,9 +510,15 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] +[[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" @@ -427,7 +527,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -500,11 +600,11 @@ dependencies = [ "aws-sdk-ssooidc", "aws-sdk-sts", "aws-smithy-async", - "aws-smithy-http", + "aws-smithy-http 0.63.6", "aws-smithy-json", "aws-smithy-runtime", "aws-smithy-runtime-api", - "aws-smithy-schema", + "aws-smithy-schema 0.1.0", "aws-smithy-types", "aws-types", "bytes", @@ -533,9 +633,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -544,27 +644,28 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] name = "aws-runtime" -version = "1.7.4" +version = "1.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ed8e8c52d2dc2390ad9f15647fe663f71e9780b4262c190fbb823a32721566" +checksum = "6c9b9de216a988dd54b754a82a7660cfe14cee4f6782ae4524470972fa0ccb39" dependencies = [ "aws-credential-types", "aws-sigv4", "aws-smithy-async", "aws-smithy-eventstream", - "aws-smithy-http", + "aws-smithy-http 0.63.6", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -582,9 +683,9 @@ dependencies = [ [[package]] name = "aws-sdk-bedrockruntime" -version = "1.133.0" +version = "1.135.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76314880945928a4ee3956e92af451ef29ef04b734d008bb94b11158a60a1034" +checksum = "e74b780f2f36912bae71b4f4f8ed9a0a88832b4681a1add3caf5ca25dbc8ab2d" dependencies = [ "arc-swap", "aws-credential-types", @@ -592,9 +693,9 @@ dependencies = [ "aws-sigv4", "aws-smithy-async", "aws-smithy-eventstream", - "aws-smithy-http", + "aws-smithy-http 0.63.6", "aws-smithy-json", - "aws-smithy-observability", + "aws-smithy-observability 0.2.6", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -610,18 +711,18 @@ dependencies = [ [[package]] name = "aws-sdk-sagemakerruntime" -version = "1.104.0" +version = "1.105.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031fec5f68bdc840361ed09b98b6ef916cc9e49de0323bb4d584adad2cb87cc7" +checksum = "ab3b9e91619a2ffd88b195f71a0054f0a8d247ba4550306628796ba976d4611c" dependencies = [ "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", "aws-smithy-eventstream", - "aws-smithy-http", + "aws-smithy-http 0.63.6", "aws-smithy-json", - "aws-smithy-observability", + "aws-smithy-observability 0.2.6", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -636,17 +737,17 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.101.0" +version = "1.102.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b647baea49ff551960b904f905681e9b4765a6c4ea08631e89dc52d8bd3f5896" +checksum = "8c82b3ac19f1431854f7ace3a7531674633e286bfdde21976893bfee36fd493b" dependencies = [ "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http", + "aws-smithy-http 0.63.6", "aws-smithy-json", - "aws-smithy-observability", + "aws-smithy-observability 0.2.6", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -661,17 +762,17 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.103.0" +version = "1.104.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ae401c65ff288aa7873117fe535cd32b7b1bb0bc43751d28901a1d5f20636b9" +checksum = "321000d2b4c5519ee573f73167f612efd7329322d9b26969ad1979f0427f1913" dependencies = [ "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http", + "aws-smithy-http 0.63.6", "aws-smithy-json", - "aws-smithy-observability", + "aws-smithy-observability 0.2.6", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -686,17 +787,17 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.106.0" +version = "1.107.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c80de7bb7d03e9ca8c9fd7b489f20f3948d3f3be91a7953591347d238115408" +checksum = "3d0d328ba962af23ecfa3c9f23b98d3d35e325fa218d7f13d17a6bf522f8a560" dependencies = [ "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http", + "aws-smithy-http 0.63.6", "aws-smithy-json", - "aws-smithy-observability", + "aws-smithy-observability 0.2.6", "aws-smithy-query", "aws-smithy-runtime", "aws-smithy-runtime-api", @@ -718,7 +819,7 @@ checksum = "bae38512beae0ffee7010fc24e7a8a123c53efdfef42a61e80fda4882418dc71" dependencies = [ "aws-credential-types", "aws-smithy-eventstream", - "aws-smithy-http", + "aws-smithy-http 0.63.6", "aws-smithy-runtime-api", "aws-smithy-types", "bytes", @@ -735,9 +836,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.2.14" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" dependencies = [ "futures-util", "pin-project-lite", @@ -746,9 +847,9 @@ dependencies = [ [[package]] name = "aws-smithy-eventstream" -version = "0.60.20" +version = "0.60.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548" +checksum = "78d8391e65fcea47c586a22e1a41f173b38615b112b2c6b7a44e80cec3e6b706" dependencies = [ "aws-smithy-types", "bytes", @@ -777,6 +878,27 @@ dependencies = [ "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-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + [[package]] name = "aws-smithy-json" version = "0.62.7" @@ -784,7 +906,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa" dependencies = [ "aws-smithy-runtime-api", - "aws-smithy-schema", + "aws-smithy-schema 0.1.0", "aws-smithy-types", ] @@ -797,6 +919,15 @@ 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" @@ -809,15 +940,15 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.11.3" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e6f5caf6fea86f8c2206541ab5857cfcda9013426cdbe8fa0098b9e2d32182" +checksum = "bea94a9ff8464016338c851e24b472d7131c388c88898a502e781815b2ee6045" dependencies = [ "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-observability", + "aws-smithy-http 0.64.0", + "aws-smithy-observability 0.3.0", "aws-smithy-runtime-api", - "aws-smithy-schema", + "aws-smithy-schema 0.2.0", "aws-smithy-types", "bytes", "fastrand", @@ -834,9 +965,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9db177daa6ba8afb9ee1aefcf548c907abcf52065e394ee11a92780057fe0e8c" +checksum = "22ed1ebe6e0a95ea84570225f5a8208dec4b8f77e61a9b0d6f51773fcb4612f0" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api-macros", @@ -852,13 +983,13 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api-macros" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -873,10 +1004,21 @@ dependencies = [ ] [[package]] -name = "aws-smithy-types" -version = "1.5.0" +name = "aws-smithy-schema" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b42fcf341259d85ca10fac9a2f6448a8ec691c6955a18e45bc3b71a85fab85" +checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.2", +] + +[[package]] +name = "aws-smithy-types" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8" dependencies = [ "base64-simd", "bytes", @@ -916,7 +1058,7 @@ dependencies = [ "aws-credential-types", "aws-smithy-async", "aws-smithy-runtime-api", - "aws-smithy-schema", + "aws-smithy-schema 0.1.0", "aws-smithy-types", "rustc_version", "tracing", @@ -956,6 +1098,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -974,6 +1117,7 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -984,7 +1128,7 @@ checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -996,7 +1140,7 @@ dependencies = [ "arc-swap", "bytes", "either", - "fs-err 3.3.0", + "fs-err", "http 1.4.2", "http-body 1.0.1", "hyper", @@ -1099,7 +1243,7 @@ dependencies = [ "bincode", "bytesize", "clircle", - "console 0.16.3", + "console", "content_inspector", "encoding_rs", "flate2", @@ -1119,7 +1263,7 @@ dependencies = [ "serde_derive", "serde_with", "serde_yaml", - "syn 2.0.117", + "syn 2.0.118", "syntect", "terminal-colorsaurus", "thiserror 2.0.18", @@ -1153,33 +1297,13 @@ dependencies = [ "serde", ] -[[package]] -name = "bindgen" -version = "0.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" -dependencies = [ - "bitflags 2.11.1", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 1.1.0", - "shlex 1.3.0", - "syn 2.0.117", -] - [[package]] name = "bindgen" version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cexpr", "clang-sys", "itertools 0.13.0", @@ -1188,9 +1312,9 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "shlex 1.3.0", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1219,7 +1343,7 @@ dependencies = [ "biome_json_parser", "biome_json_syntax", "biome_rowan", - "bitflags 2.11.1", + "bitflags 2.13.0", "indexmap 1.9.3", "serde", "serde_json", @@ -1239,7 +1363,7 @@ dependencies = [ "biome_rowan", "biome_text_edit", "biome_text_size", - "bitflags 2.11.1", + "bitflags 2.13.0", "bpaf", "serde", "termcolor", @@ -1332,7 +1456,7 @@ dependencies = [ "biome_js_unicode_table", "biome_parser", "biome_rowan", - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if 1.0.4", "drop_bomb", "indexmap 1.9.3", @@ -1417,7 +1541,7 @@ dependencies = [ "biome_console", "biome_diagnostics", "biome_rowan", - "bitflags 2.11.1", + "bitflags 2.13.0", "drop_bomb", ] @@ -1498,19 +1622,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] -name = "bitcoin-io" -version = "0.1.4" +name = "bitcoin-consensus-encoding" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] [[package]] name = "bitcoin_hashes" -version = "0.14.1" +version = "0.14.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" dependencies = [ "bitcoin-io", - "hex-conservative", + "hex-conservative 0.2.2", "serde", ] @@ -1522,18 +1667,18 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -1572,9 +1717,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -1597,6 +1742,19 @@ 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" @@ -1619,7 +1777,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1635,7 +1793,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17d4f95e880cfd28c4ca5a006cf7f6af52b4bcb7b5866f573b2faa126fb7affb" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1655,14 +1813,14 @@ checksum = "2f7e98cee839b19076cb3ce1afdb62bb182e04ff5f71f70188827002fae91094" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -1671,9 +1829,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -1690,13 +1848,13 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] @@ -1731,7 +1889,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1748,15 +1906,15 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "bytes-str" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c60b5ce37e0b883c37eb89f79a1e26fbe9c1081945d024eee93e8d91a7e18b3" +checksum = "577d2bf5650f8554d5a372af5ac93535110a0fc75b3e702bb853369febf227c2" dependencies = [ "bytes", "serde", @@ -1805,27 +1963,28 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.2" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" dependencies = [ "serde_core", ] [[package]] name = "candle-core" -version = "0.10.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd9895436c1ba5dc1037a19935d084b838db066ff4e15ef7dded020b7c12a4a" +checksum = "5ecb245093b0f791b89d3420c3df9c6d49c60ab63ba54db896bf8a3baf486706" dependencies = [ "byteorder", "candle-kernels", "candle-metal-kernels", "candle-ug", - "cudarc 0.19.7", + "cudarc 0.19.8", "float8", "gemm 0.19.0", "half", + "libc", "libm", "memmap2", "num-traits", @@ -1835,28 +1994,30 @@ dependencies = [ "rand 0.9.4", "rand_distr", "rayon", - "safetensors 0.7.0", + "safetensors 0.8.0", "thiserror 2.0.18", "tokenizers 0.22.2", - "yoke 0.8.2", - "zip 7.2.0", + "yoke 0.8.3", + "zerocopy", + "zip 8.6.0", ] [[package]] name = "candle-kernels" -version = "0.10.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "742e2ac226b777134436e9e692f44e77c278b8a7abb1554dc10e44dc911b349f" +checksum = "67450168a281bbb195a14cc85cf164c7a12023a54a03409a3324f476346e0789" dependencies = [ "cudaforge", ] [[package]] name = "candle-metal-kernels" -version = "0.10.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b6b5a4cae6b4e1ab0efcee4dc05272d11b374a3d1ba121b3a961e36be54ab60" +checksum = "242e83c6acf639bb273c929d73c67a882bb4dd08a140f121096e19ba2f213d3e" dependencies = [ + "block2", "half", "objc2", "objc2-foundation", @@ -1868,9 +2029,9 @@ dependencies = [ [[package]] name = "candle-nn" -version = "0.10.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9317a09d6530b758990ed7f625ac69ff43653bc9ee28b0464644ad1169ada87" +checksum = "eaa10b6ccc365b33210ce404fbf45e60d3e0bdac1004463cf1052e6ee1c1739a" dependencies = [ "candle-core", "candle-metal-kernels", @@ -1879,21 +2040,21 @@ dependencies = [ "num-traits", "objc2-metal", "rayon", - "safetensors 0.7.0", + "safetensors 0.8.0", "serde", "thiserror 2.0.18", ] [[package]] name = "candle-transformers" -version = "0.10.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f59d08c89e9f4af9c464e2f3a8e16199e7cc601e6f34538c2cfbb42b623b1783" +checksum = "3bcbbf7ff00ff6fe2af22b93600195917fe90e90ff48424a140d1a926c44b1c1" dependencies = [ "byteorder", "candle-core", "candle-nn", - "fancy-regex 0.17.0", + "fancy-regex 0.18.0", "num-traits", "rand 0.9.4", "rayon", @@ -1905,9 +2066,9 @@ dependencies = [ [[package]] name = "candle-ug" -version = "0.10.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca0fc3167cbc99c8ec1be618cb620aa21dca95038f118c3579a79370e3dc5f77" +checksum = "257411c33abf7d898a31ac20d80813dfe96ff09e23a73039e22ab293aea9b871" dependencies = [ "ug", "ug-cuda", @@ -1933,23 +2094,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b4a6cae9efc04cc6cbb8faf338d2c497c165c83e74509cf4dbedea948bbf6e5" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "cargo-platform" -version = "0.1.9" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" dependencies = [ "serde", + "serde_core", ] [[package]] name = "cargo_metadata" -version = "0.19.2" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" dependencies = [ "camino", "cargo-platform", @@ -1979,14 +2141,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex 1.3.0", + "shlex 2.0.1", ] [[package]] @@ -2040,9 +2202,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if 1.0.4", "cpufeatures 0.3.0", @@ -2132,9 +2294,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.5" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" dependencies = [ "clap", ] @@ -2158,7 +2320,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2179,12 +2341,12 @@ dependencies = [ [[package]] name = "cliclack" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4529f45438fc25ca048b242d5c48e2d3ce9a521e2a5a9123d9737d8520b030dd" +checksum = "9fd2a475e9dff35ddebd459b0523aab732572581f7fe53133edd20b3330a0ce6" dependencies = [ - "console 0.16.3", - "indicatif 0.18.4", + "console", + "indicatif", "once_cell", "strsim", "textwrap", @@ -2221,9 +2383,9 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "cmpv2" @@ -2232,8 +2394,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" dependencies = [ "crmf", - "der 0.7.10", - "spki 0.7.3", + "der", + "spki", "x509-cert", ] @@ -2244,8 +2406,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" dependencies = [ "const-oid 0.9.6", - "der 0.7.10", - "spki 0.7.3", + "der", + "spki", "x509-cert", ] @@ -2305,9 +2467,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if 1.0.4", @@ -2347,36 +2509,11 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "config" -version = "0.15.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f316c6237b2d38be61949ecd15268a4c6ca32570079394a2444d9ce2c72a72d8" -dependencies = [ - "pathdiff", - "serde_core", - "toml 1.1.2+spec-1.1.0", - "winnow 1.0.3", -] - [[package]] name = "console" -version = "0.15.11" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width 0.2.2", - "windows-sys 0.59.0", -] - -[[package]] -name = "console" -version = "0.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", @@ -2580,8 +2717,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" dependencies = [ "cms", - "der 0.7.10", - "spki 0.7.3", + "der", + "spki", "x509-cert", ] @@ -2598,18 +2735,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -2617,27 +2754,27 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -2705,7 +2842,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2763,9 +2900,9 @@ dependencies = [ [[package]] name = "cudarc" -version = "0.19.7" +version = "0.19.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cea5f10a99e025c1b44ae2354c2d8326b25ddbd0baf76bde8e55cfd4018a2cc" +checksum = "42310153e06cf4cd532901f7096beb27504d681736a29ee90728ae4e2d93b2a8" dependencies = [ "float8", "half", @@ -2796,7 +2933,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2846,7 +2983,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2860,7 +2997,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2873,7 +3010,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2884,7 +3021,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2895,7 +3032,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2906,7 +3043,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2945,9 +3082,9 @@ checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" [[package]] name = "dbus" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" dependencies = [ "libc", "libdbus-sys", @@ -2993,6 +3130,37 @@ 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" @@ -3158,7 +3326,7 @@ checksum = "1c28ede88783f14cd8aae46ca89f230c226b40e4a81ab06fa52ed72af84beb2f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3225,7 +3393,7 @@ dependencies = [ "stringcase", "strum 0.27.2", "strum_macros 0.27.2", - "syn 2.0.117", + "syn 2.0.118", "syn-match", "thiserror 2.0.18", ] @@ -3356,16 +3524,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "der" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" -dependencies = [ - "const-oid 0.10.2", - "zeroize", -] - [[package]] name = "der-parser" version = "10.0.0" @@ -3388,7 +3546,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3397,21 +3555,9 @@ 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" @@ -3430,7 +3576,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3440,7 +3586,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3462,7 +3608,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.118", "unicode-xid", ] @@ -3490,7 +3636,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", @@ -3505,7 +3651,7 @@ dependencies = [ "diplomat_core", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3525,7 +3671,7 @@ dependencies = [ "serde", "smallvec", "strck", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3555,19 +3701,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3625,7 +3771,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33175ddb7a6d418589cab2966bd14a710b3b1139459d3d5ca9edf783c4833f4c" dependencies = [ "num-bigint", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "swc_atoms", "swc_common", "swc_ecma_ast", @@ -3707,12 +3853,12 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der 0.7.10", + "der", "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", - "spki 0.7.3", + "spki", ] [[package]] @@ -3730,7 +3876,7 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8 0.10.2", + "pkcs8", "signature", ] @@ -3771,7 +3917,7 @@ dependencies = [ "group", "hkdf", "pem-rfc7468", - "pkcs8 0.10.2", + "pkcs8", "rand_core 0.6.4", "sec1", "subtle", @@ -3817,7 +3963,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3837,7 +3983,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3915,6 +4061,16 @@ 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" @@ -3958,6 +4114,17 @@ 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" @@ -4057,9 +4224,9 @@ dependencies = [ [[package]] name = "fluent-uri" -version = "0.3.2" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" dependencies = [ "borrow-or-share", "ref-cast", @@ -4122,7 +4289,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4172,23 +4339,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5ff35a391aef949120a0340d690269b3d9f63460a6106e99bd07b961f345ea9" dependencies = [ "swc_macros_common", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "fs-err" -version = "2.11.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" -dependencies = [ - "autocfg", -] - -[[package]] -name = "fs-err" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" dependencies = [ "autocfg", "tokio", @@ -4319,7 +4477,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4495,7 +4653,7 @@ dependencies = [ "num-traits", "once_cell", "paste", - "pulp 0.22.2", + "pulp 0.22.3", "raw-cpuid", "rayon", "seq-macro", @@ -4648,17 +4806,15 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if 1.0.4", "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", "wasm-bindgen", ] @@ -4705,7 +4861,7 @@ checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4752,19 +4908,22 @@ dependencies = [ [[package]] name = "goose" -version = "1.39.0" +version = "1.42.0" dependencies = [ "agent-client-protocol", + "agent-client-protocol-http", "agent-client-protocol-schema", "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", @@ -4778,21 +4937,19 @@ dependencies = [ "dirs", "dotenvy", "dtor 1.0.5", - "encoding_rs", "env-lock", "etcetera 0.11.0", - "fs-err 3.3.0", + "fs-err", "fs2", "futures", "gethostname", "goose-acp-macros", + "goose-download-manager", "goose-mcp", "goose-providers", "goose-sdk-types", "goose-test-support", - "hf-hub 1.0.0-rc.1", "http 1.4.2", - "http-body-util", "icu_calendar", "icu_locale", "ignore", @@ -4805,19 +4962,15 @@ dependencies = [ "jsonwebtoken", "keyring", "libc", - "llama-cpp-2", - "llama-cpp-sys-2", "lru 0.18.0", "minijinja", - "mlx-lm", - "mlx-lm-utils", - "mlx-rs", "mockall", "nanoid", "nostr", "nostr-sdk", "oauth2", "once_cell", + "openssl", "opentelemetry 0.32.0", "opentelemetry-appender-tracing", "opentelemetry-otlp 0.32.0", @@ -4825,10 +4978,12 @@ dependencies = [ "opentelemetry_sdk 0.32.1", "pastey", "pctx_code_mode", + "pem", "process-wrap", "pulldown-cmark", - "rand 0.10.1", + "rand 0.10.2", "rayon", + "rcgen", "regex", "reqwest 0.13.4", "rmcp", @@ -4883,28 +5038,28 @@ dependencies = [ "uuid", "v_htmlescape", "webbrowser", - "which 8.0.3", + "which 8.0.4", "winapi", "wiremock", - "zip 8.6.0", ] [[package]] name = "goose-acp-macros" -version = "1.39.0" +version = "1.42.0" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "goose-cli" -version = "1.39.0" +version = "1.42.0" dependencies = [ "anstream", "anyhow", "async-trait", "axum", + "axum-server", "base64 0.22.1", "bat", "bzip2", @@ -4915,7 +5070,7 @@ dependencies = [ "clap_mangen", "cliclack", "comfy-table", - "console 0.16.3", + "console", "dotenvy", "env-lock", "etcetera 0.11.0", @@ -4923,9 +5078,9 @@ dependencies = [ "goose", "goose-mcp", "goose-providers", - "indicatif 0.18.4", + "indicatif", "open", - "rand 0.10.1", + "rand 0.10.2", "regex", "reqwest 0.13.4", "rmcp", @@ -4951,9 +5106,58 @@ 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.39.0" +version = "1.42.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -4982,8 +5186,8 @@ dependencies = [ ] [[package]] -name = "goose-providers" -version = "1.39.0" +name = "goose-provider-types" +version = "0.1.0-alpha.0" dependencies = [ "anyhow", "async-stream", @@ -4993,14 +5197,10 @@ dependencies = [ "env-lock", "futures", "once_cell", - "pem", - "pkcs1", - "pkcs8 0.11.0", - "rand 0.10.1", + "rand 0.10.2", "regex", "reqwest 0.13.4", "rmcp", - "sec1", "serde", "serde_json", "strum 0.28.0", @@ -5009,21 +5209,56 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", - "tokio-util", "tracing", "unicode-normalization", - "url", "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 = "1.39.0" +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", @@ -5032,7 +5267,7 @@ dependencies = [ [[package]] name = "goose-sdk-types" -version = "1.39.0" +version = "0.1.0-alpha.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", @@ -5041,56 +5276,9 @@ dependencies = [ "serde_json", ] -[[package]] -name = "goose-server" -version = "1.39.0" -dependencies = [ - "anyhow", - "aws-lc-rs", - "axum", - "axum-server", - "base64 0.22.1", - "bytes", - "chrono", - "clap", - "config", - "fs2", - "futures", - "goose", - "goose-mcp", - "goose-providers", - "hex", - "http 1.4.2", - "openssl", - "pem", - "rand 0.10.1", - "rcgen", - "reqwest 0.13.4", - "rmcp", - "rustls", - "serde", - "serde_json", - "serde_path_to_error", - "serde_yaml", - "socket2", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tokio-tungstenite 0.29.0", - "tokio-util", - "tower", - "tower-http 0.7.0", - "tracing", - "tracing-subscriber", - "url", - "utoipa 4.2.3", - "uuid", - "winreg", -] - [[package]] name = "goose-test" -version = "1.39.0" +version = "1.42.0" dependencies = [ "clap", "serde_json", @@ -5098,7 +5286,7 @@ dependencies = [ [[package]] name = "goose-test-support" -version = "1.39.0" +version = "1.42.0" dependencies = [ "axum", "env-lock", @@ -5130,9 +5318,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -5164,9 +5352,9 @@ dependencies = [ [[package]] name = "handlebars" -version = "6.4.1" +version = "6.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43ccdfe15a81ab0a8af639e90254227c9a46afd9c5f5b6ec7efaa345c4b0f00" +checksum = "f26569a2763497b7bd3fbd19374b774ea6038c5293678771259cd534d49740ff" dependencies = [ "derive_builder", "log", @@ -5267,22 +5455,12 @@ dependencies = [ ] [[package]] -name = "hf-hub" -version = "0.4.3" +name = "hex-conservative" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" +checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" dependencies = [ - "dirs", - "http 1.4.2", - "indicatif 0.17.11", - "libc", - "log", - "rand 0.9.4", - "serde", - "serde_json", - "thiserror 2.0.18", - "ureq", - "windows-sys 0.60.2", + "arrayvec", ] [[package]] @@ -5382,14 +5560,14 @@ dependencies = [ [[package]] name = "hstr" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1b94e40256e78ddd4e30490aa931bec17e65e9413a6ad11f64ec67815da9323" +checksum = "83bb87e4b300d73412f6dcc7022ee7741452b51b155c2b06e5994d0770c2dbe2" dependencies = [ "hashbrown 0.14.5", "new_debug_unreachable", "once_cell", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "serde", "triomphe", ] @@ -5478,24 +5656,24 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -5526,7 +5704,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] @@ -5637,7 +5815,7 @@ checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", - "yoke 0.8.2", + "yoke 0.8.3", "zerofrom", "zerovec", ] @@ -5728,18 +5906,12 @@ dependencies = [ "serde", "stable_deref_trait", "writeable", - "yoke 0.8.2", + "yoke 0.8.3", "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" @@ -5775,9 +5947,9 @@ checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" [[package]] name = "ignore" -version = "0.4.26" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +checksum = "fe112b004901c62c2faa11f4f75e9864e0cc5af8da71c9115d184a3aa888749f" dependencies = [ "crossbeam-deque", "globset", @@ -5891,24 +6063,11 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.17.11" +version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ - "console 0.15.11", - "number_prefix", - "portable-atomic", - "unicode-width 0.2.2", - "web-time", -] - -[[package]] -name = "indicatif" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" -dependencies = [ - "console 0.16.3", + "console", "portable-atomic", "unicode-width 0.2.2", "unit-prefix", @@ -5981,7 +6140,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6032,10 +6191,11 @@ checksum = "2ceaf4c6c48465bead8cb6a0b7c4ee0c86ecbb31239032b9c66ab9a08d2f3ee1" [[package]] name = "jiff" -version = "0.2.27" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "392c70591e8749fe235ddaf513e6f58b26bce3dcc16524cecc8936f75afa161e" +checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" dependencies = [ + "defmt", "jiff-static", "jiff-tzdb-platform", "log", @@ -6047,20 +6207,20 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.27" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b605b0c050d845fc355bb11eb3f9a8deddc218ea60c76e61aa1f2adfb2c96a" +checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "jiff-tzdb" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" +checksum = "6142247df1a93c2b3587402a19710be3e6e942f1581a1702e76408f2c21d6590" [[package]] name = "jiff-tzdb-platform" @@ -6098,7 +6258,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6117,16 +6277,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] @@ -6141,13 +6301,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if 1.0.4", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -6160,42 +6319,42 @@ dependencies = [ "serde_json", ] -[[package]] -name = "jsonrpcmsg" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d833a15225c779251e13929203518c2ff26e2fe0f322d584b213f4f4dad37bd" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "jsonschema" -version = "0.30.0" +version = "0.46.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1b46a0365a611fbf1d2143104dcf910aada96fafd295bab16c60b802bf6fa1d" +checksum = "f0a699d3e77675e6aa4bfffe3b907c8b5f7ed3241f9965bffb25475ad4b08d05" dependencies = [ "ahash", - "base64 0.22.1", "bytecount", + "data-encoding", "email_address", - "fancy-regex 0.14.0", + "fancy-regex 0.18.0", "fraction", + "getrandom 0.3.4", "idna", "itoa", + "jsonschema-regex", "num-cmp", "num-traits", - "once_cell", "percent-encoding", "referencing", "regex", - "regex-syntax", "serde", "serde_json", + "unicode-general-category", "uuid-simd", ] +[[package]] +name = "jsonschema-regex" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbd1086b01b9349fd4ef9a07433965af64c8ce8159abe633a189e4ff817bd13" +dependencies = [ + "regex-syntax", +] + [[package]] name = "jsonwebtoken" version = "10.4.0" @@ -6275,7 +6434,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6287,12 +6446,6 @@ 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" @@ -6349,14 +6502,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "libc", "plain", - "redox_syscall 0.7.5", + "redox_syscall 0.9.0", ] [[package]] @@ -6382,7 +6535,7 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "libc", ] @@ -6430,7 +6583,7 @@ version = "0.1.146" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b291e4bc2d10c43cd8dec16d49b6104cb3cb125f596ec380a753a5db1d965dd" dependencies = [ - "bindgen 0.72.1", + "bindgen", "cc", "cmake", "find_cuda_helper", @@ -6449,29 +6602,29 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lopdf" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67513274c50a2b51e5f75d9e682fcf4ab064a8a9c9ae2c3c59309084882bb24d" +checksum = "25aab26d99567469098e64a02f42679f8965c6401263eefa31d8f2dcc37a221c" dependencies = [ "aes", - "bitflags 2.11.1", + "bitflags 2.13.0", "cbc", "ecb", "encoding_rs", "flate2", - "getrandom 0.4.2", + "getrandom 0.4.3", "indexmap 2.14.0", "itoa", "log", "md-5", "nom 8.0.0", - "rand 0.10.1", + "rand 0.10.2", "rangemap", "sha2 0.10.9", "stringprep", @@ -6568,15 +6721,15 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", "stable_deref_trait", @@ -6603,7 +6756,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block", "core-graphics-types", "foreign-types 0.5.0", @@ -6612,6 +6765,12 @@ 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" @@ -6630,9 +6789,9 @@ dependencies = [ [[package]] name = "minijinja" -version = "2.20.0" +version = "2.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2929e494b2280e1e18959bb2e121da03347ae896896fdfaceaab43c88a02803f" +checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39" dependencies = [ "memo-map", "serde", @@ -6640,9 +6799,9 @@ dependencies = [ [[package]] name = "minijinja-contrib" -version = "2.20.0" +version = "2.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99df5123c54391e2a228014c1dbbd85a3dab08a25e776c810526f2f47542b3de" +checksum = "85342f6fac0be8ccd5bd00d9066be538f34f393f577b75d81b17c8398a6b43bb" dependencies = [ "minijinja", "serde", @@ -6666,113 +6825,20 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] -[[package]] -name = "mlx-internal-macros" -version = "0.25.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a7c4444d624bf6b93db5cc22ebff4fdfa13593fd56154fe33b1f302a557c2c6" -dependencies = [ - "darling 0.21.3", - "itertools 0.14.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "mlx-lm" -version = "0.0.1" -source = "git+https://github.com/jh-block/mlx-lm#4cb8572045f55f170eb6e41005a3f8a730673c42" -dependencies = [ - "anyhow", - "clap", - "idna_adapter", - "minijinja", - "mlx-lm-utils", - "mlx-macros", - "mlx-rs", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokenizers 0.22.2", -] - -[[package]] -name = "mlx-lm-utils" -version = "0.0.1" -source = "git+https://github.com/jh-block/mlx-lm#4cb8572045f55f170eb6e41005a3f8a730673c42" -dependencies = [ - "minijinja", - "minijinja-contrib", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokenizers 0.22.2", -] - -[[package]] -name = "mlx-macros" -version = "0.25.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a819ee8b4434690572b6feb9c3ef0b6e90137e4190b340cf00150703b410aaf9" -dependencies = [ - "darling 0.21.3", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "mlx-rs" -version = "0.25.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a0f592c5839b0237b3072530b1d3c503923579a2009ee0761df3edd1b1f27b" -dependencies = [ - "bytemuck", - "dyn-clone", - "half", - "itertools 0.14.0", - "libc", - "mach-sys", - "mlx-internal-macros", - "mlx-macros", - "mlx-sys", - "num-complex", - "num-traits", - "num_enum", - "parking_lot", - "paste", - "safetensors 0.6.2", - "smallvec", - "strum 0.27.2", - "thiserror 2.0.18", -] - -[[package]] -name = "mlx-sys" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e3bc3880111918b2d5018f845d48fd995f9901f16efc81d1fcfd2f4210b8219" -dependencies = [ - "bindgen 0.70.1", - "cc", - "cmake", -] - [[package]] name = "mockall" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b" +checksum = "1a6ceddfe3ce334925e96bf420fdb2dcee5bed6c632a168ece622676dadeaf8a" dependencies = [ "cfg-if 1.0.4", "downcast", @@ -6784,14 +6850,14 @@ dependencies = [ [[package]] name = "mockall_derive" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf" +checksum = "9cfe16fbe8a314aeec0b861ac24e60b1e123e97634bab045475b9d6a18416fd8" dependencies = [ "cfg-if 1.0.4", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6819,7 +6885,7 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6897,7 +6963,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if 1.0.4", "cfg_aliases", "libc", @@ -6955,9 +7021,9 @@ dependencies = [ [[package]] name = "nostr" -version = "0.44.3" +version = "0.44.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d8f0fe13526800300a36bf3b7c5f752e62e32ab81c74a8e5caa2865708625a" +checksum = "98cf5d15d70d1f8f4059e5f79923ac15891eb691d2843d01191e0585fb064d70" dependencies = [ "base64 0.22.1", "bech32", @@ -7064,9 +7130,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -7120,7 +7186,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7134,20 +7200,19 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] [[package]] name = "num-modular" -version = "0.6.1" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" +checksum = "fc41a1374056e9672221567958a66c16be12d0e2c1b408761e14d901c237d5e0" [[package]] name = "num-order" @@ -7208,15 +7273,9 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] -[[package]] -name = "number_prefix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" - [[package]] name = "oauth2" version = "5.0.0" @@ -7261,7 +7320,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-core-graphics", "objc2-foundation", @@ -7273,7 +7332,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "dispatch2", "objc2", ] @@ -7284,7 +7343,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "dispatch2", "objc2", "objc2-core-foundation", @@ -7303,7 +7362,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "libc", "objc2", @@ -7326,7 +7385,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", ] @@ -7337,7 +7396,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "dispatch2", "objc2", @@ -7396,7 +7455,7 @@ version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "libc", "once_cell", "onig_sys", @@ -7420,22 +7479,21 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.3.5" +version = "5.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a" dependencies = [ "is-wsl", "libc", - "pathdiff", ] [[package]] name = "openssl" -version = "0.10.80" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if 1.0.4", "foreign-types 0.3.2", "libc", @@ -7451,7 +7509,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7462,18 +7520,18 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" -version = "300.6.0+3.6.2" +version = "300.6.1+3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.116" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -7967,9 +8025,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" dependencies = [ "memchr", "ucd-trie", @@ -7977,9 +8035,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" dependencies = [ "pest", "pest_generator", @@ -7987,25 +8045,24 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "pest_meta" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" dependencies = [ "pest", - "sha2 0.10.9", ] [[package]] @@ -8047,7 +8104,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8085,7 +8142,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8100,15 +8157,26 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der 0.7.10", - "pkcs8 0.10.2", - "spki 0.7.3", + "der", + "pkcs8", + "spki", ] [[package]] @@ -8117,18 +8185,8 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der 0.7.10", - "spki 0.7.3", -] - -[[package]] -name = "pkcs8" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" -dependencies = [ - "der 0.8.0", - "spki 0.8.0", + "der", + "spki", ] [[package]] @@ -8145,13 +8203,13 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml 0.39.4", + "quick-xml 0.41.0", "serde", "time", ] @@ -8175,13 +8233,27 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "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" @@ -8277,7 +8349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8347,9 +8419,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -8357,15 +8429,15 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8400,7 +8472,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "memchr", "unicase", ] @@ -8421,9 +8493,9 @@ dependencies = [ [[package]] name = "pulp" -version = "0.22.2" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e205bb30d5b916c55e584c22201771bcf2bad9aabd5d4127f38387140c38632" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" dependencies = [ "bytemuck", "cfg-if 1.0.4", @@ -8438,15 +8510,15 @@ dependencies = [ [[package]] name = "pulp-wasm-simd-flag" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40e24eee682d89fb193496edf918a7f407d30175b2e785fe057e4392dfd182e0" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" [[package]] name = "pxfm" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "quick-error" @@ -8476,25 +8548,25 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "rustls", "socket2", "thiserror 2.0.18", @@ -8505,17 +8577,18 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "slab", @@ -8527,23 +8600,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -8599,12 +8672,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.0", - "getrandom 0.4.2", + "chacha20 0.10.1", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -8662,6 +8735,15 @@ 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" @@ -8674,7 +8756,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -8716,6 +8798,7 @@ checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" dependencies = [ "aws-lc-rs", "pem", + "ring", "rustls-pki-types", "time", "x509-parser", @@ -8743,16 +8826,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] name = "redox_syscall" -version = "0.7.5" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -8783,18 +8866,21 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "referencing" -version = "0.30.0" +version = "0.46.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8eff4fa778b5c2a57e85c5f2fe3a709c52f0e60d23146e2151cbef5893f420e" +checksum = "0fbf332a2f81899f6836f22c03da73dae8a664c32e3016b84692c23cddadc95d" dependencies = [ "ahash", "fluent-uri", - "once_cell", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "itoa", + "micromap", "parking_lot", "percent-encoding", "serde_json", @@ -8875,7 +8961,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] @@ -8988,9 +9074,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" +checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" dependencies = [ "async-trait", "base64 0.22.1", @@ -9006,7 +9092,7 @@ dependencies = [ "pastey", "pin-project-lite", "process-wrap", - "rand 0.10.1", + "rand 0.10.2", "reqwest 0.13.4", "rmcp-macros", "schemars 1.2.1", @@ -9025,15 +9111,15 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" +checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5" dependencies = [ "darling 0.23.0", "proc-macro2", "quote", "serde_json", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9054,10 +9140,10 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8 0.10.2", + "pkcs8", "rand_core 0.6.4", "signature", - "spki 0.7.3", + "spki", "subtle", "zeroize", ] @@ -9086,9 +9172,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -9114,7 +9200,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -9127,7 +9213,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -9136,12 +9222,11 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", - "log", "once_cell", "ring", "rustls-pki-types", @@ -9152,9 +9237,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -9164,9 +9249,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -9213,17 +9298,17 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rustyline" -version = "18.0.0" +version = "18.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a990b25f351b25139ddc7f21ee3f6f56f86d6846b74ac8fad3a719a287cd4a0" +checksum = "53f6a737db68eb1a8ccff86b584b2fc13eca6a7bb6f78ebc7c529547e3ab9684" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if 1.0.4", "clipboard-win", "home", @@ -9256,6 +9341,99 @@ 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" @@ -9278,13 +9456,15 @@ dependencies = [ [[package]] name = "safetensors" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5" +checksum = "79b079b829cb27a1c3c374341345ed2e8b2c0c839034522cee576c140bd7f846" dependencies = [ "hashbrown 0.16.1", + "libc", "serde", "serde_json", + "tempfile", ] [[package]] @@ -9362,7 +9542,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9374,7 +9554,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9406,7 +9586,7 @@ checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9428,9 +9608,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", - "der 0.7.10", + "der", "generic-array", - "pkcs8 0.10.2", + "pkcs8", "subtle", "zeroize", ] @@ -9461,7 +9641,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -9474,7 +9654,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -9544,7 +9724,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9555,7 +9735,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9611,7 +9791,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9651,9 +9831,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", "bs58", @@ -9671,14 +9851,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9713,7 +9893,7 @@ checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9819,9 +9999,9 @@ dependencies = [ [[package]] name = "sigstore-bundle" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94ba33ef96bafd16f5dfc954113c37a689dcf721816d11680667626b1ebb0496" +checksum = "18b51b097b7e9d898efad51e1031c855ce4008b4866d0c38ac8b0fac5745a84c" dependencies = [ "base64 0.22.1", "hex", @@ -9836,21 +10016,21 @@ dependencies = [ [[package]] name = "sigstore-crypto" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ecc48bf9facc710e772b3e7ab6a3ebc81c34be208a6ebf6c872d0ccbb8b52f" +checksum = "1210ea5dd0dd07b1466449a6c5c79000f8ca10ac30b4e8f64031c3bed4c5c662" dependencies = [ "aws-lc-rs", "base64 0.22.1", "const-oid 0.9.6", - "der 0.7.10", + "der", "digest 0.10.7", "pem", "rand_core 0.9.5", "sha2 0.10.9", "signature", "sigstore-types", - "spki 0.7.3", + "spki", "thiserror 2.0.18", "tracing", "x509-cert", @@ -9858,9 +10038,9 @@ dependencies = [ [[package]] name = "sigstore-merkle" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f6fc5c295b5b9fa929e38acc53b29c8ed9ba3ee93e5fa87e7daa44072f0b26e" +checksum = "9ae98f8a270885497dbe84e6dae10fa29066df0388f62980923728e38d844716" dependencies = [ "base64 0.22.1", "hex", @@ -9871,9 +10051,9 @@ dependencies = [ [[package]] name = "sigstore-rekor" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ece0d614b93c94e2ddab5c2142959396ce5a5f1d315435779e8ce0f9da250f5d" +checksum = "4e1c153f4f89f7570d0f625f660cadcb1b2fba72583f378d2d2b308c752ac45f" dependencies = [ "base64 0.22.1", "hex", @@ -9889,9 +10069,9 @@ dependencies = [ [[package]] name = "sigstore-trust-root" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a91dbe81d236075c568926b5540d86dbc0e272cd0c6cfc5523e35d5200e71cf0" +checksum = "ded6afc37295a10dba6569e033dd15f85f04f00980bfcf2c64009bc8ed279985" dependencies = [ "base64 0.22.1", "hex", @@ -9908,16 +10088,16 @@ dependencies = [ [[package]] name = "sigstore-tsa" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a04c841cdc52fc5da6284d6aa1fbf93f6b65d71437b2cc04751d117eac8aa33" +checksum = "a88505b71600a791e52bed3777ed9cfeefd4b03dee6575d38c5542ae80e2bbdc" dependencies = [ "aws-lc-rs", "base64 0.22.1", "cmpv2", "cms", "const-oid 0.9.6", - "der 0.7.10", + "der", "hex", "jiff", "rand 0.9.4", @@ -9934,9 +10114,9 @@ dependencies = [ [[package]] name = "sigstore-tuf" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556727ed8c836bcfee6a151a3201e8605af30ef0e01dd9021a2fd43c3f5269f4" +checksum = "dfc7cced094bd306ef561312cfffa3ea05ac7eea4a62cfa81680c07a10addbc0" dependencies = [ "globset", "hex", @@ -9956,9 +10136,9 @@ dependencies = [ [[package]] name = "sigstore-types" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a48661f5c7a4f7496de80865e5b258adac0bc514164e6c944b93b65ba5de334b" +checksum = "56f034e86c3b8d91b32608c83f4a18939eed32c6ca08d17d2f52b88be365aac6" dependencies = [ "base64 0.22.1", "hex", @@ -9970,9 +10150,9 @@ dependencies = [ [[package]] name = "sigstore-verify" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c4fed99a3609ebb236284b781674d8c45beea93bbc823ef2facd2261b3801c7" +checksum = "f4720f300d093ecac6dcd75d4498fd1b2d9133600e679837bb9d944a4229eda7" dependencies = [ "base64 0.22.1", "cms", @@ -10062,9 +10242,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] @@ -10082,9 +10262,9 @@ dependencies = [ [[package]] name = "smawk" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" [[package]] name = "smithy-transport-reqwest" @@ -10109,17 +10289,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "socks" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" -dependencies = [ - "byteorder", - "libc", - "winapi", -] - [[package]] name = "sourcemap" version = "9.3.2" @@ -10131,7 +10300,7 @@ dependencies = [ "data-encoding", "debugid", "if_chain", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "serde", "serde_json", "unicode-id-start", @@ -10154,17 +10323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der 0.7.10", -] - -[[package]] -name = "spki" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" -dependencies = [ - "base64ct", - "der 0.8.0", + "der", ] [[package]] @@ -10246,7 +10405,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -10269,7 +10428,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.117", + "syn 2.0.118", "tokio", "url", ] @@ -10282,7 +10441,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.11.1", + "bitflags 2.13.0", "byteorder", "bytes", "chrono", @@ -10325,7 +10484,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.11.1", + "bitflags 2.13.0", "byteorder", "chrono", "crc", @@ -10382,9 +10541,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" +checksum = "39f24a9b78c40b90817bbcd1821c74ddfd74916aadd29403d001532a9195532d" dependencies = [ "bytes", "futures-util", @@ -10457,7 +10616,7 @@ checksum = "ae36a4951ca7bd1cfd991c241584a9824a70f6aff1e7d4f693fb3f2465e4030e" dependencies = [ "quote", "swc_macros_common", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -10510,7 +10669,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -10522,7 +10681,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -10540,7 +10699,7 @@ dependencies = [ "allocator-api2", "bumpalo", "hashbrown 0.14.5", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", ] [[package]] @@ -10569,7 +10728,7 @@ dependencies = [ "new_debug_unreachable", "num-bigint", "once_cell", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "serde", "siphasher 0.3.11", "swc_atoms", @@ -10604,7 +10763,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -10613,12 +10772,12 @@ version = "18.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a573a0c72850dec8d4d8085f152d5778af35a2520c3093b242d2d1d50776da7c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "is-macro", "num-bigint", "once_cell", "phf 0.11.3", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "serde", "string_enum", "swc_atoms", @@ -10639,7 +10798,7 @@ dependencies = [ "num-bigint", "once_cell", "regex", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "ryu-js", "serde", "swc_allocator", @@ -10659,7 +10818,7 @@ checksum = "e276dc62c0a2625a560397827989c82a93fd545fcf6f7faec0935a82cc4ddbb8" dependencies = [ "proc-macro2", "swc_macros_common", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -10668,10 +10827,10 @@ version = "26.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e82f7747e052c6ff6e111fa4adeb14e33b46ee6e94fe5ef717601f651db48fc" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "either", "num-bigint", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "seq-macro", "serde", "smallvec", @@ -10692,7 +10851,7 @@ checksum = "fbcababb48f0d46587a0a854b2c577eb3a56fa99687de558338021e93cd2c8f5" dependencies = [ "anyhow", "pathdiff", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "serde", "swc_atoms", "swc_common", @@ -10705,11 +10864,11 @@ version = "27.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f1a51af1a92cd4904c073b293e491bbc0918400a45d58227b34c961dd6f52d7" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "either", "num-bigint", "phf 0.11.3", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "seq-macro", "serde", "smartstring", @@ -10731,7 +10890,7 @@ dependencies = [ "once_cell", "par-core", "phf 0.11.3", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "serde", "swc_atoms", "swc_common", @@ -10764,7 +10923,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -10774,7 +10933,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2d7748d4112c87ce1885260035e4a43cebfe7661a40174b7d77a0a04760a257" dependencies = [ "either", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "serde", "swc_atoms", "swc_common", @@ -10795,7 +10954,7 @@ dependencies = [ "bytes-str", "indexmap 2.14.0", "once_cell", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "serde", "sha1", "string_enum", @@ -10816,7 +10975,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4408800fdeb541fabf3659db622189a0aeb386f57b6103f9294ff19dfde4f7b0" dependencies = [ "bytes-str", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "serde", "swc_atoms", "swc_common", @@ -10837,7 +10996,7 @@ dependencies = [ "num_cpus", "once_cell", "par-core", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "ryu-js", "swc_atoms", "swc_common", @@ -10869,7 +11028,7 @@ checksum = "c16ce73424a6316e95e09065ba6a207eba7765496fed113702278b7711d4b632" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -10880,7 +11039,7 @@ checksum = "aae1efbaa74943dc5ad2a2fb16cbd78b77d7e4d63188f3c5b4df2b4dcd2faaae" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -10895,7 +11054,7 @@ dependencies = [ "data-encoding", "debugid", "if_chain", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "serde", "serde_json", "unicode-id-start", @@ -11088,9 +11247,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -11105,7 +11264,7 @@ checksum = "54b8f0a9004d6aafa6a588602a1119e6cdaacec9921aa1605383e6e7d6258fd6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -11125,7 +11284,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -11173,7 +11332,7 @@ checksum = "181f22127402abcf8ee5c83ccd5b408933fec36a6095cf82cda545634692657e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -11182,7 +11341,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "byteorder", "enum-as-inner", "libc", @@ -11210,7 +11369,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -11248,7 +11407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -11346,7 +11505,7 @@ dependencies = [ "cfg-if 1.0.4", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -11357,7 +11516,7 @@ checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "test-case-core", ] @@ -11381,12 +11540,6 @@ 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" @@ -11413,7 +11566,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -11424,7 +11577,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -11479,17 +11632,16 @@ dependencies = [ "fancy-regex 0.17.0", "lazy_static", "regex", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -11499,15 +11651,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -11569,7 +11721,7 @@ checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -11580,13 +11732,11 @@ checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" dependencies = [ "ahash", "aho-corasick", - "compact_str 0.9.0", + "compact_str 0.9.1", "dary_heap", "derive_builder", "esaxx-rs", "getrandom 0.3.4", - "hf-hub 0.4.3", - "indicatif 0.18.4", "itertools 0.14.0", "log", "macro_rules_attribute", @@ -11614,7 +11764,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44e5bea67576e04b6ff8564c5d9e09c2ef0cf476502245f2f120e497769d3112" dependencies = [ "ahash", - "compact_str 0.9.0", + "compact_str 0.9.1", "daachorse", "dary_heap", "derive_builder", @@ -11681,7 +11831,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -11712,7 +11862,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a129d95275ebf4c493ec53bf0f8cd95f5ac161bc4f381700809a54f595d4470" dependencies = [ "pin-project-lite", - "rand 0.10.1", + "rand 0.10.2", "tokio", ] @@ -11728,9 +11878,9 @@ dependencies = [ [[package]] name = "tokio-socks" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" dependencies = [ "either", "futures-util", @@ -11773,13 +11923,7 @@ 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", ] @@ -11819,10 +11963,12 @@ 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", ] @@ -11937,7 +12083,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "futures-core", "futures-util", @@ -11959,7 +12105,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "http 1.4.2", "percent-encoding", @@ -12013,7 +12159,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -12098,9 +12244,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.26.9" +version = "0.26.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dab76d0b724ba557954125188cf0633a1ca43199ced82d95c7b9c32cc3de1f3" +checksum = "3c343ed63e3f5c64d1acdecb5d2c13d4e169cb5fde0052106ebaa6c6f27f9e55" dependencies = [ "cc", "regex", @@ -12208,9 +12354,9 @@ dependencies = [ [[package]] name = "triomphe" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" dependencies = [ "serde", "stable_deref_trait", @@ -12258,10 +12404,7 @@ dependencies = [ "http 1.4.2", "httparse", "log", - "native-tls", "rand 0.9.4", - "rustls", - "rustls-pki-types", "sha1", "thiserror 2.0.18", ] @@ -12280,9 +12423,9 @@ checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "typewit" @@ -12346,12 +12489,11 @@ dependencies = [ [[package]] name = "umya-spreadsheet" -version = "2.3.3" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "408c7e039c96ec1d517a1111ade7fadab889f32c096dac691a1e3b8018c3e39a" +checksum = "caf31e59909bf0fed6fc9df1d24bed0f135518eb2af05a330284f81ffcfb0513" dependencies = [ "aes", - "ahash", "base64 0.22.1", "byteorder", "cbc", @@ -12359,18 +12501,20 @@ dependencies = [ "chrono", "encoding_rs", "fancy-regex 0.14.0", - "getrandom 0.2.17", "hmac 0.12.1", "html_parser", "imagesize", - "lazy_static", + "jiff", "md-5", + "num-traits", + "paste", + "phf 0.11.3", "quick-xml 0.37.5", - "regex", + "rand 0.8.6", + "rgb", "sha2 0.10.9", - "thin-vec", "thousands", - "zip 2.4.2", + "zip 8.6.0", ] [[package]] @@ -12391,6 +12535,12 @@ 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" @@ -12435,9 +12585,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -12465,9 +12615,9 @@ checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" [[package]] name = "uniffi" -version = "0.31.1" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc5f2297ee5b893405bed1a6929faec4713a061df158ecf5198089f23910d470" +checksum = "a782a48d72cfd7a2d65cfc7c691dbf5375c43104b3c195f7eccc716dcc3540c8" dependencies = [ "anyhow", "camino", @@ -12481,15 +12631,15 @@ dependencies = [ [[package]] name = "uniffi_bindgen" -version = "0.31.1" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bc0c60a9607e7ab77a2ad47ec5530178015014839db25af7512447d2238016c" +checksum = "533b0312c73e3b54eb78a4b257ceae390962dd4767995778309a74644643f9ac" dependencies = [ "anyhow", "askama", "camino", "cargo_metadata", - "fs-err 2.11.0", + "fs-err", "glob", "goblin", "heck", @@ -12498,7 +12648,7 @@ dependencies = [ "serde", "tempfile", "textwrap", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "uniffi_internal_macros", "uniffi_meta", "uniffi_pipeline", @@ -12507,9 +12657,9 @@ dependencies = [ [[package]] name = "uniffi_core" -version = "0.31.1" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77baf5d539fe2e1ad6805e942dbc5dbdeb2b83eb5f2b3a6535d422ca4b02a12f" +checksum = "8e32e261c5b0dfaba6488f536e71957dddd6b1a498ac7eb791bee56b60a086be" dependencies = [ "anyhow", "bytes", @@ -12519,39 +12669,39 @@ dependencies = [ [[package]] name = "uniffi_internal_macros" -version = "0.31.1" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4b42137524f4be6400fcaca9d02c1d4ecb6ad917e4013c0b93235526d8396e5" +checksum = "84ae78069a5e6772ef694fd5bdb628532c88d2c2f0e7142bf6a384636eadb1af" dependencies = [ "anyhow", "indexmap 2.14.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "uniffi_macros" -version = "0.31.1" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9273ec45330d8fe9a3701b7b983cea7a4e218503359831967cb95d26b873561" +checksum = "330be6770532e86320df31f54c70bb0be67588594e8e77fa56e9083a3fed5d0d" dependencies = [ "camino", - "fs-err 2.11.0", + "fs-err", "once_cell", "proc-macro2", "quote", "serde", - "syn 2.0.117", - "toml 0.9.12+spec-1.1.0", + "syn 2.0.118", + "toml 1.1.2+spec-1.1.0", "uniffi_meta", ] [[package]] name = "uniffi_meta" -version = "0.31.1" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "431d2f443e7828a6c29d188de98b6771a6491ee98bba2d4372643bf93f988a18" +checksum = "78de021f5547e56ab16c665a49d67d4fd3d31e77422f7739a2e9359d328cd9e7" dependencies = [ "anyhow", "siphasher 1.0.3", @@ -12561,9 +12711,9 @@ dependencies = [ [[package]] name = "uniffi_pipeline" -version = "0.31.1" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "761ef74f6175e15603d0424cc5f98854c5baccfe7bf4ccb08e5816f9ab8af689" +checksum = "3f8201bb1907ed8a42d80e11cbc25c8a033e7a31c3cff1d911f56eedb81d4948" dependencies = [ "anyhow", "heck", @@ -12574,9 +12724,9 @@ dependencies = [ [[package]] name = "uniffi_udl" -version = "0.31.1" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68773ec0e1c067b6505a73bbf6a5782f31a7f9209333a0df97b87565c46bf370" +checksum = "a6e57996bc58009cc29bf04845d627ae313c2547b87171c1c349d6c51a1656c0" dependencies = [ "anyhow", "textwrap", @@ -12618,25 +12768,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "once_cell", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "socks", - "url", - "webpki-roots 0.26.11", -] - [[package]] name = "url" version = "2.5.8" @@ -12707,8 +12838,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "regex", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -12719,16 +12849,16 @@ checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "uuid" -version = "1.23.3" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -12740,7 +12870,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" dependencies = [ "outref", - "uuid", "vsimd", ] @@ -12757,8 +12886,8 @@ version = "145.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f84468f393984251db025e944544590a8fb429d5088b78102bd72f09232f0da" dependencies = [ - "bindgen 0.72.1", - "bitflags 2.11.1", + "bindgen", + "bitflags 2.13.0", "fslock", "gzip-header", "home", @@ -12843,20 +12972,11 @@ dependencies = [ [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -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 = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -12876,9 +12996,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if 1.0.4", "once_cell", @@ -12889,9 +13009,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -12899,9 +13019,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -12909,48 +13029,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" 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" @@ -12974,23 +13072,11 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -13024,9 +13110,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] @@ -13037,14 +13123,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] @@ -13090,9 +13176,9 @@ dependencies = [ [[package]] name = "which" -version = "8.0.3" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c789537cf2f7f55be8e6192f92e464174ee55f91af622777f7f1ceb0dbccd03e" +checksum = "48d7cd18d4acb58fb3cdfe9ea54e6cd96a4e7d4cc45c56338b236e82dad47248" dependencies = [ "libc", ] @@ -13226,7 +13312,7 @@ checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -13237,7 +13323,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -13248,7 +13334,7 @@ checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -13259,7 +13345,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -13561,9 +13647,6 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] [[package]] name = "winnow" @@ -13574,16 +13657,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" -dependencies = [ - "cfg-if 1.0.4", - "windows-sys 0.61.2", -] - [[package]] name = "winsafe" version = "0.0.19" @@ -13613,100 +13686,12 @@ 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" @@ -13746,10 +13731,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ "const-oid 0.9.6", - "der 0.7.10", + "der", "sha1", "signature", - "spki 0.7.3", + "spki", "tls_codec", ] @@ -13766,6 +13751,7 @@ dependencies = [ "lazy_static", "nom 7.1.3", "oid-registry", + "ring", "rusticata-macros", "thiserror 2.0.18", "time", @@ -13779,7 +13765,7 @@ checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" dependencies = [ "cmpv2", "cms", - "der 0.7.10", + "der", ] [[package]] @@ -13799,7 +13785,7 @@ dependencies = [ "hyper", "lazy_static", "more-asserts", - "rand 0.10.1", + "rand 0.10.2", "redb", "reqwest 0.13.4", "reqwest-middleware", @@ -13836,13 +13822,13 @@ dependencies = [ "csv", "futures", "futures-util", - "getrandom 0.4.2", + "getrandom 0.4.3", "heapify", "itertools 0.14.0", "lazy_static", "lz4_flex", "more-asserts", - "rand 0.10.1", + "rand 0.10.2", "regex", "safe-transmute", "serde", @@ -13873,7 +13859,7 @@ dependencies = [ "itertools 0.14.0", "lazy_static", "more-asserts", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_json", "sha2 0.10.9", @@ -13913,7 +13899,7 @@ dependencies = [ "more-asserts", "oneshot", "pin-project", - "rand 0.10.1", + "rand 0.10.2", "reqwest 0.13.4", "serde", "serde_json", @@ -13971,9 +13957,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive 0.8.2", @@ -13988,7 +13974,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -14000,28 +13986,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -14041,28 +14027,28 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -14072,7 +14058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", - "yoke 0.8.2", + "yoke 0.8.3", "zerofrom", "zerovec", ] @@ -14084,7 +14070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "serde", - "yoke 0.8.2", + "yoke 0.8.3", "zerofrom", "zerovec-derive", ] @@ -14097,7 +14083,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -14112,35 +14098,6 @@ 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" @@ -14157,9 +14114,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" +checksum = "5431d5661c32445236631278f27946e444ddafe4684cac70b185272d4f9c52d5" [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index d0022e7f57..3e25ab5025 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,8 +8,8 @@ resolver = "2" [workspace.package] edition = "2021" -version = "1.39.0" -rust-version = "1.91.1" +version = "1.42.0" +rust-version = "1.94.1" authors = ["AAIF "] license = "Apache-2.0" repository = "https://github.com/aaif-goose/goose" @@ -21,8 +21,9 @@ string_slice = "warn" [workspace.dependencies] rmcp = { version = "1.4", default-features = false, features = ["schemars", "auth"] } -agent-client-protocol-schema = { version = "0.12", default-features = false, features = ["unstable"] } -agent-client-protocol = { version = "0.11", default-features = false } +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"] } 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 } @@ -30,8 +31,8 @@ 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.10", default-features = false } -candle-nn = { version = "0.10", default-features = false } +candle-core = { version = "0.11", default-features = false } +candle-nn = { version = "0.11", 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 } @@ -83,7 +84,7 @@ url = { version = "2.5.4", default-features = false, features = ["std"] } opentelemetry = { version = "0.32", default-features = false } opentelemetry_sdk = { version = "0.32", default-features = false, features = ["metrics"] } opentelemetry-http = { version = "0.32", default-features = false, features = ["internal-logs", "reqwest"] } -opentelemetry-otlp = { version = "0.32", default-features = false, features = ["http-proto", "internal-logs", "logs", "metrics", "reqwest-blocking-client", "trace"] } +opentelemetry-otlp = { version = "0.32", default-features = false, features = ["http-proto", "internal-logs", "logs", "metrics", "reqwest-client", "trace"] } opentelemetry-appender-tracing = { version = "0.32", default-features = false, features = ["experimental_span_attributes"] } opentelemetry-stdout = { version = "0.32", default-features = false, features = ["trace", "metrics", "logs"] } tracing-opentelemetry = { version = "0.33", default-features = false, features = ["metrics"] } diff --git a/Justfile b/Justfile index 56f0fe3302..be971a398c 100644 --- a/Justfile +++ b/Justfile @@ -13,18 +13,14 @@ 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 + cargo build --release -p goose-cli --bin goose @just copy-binary - @echo "Generating OpenAPI schema..." - cargo run -p goose-server --bin generate_schema # Build Windows executable on a Windows host [unix] @@ -34,7 +30,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-server; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; Write-Host "Windows executable created at ./target/x86_64-pc-windows-msvc/release/goosed.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-cli --bin goose; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; Write-Host "Windows executable created at ./target/x86_64-pc-windows-msvc/release/goose.exe"' # Build for Intel Mac release-intel: @@ -43,14 +39,7 @@ release-intel: @just copy-binary-intel copy-binary BUILD_MODE="release": - @if [ -f ./target/{{BUILD_MODE}}/goosed ]; then \ - echo "Copying goosed binary from target/{{BUILD_MODE}}..."; \ - rm -f ./ui/desktop/src/bin/goosed; \ - cp -p ./target/{{BUILD_MODE}}/goosed ./ui/desktop/src/bin/; \ - else \ - echo "Binary not found in target/{{BUILD_MODE}}"; \ - exit 1; \ - fi + @rm -f ./ui/desktop/src/bin/goosed @if [ -f ./target/{{BUILD_MODE}}/goose ]; then \ echo "Copying goose CLI binary from target/{{BUILD_MODE}}..."; \ rm -f ./ui/desktop/src/bin/goose; \ @@ -62,14 +51,7 @@ copy-binary BUILD_MODE="release": # Copy binary command for Intel build copy-binary-intel: - @if [ -f ./target/x86_64-apple-darwin/release/goosed ]; then \ - echo "Copying Intel goosed binary to ui/desktop/src/bin with permissions preserved..."; \ - rm -f ./ui/desktop/src/bin/goosed; \ - cp -p ./target/x86_64-apple-darwin/release/goosed ./ui/desktop/src/bin/; \ - else \ - echo "Intel release binary not found."; \ - exit 1; \ - fi + @rm -f ./ui/desktop/src/bin/goosed @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; \ @@ -87,10 +69,11 @@ copy-binary-windows: [windows] copy-binary-windows: - @powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'if (Test-Path ./target/x86_64-pc-windows-msvc/release/goosed.exe) { \ + @powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'if (Test-Path ./target/x86_64-pc-windows-msvc/release/goose.exe) { \ Write-Host "Copying Windows binary to ui/desktop/src/bin..."; \ New-Item -ItemType Directory -Force "./ui/desktop/src/bin" | Out-Null; \ - Copy-Item -Path "./target/x86_64-pc-windows-msvc/release/goosed.exe" -Destination "./ui/desktop/src/bin/" -Force; \ + 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; \ } else { \ Write-Host "Windows binary not found." -ForegroundColor Red; \ exit 1; \ @@ -116,7 +99,7 @@ run-ui-only: cd ui/desktop && pnpm install && pnpm run start-gui debug-ui: - @echo "๐Ÿš€ Starting goose frontend in external backend mode" + @echo "๐Ÿš€ Starting goose frontend in external ACP backend mode" cd ui/desktop && \ export GOOSE_EXTERNAL_BACKEND=true && \ export GOOSE_SERVER__SECRET_KEY="${GOOSE_SERVER__SECRET_KEY:-test}" && \ @@ -161,19 +144,8 @@ run-docs: # Run server run-server: - @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 + @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 # Check if generated ACP schema and TypeScript types are up-to-date check-acp-schema: generate-acp-types @@ -318,7 +290,6 @@ 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: @@ -333,14 +304,10 @@ prepare-release version: Cargo.lock \ ui/desktop/package.json \ ui/pnpm-lock.yaml \ - ui/desktop/openapi.json \ - crates/goose-providers/src/canonical/data/canonical_models.json \ - crates/goose-providers/src/canonical/data/provider_metadata.json + crates/goose-provider-types/src/canonical/data/canonical_models.json \ + crates/goose-provider-types/src/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" @@ -375,7 +342,6 @@ 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 @@ -404,6 +370,7 @@ 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 e6c4f05608..f6f0522594 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -> **๐Ÿฆ† 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 @@ -16,8 +14,12 @@ _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 d25d7c00bc..cb87c076c6 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -1,134 +1,23 @@ # goose Release Manual Testing Checklist -## Version: {{VERSION}} - -### Identify the high risk changes in this Release +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. +## 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 -## Regression Testing +goose run --recipe goose-self-test.yaml -Make a copy of this document for each version and check off as steps are verified. +## Have goose produce a test plan -### Provider Testing +Open the release candidate desktop app and have goose produce a test plan by pointing it at this PR. Use a prompt like -- [ ] 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 +> 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. -### 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:** _____ +goose will produce a plan. Follow this plan to finish testing. diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index e50ee6d8a8..e47f98bf55 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -62,8 +62,9 @@ urlencoding = { workspace = true } clap_complete = { version = "4", default-features = false } comfy-table = { version = "7", default-features = false } sha2 = { workspace = true } -sigstore-verify = { version = "0.9", default-features = false, optional = true } +sigstore-verify = { version = "0.10", 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] @@ -99,14 +100,18 @@ 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", diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 3694127a4e..427dd80351 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -51,6 +51,22 @@ fn generate_serve_secret_key() -> String { ) } +#[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 { @@ -75,7 +91,7 @@ pub struct Identifier { alias = "id", value_name = "SESSION_ID", help = "Session ID (e.g., '20250921_143022')", - long_help = "Specify a session ID directly. When used with --resume, will resume this specific session if it exists." + long_help = "Specify a session ID to resume. Requires --resume." )] pub session_id: Option, @@ -392,7 +408,9 @@ async fn get_or_create_session_id( let resolved_id = if resume { let Some(id) = identifier else { - let sessions = session_manager.list_sessions().await?; + let sessions = session_manager + .list_sessions_by_types(&[SessionType::User]) + .await?; let session_id = sessions .first() .map(|s| s.id.clone()) @@ -580,11 +598,9 @@ 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, }, @@ -833,6 +849,18 @@ 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", @@ -842,6 +870,20 @@ 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 @@ -874,6 +916,15 @@ 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, @@ -1326,7 +1377,21 @@ async fn handle_mcp_command(server: McpCommand) -> Result<()> { Ok(()) } -async fn handle_serve_command(host: String, port: u16, builtins: Vec) -> Result<()> { +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; use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig}; use goose::acp::transport::create_router; use goose::config::paths::Paths; @@ -1334,6 +1399,18 @@ async fn handle_serve_command(host: String, port: u16, builtins: Vec) -> 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; + let builtins = if builtins.is_empty() { vec!["developer".to_string()] } else { @@ -1356,32 +1433,93 @@ async fn handle_serve_command(host: String, port: u16, builtins: Vec) -> builtins, data_dir: Paths::data_dir(), config_dir: Paths::config_dir(), - goose_platform: GoosePlatform::GooseCli, + goose_platform: platform.into(), additional_source_roots, - scheduler: None, })); let env_secret = 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 { - warn!( - "{GOOSE_SERVER_SECRET_KEY_ENV} is not set; the ACP endpoint will accept unauthenticated connections" + 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); + 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(); let addr: SocketAddr = format!("{}:{}", host, port).parse()?; - info!("Starting ACP server on {}", addr); + 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); - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve( - listener, - router.into_make_service_with_connect_info::(), - ) - .await?; + #[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?; + } Ok(()) } @@ -1466,6 +1604,7 @@ async fn handle_interactive_session( identifier: Option, resume: bool, fork: bool, + edit: bool, history: bool, session_opts: SessionOptions, extension_opts: ExtensionOptions, @@ -1505,12 +1644,31 @@ 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 fork { - if let Some(id) = session_id { + if edit || fork { + if let Some(ref id) = session_id { let session_manager = SessionManager::instance(); - let original = session_manager.get_session(&id, false).await?; - let copied = session_manager.copy_session(&id, original.name).await?; - session_id = Some(copied.id); + 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?; + } } } @@ -1881,6 +2039,8 @@ 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(); + match command { LocalModelsCommand::Search { query, limit } => { println!("Searching HuggingFace for '{}'...", query); @@ -2072,8 +2232,27 @@ pub async fn cli() -> anyhow::Result<()> { Some(Command::Serve { host, port, + tls, + tls_cert_path, + tls_key_path, + platform, builtins, - }) => handle_serve_command(host, port, builtins).await, + dangerously_unauthenticated, + allowed_origins, + }) => { + handle_serve_command(ServeCommandArgs { + host, + port, + tls, + tls_cert_path, + tls_key_path, + platform, + builtins, + dangerously_unauthenticated, + allowed_origins, + }) + .await + } Some(Command::Session { command: Some(cmd), .. }) => handle_session_subcommand(cmd).await, @@ -2082,6 +2261,7 @@ pub async fn cli() -> anyhow::Result<()> { identifier, resume, fork, + edit, history, session_opts, extension_opts, @@ -2090,6 +2270,7 @@ pub async fn cli() -> anyhow::Result<()> { identifier, resume, fork, + edit, history, session_opts, extension_opts, @@ -2268,6 +2449,35 @@ mod tests { } } + #[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([ diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index cf1dd5f04b..96f1574b65 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -28,11 +28,22 @@ use goose::session::SessionType; use goose_providers::thinking::ThinkingEffort; use serde_json::Value; use std::collections::HashMap; -use std::io::IsTerminal; +use std::io::{IsTerminal, Write}; // 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() { @@ -42,6 +53,7 @@ pub async fn handle_configure() -> anyhow::Result<()> { ); } + let _cursor_restore = CursorRestoreGuard; let config = Config::global(); if !config.exists() { @@ -339,8 +351,7 @@ async fn handle_oauth_configuration(provider_name: &str, key_name: &str) -> anyh )); // Create a temporary provider instance to handle OAuth - let temp_model = goose::model_config::model_config_from_user_config(provider_name, "temp")?; - match create(provider_name, temp_model, Vec::new()).await { + match create(provider_name, Vec::new()).await { Ok(provider) => match provider.configure_oauth().await { Ok(_) => { let _ = cliclack::log::success("OAuth authentication completed successfully!"); @@ -761,13 +772,11 @@ pub async fn configure_provider_dialog() -> anyhow::Result { let spin = spinner(); spin.start("Attempting to fetch supported models..."); - let temp_model_config = goose::model_config::model_config_from_user_config( - provider_name, - &provider_meta.default_model, - )?; - let temp_provider = create(provider_name, temp_model_config, Vec::new()).await?; + let temp_provider = create(provider_name, Vec::new()).await?; let models_res = retry_operation(&RetryConfig::default(), || async { - temp_provider.fetch_recommended_models().await + temp_provider + .fetch_recommended_models(goose::model_config::global_toolshim()) + .await }) .await; spin.stop(style("Model fetch complete").green()); @@ -792,9 +801,7 @@ 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) - .map(|c| c.is_reasoning_model()) - .unwrap_or(false), + Err(_) => goose_providers::model::ModelConfig::new(&model).is_reasoning_model(), }; if supports_thinking { @@ -1618,8 +1625,10 @@ pub async fn configure_tool_permissions_dialog() -> anyhow::Result<()> { } let extensions = extension_config.into_iter().collect::>(); - let new_provider = create(&provider_name, model_config, extensions).await?; - agent.update_provider(new_provider, &session.id).await?; + let new_provider = create(&provider_name, extensions).await?; + agent + .update_provider(new_provider, model_config, &session.id) + .await?; let permission_manager = PermissionManager::instance(); let selected_tools = agent @@ -1811,13 +1820,11 @@ pub async fn handle_openrouter_auth() -> anyhow::Result<()> { } }; - match create("openrouter", model_config, Vec::new()).await { + match create("openrouter", Vec::new()).await { Ok(provider) => { - let provider_model_config = provider.get_model_config(); let test_result = provider .complete( - &provider_model_config, - "", + &model_config, "You are goose, an AI assistant.", &[Message::user().with_text("Say 'Configuration test successful!'")], &[], @@ -1883,17 +1890,14 @@ pub async fn handle_tetrate_auth() -> anyhow::Result<()> { // Test configuration println!("\nTesting configuration..."); let configured_model: String = config.get_goose_model()?; - let model_config = - match goose::model_config::model_config_from_user_config("tetrate", &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(()); - } - }; + 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(()); + } - match create("tetrate", model_config, Vec::new()).await { + match create("tetrate", 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 447d1a8ed8..f471018cb3 100644 --- a/crates/goose-cli/src/commands/info.rs +++ b/crates/goose-cli/src/commands/info.rs @@ -76,7 +76,7 @@ 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 provider_client = goose::providers::create(&provider, model_config, Vec::new()) + let provider_client = goose::providers::create(&provider, Vec::new()) .await .map_err(|e| { let error = e.to_string(); @@ -87,12 +87,13 @@ 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(); - provider_client - .complete(&model_config, "check", "", &[test_msg], &[]) - .await - .map_err(ProviderCheckError::ProviderRequest)?; + goose::session_context::with_session_id( + Some("check".to_string()), + provider_client.complete(&model_config, "", &[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 c0a55dedac..43fd67d60d 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(|a, b| b.last_accessed.cmp(&a.last_accessed)); + projects.sort_by_key(|project| std::cmp::Reverse(project.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(|a, b| b.last_accessed.cmp(&a.last_accessed)); + projects.sort_by_key(|project| std::cmp::Reverse(project.last_accessed)); // Format project paths for display let project_choices: Vec<(String, String)> = projects diff --git a/crates/goose-cli/src/commands/schedule.rs b/crates/goose-cli/src/commands/schedule.rs index 1f56481bdd..431afed7a1 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: {}, Working Dir: {}, Description: \"{}\", Schedule ID: {:?}", - session_name, // Display the session_name as Session ID + " - Session ID: {}, Messages: {}, Working Dir: {}, Description: \"{}\", Schedule ID: {:?}", + session_name, + metadata.message_count, 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 1a91a654bd..72c3610fe3 100644 --- a/crates/goose-cli/src/commands/session.rs +++ b/crates/goose-cli/src/commands/session.rs @@ -7,7 +7,9 @@ use etcetera::home_dir; use goose::config::Config; #[cfg(feature = "nostr")] use goose::session::nostr_share; -use goose::session::{generate_diagnostics, Session, SessionManager, SessionType}; +use goose::session::{ + generate_diagnostics, DiagnosticsLevel, Session, SessionManager, SessionType, +}; use goose::utils::safe_truncate; use regex::Regex; use std::fs; @@ -68,7 +70,8 @@ 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, @@ -168,9 +175,9 @@ pub async fn handle_session_list( } if ascending { - sessions.sort_by(|a, b| a.updated_at.cmp(&b.updated_at)); + sessions.sort_by_key(session_activity_at); } else { - sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + sessions.sort_by_key(|b| std::cmp::Reverse(session_activity_at(b))); } if let Some(n) = limit { @@ -204,7 +211,7 @@ pub async fn handle_session_list( "{} - {} - {} - {}", session.id, session.name, - session.updated_at, + session_activity_at(&session), display_path_with_tilde(&session.working_dir) ); if !write_line_or_broken_pipe_ok(&mut out, &output)? { @@ -322,24 +329,27 @@ 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 bundle for session '{}'...", + "Generating diagnostics report for session '{}'...", session_id ); let session_manager = SessionManager::instance(); - 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 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 output_file = if let Some(path) = output_path { path.clone() } else { - PathBuf::from(format!("diagnostics_{}.zip", session_id)) + PathBuf::from(format!("diagnostics_{}.json", session_id)) }; let mut file = fs::File::create(&output_file).context(format!( @@ -350,7 +360,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 bundle saved to: {}", output_file.display()); + println!("Diagnostics report 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 26d2a3652d..9c80fc1e0e 100644 --- a/crates/goose-cli/src/commands/term.rs +++ b/crates/goose-cli/src/commands/term.rs @@ -280,9 +280,15 @@ pub async fn handle_term_run(prompt: Vec) -> Result<()> { }; if let Some(oldest_user) = user_messages_after_last_assistant.last() { - session_manager - .truncate_conversation(&session_id, oldest_user.created) - .await?; + 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?; + } } let prompt_with_context = if user_messages_after_last_assistant.is_empty() { diff --git a/crates/goose-cli/src/commands/update.rs b/crates/goose-cli/src/commands/update.rs index 04272d2059..7c600a92c3 100644 --- a/crates/goose-cli/src/commands/update.rs +++ b/crates/goose-cli/src/commands/update.rs @@ -1,4 +1,8 @@ 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}; @@ -79,6 +83,32 @@ 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}\ @@ -86,17 +116,14 @@ 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, @@ -142,8 +187,8 @@ fn verify_bundle( Ok(()) } -/// Returns `Ok(true)` verified, `Ok(false)` skipped (soft warning), `Err` hard failure. -async fn verify_provenance(archive_data: &[u8], tag: &str) -> Result { +/// Returns `Ok(())` when the downloaded archive has verified provenance. +async fn verify_provenance(archive_data: &[u8], tag: &str) -> Result<()> { let digest = sha256_hex(archive_data); println!("Archive SHA-256: {digest}"); @@ -152,30 +197,19 @@ async fn verify_provenance(archive_data: &[u8], tag: &str) -> Result { _ => "release.yml", }; - let token = env::var("GITHUB_TOKEN") - .ok() - .or_else(|| env::var("GH_TOKEN").ok()); + let token = github_token(); println!("Verifying SLSA provenance via Sigstore..."); - 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 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 trusted_root = TrustedRoot::from_json(SIGSTORE_PRODUCTION_TRUSTED_ROOT) .context("Failed to load Sigstore trusted root")?; @@ -195,7 +229,7 @@ async fn verify_provenance(archive_data: &[u8], tag: &str) -> Result { ) { Ok(()) => { println!("Sigstore provenance verification passed."); - return Ok(true); + return Ok(()); } Err(e) => last_err = Some(e), } @@ -247,7 +281,7 @@ pub async fn update(canary: bool, reconfigure: bool) -> Result<()> { println!("Downloaded {} bytes.", bytes.len()); // --- Verify SLSA provenance via Sigstore -------------------------------- - let provenance_verified = verify_provenance(&bytes, tag).await?; + verify_provenance(&bytes, tag).await?; // --- Extract to temp dir (hardened against path traversal) -------------- let tmp_dir = tempfile::tempdir().context("Failed to create temp directory")?; @@ -274,11 +308,7 @@ pub async fn update(canary: bool, reconfigure: bool) -> Result<()> { #[cfg(target_os = "windows")] copy_dlls(&extracted_binary, ¤t_exe)?; - if provenance_verified { - println!("goose updated successfully (verified with Sigstore SLSA provenance)."); - } else { - println!("goose updated successfully."); - } + println!("goose updated successfully (verified with Sigstore SLSA provenance)."); // --- Reconfigure if requested ------------------------------------------- if reconfigure { @@ -737,6 +767,48 @@ 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 // ----------------------------------------------------------------------- @@ -808,13 +880,11 @@ mod tests { // ----------------------------------------------------------------------- #[tokio::test] - async fn test_verify_provenance_warns_on_missing_attestation() { + async fn test_verify_provenance_fails_closed_when_unverifiable() { let result = verify_provenance(b"not a real archive", "stable").await; - // 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" + assert!( + result.is_err(), + "verify_provenance must fail closed when provenance cannot be verified" ); } diff --git a/crates/goose-cli/src/lib.rs b/crates/goose-cli/src/lib.rs index be821a2a29..285144c468 100644 --- a/crates/goose-cli/src/lib.rs +++ b/crates/goose-cli/src/lib.rs @@ -1,3 +1,5 @@ +#![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/main.rs b/crates/goose-cli/src/main.rs index afecc21c82..b5b3c19d5b 100644 --- a/crates/goose-cli/src/main.rs +++ b/crates/goose-cli/src/main.rs @@ -1,3 +1,5 @@ +#![recursion_limit = "256"] + use anyhow::Result; use goose_cli::cli::cli; diff --git a/crates/goose-cli/src/scenario_tests/scenario_runner.rs b/crates/goose-cli/src/scenario_tests/scenario_runner.rs index 9619f3bdc7..2498c78cc0 100644 --- a/crates/goose-cli/src/scenario_tests/scenario_runner.rs +++ b/crates/goose-cli/src/scenario_tests/scenario_runner.rs @@ -185,12 +185,7 @@ where let original_env = setup_environment(config)?; - let inner_provider = create( - &factory_name, - goose::model_config::model_config_from_user_config(&factory_name, config.model_name)?, - Vec::new(), - ) - .await?; + let inner_provider = create(&factory_name, Vec::new()).await?; let test_provider = Arc::new(TestProvider::new_recording(inner_provider, &file_path)); ( @@ -245,9 +240,12 @@ 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?; diff --git a/crates/goose-cli/src/scenario_tests/scenarios.rs b/crates/goose-cli/src/scenario_tests/scenarios.rs index d1c7695742..c52df9753f 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 = provider.get_model_config(); + // let model_config = resolve_global_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 5639a01fce..de228d6e99 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -333,7 +333,10 @@ async fn resolve_session_id( } } } else { - match session_manager.list_sessions().await { + match session_manager + .list_sessions_by_types(&[SessionType::User]) + .await + { Ok(sessions) if !sessions.is_empty() => sessions[0].id.clone(), _ => { output::render_error("Cannot resume - no previous sessions found"); @@ -544,84 +547,79 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { } }; - let (new_provider, effective_provider_name, effective_model_name) = match create( - &resolved.provider_name, - resolved.model_config.clone(), - extensions_for_provider.clone(), - ) - .await - { - Ok(provider) => ( - provider, - resolved.provider_name.clone(), - resolved.model_name.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, - fallback_model_config, - extensions_for_provider.clone(), - ) - .await + 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) => { - Ok(provider) => (provider, fallback_provider, fallback_model), - Err(e2) => { - output::render_error(&format!( + 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); + process::exit(1); + } } } - } - Err(e) => { - output::render_error(&format!( + 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); - } - }; + process::exit(1); + } + }; tracing::info!("๐Ÿค– Using model: {}", effective_model_name); agent - .update_provider(new_provider, &session_id) + .update_provider(new_provider, effective_model_config, &session_id) .await .unwrap_or_else(|e| { output::render_error(&format!("Failed to initialize agent: {}", e)); @@ -700,6 +698,8 @@ fn is_provider_unavailable_error(e: &anyhow::Error) -> bool { #[cfg(test)] mod tests { use super::*; + use goose::session::SessionManager; + use tempfile::TempDir; #[test] fn test_session_builder_config_creation() { @@ -763,6 +763,44 @@ 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 a96ce75405..410acd9356 100644 --- a/crates/goose-cli/src/session/completion.rs +++ b/crates/goose-cli/src/session/completion.rs @@ -1,3 +1,4 @@ +use goose::agents::execute_commands::list_commands; use goose::config::GooseMode; use rustyline::completion::{Completer, FilenameCompleter, Pair}; use rustyline::highlight::{CmdKind, Highlighter}; @@ -36,8 +37,8 @@ impl GooseCompleter { // Create completion candidates that match the prefix let candidates: Vec = cache .prompts - .iter() - .flat_map(|(_, names)| names) + .values() + .flatten() .filter(|name| name.starts_with(prefix.trim())) .map(|name| Pair { display: name.clone(), @@ -152,23 +153,25 @@ impl GooseCompleter { /// Complete slash commands fn complete_slash_commands(&self, line: &str) -> Result<(usize, Vec)> { - // Define available slash commands - let commands = [ - "/exit", - "/quit", - "/help", - "/?", - "/t", - "/extension", - "/builtin", - "/prompts", - "/prompt", - "/mode", - "/model", - "/recipe", - "/skills", - "/status", + 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(), ]; + 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 @@ -578,6 +581,15 @@ 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 1e42d6069a..6ed3e297cc 100644 --- a/crates/goose-cli/src/session/editor.rs +++ b/crates/goose-cli/src/session/editor.rs @@ -1,7 +1,10 @@ -use anyhow::Result; +use anyhow::{Context, 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; @@ -21,9 +24,6 @@ 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,6 +37,57 @@ 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"); @@ -84,21 +135,36 @@ 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: Vec<&str> = editor_cmd.split_whitespace().collect(); + let parts = split_editor_command(editor_cmd)?; 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); } @@ -414,54 +480,64 @@ with multiple lines. ); } - // --- resolve_editor_from_sources tests --- - #[test] - 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")); + 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 + ); } #[test] - 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")); - } + fn test_split_editor_command() { + assert_eq!( + split_editor_command("code --wait").unwrap(), + vec!["code", "--wait"] + ); - #[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#""/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_returns_none_when_nothing_set() { - let result = resolve_editor_from_sources(None, None, None); - assert_eq!(result, None); - } + assert_eq!( + split_editor_command(r"C:\Windows\System32\notepad.exe").unwrap(), + vec![r"C:\Windows\System32\notepad.exe"] + ); - #[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")); + assert!(split_editor_command(r#"code --wait "unclosed"#).is_err()); } // --- build_template edge case tests --- @@ -469,9 +545,7 @@ 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")); } @@ -498,11 +572,8 @@ 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"); @@ -510,7 +581,6 @@ 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", @@ -522,7 +592,6 @@ 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); @@ -531,7 +600,6 @@ 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/input.rs b/crates/goose-cli/src/session/input.rs index 2bd8cafe2f..6b9da991af 100644 --- a/crates/goose-cli/src/session/input.rs +++ b/crates/goose-cli/src/session/input.rs @@ -401,10 +401,17 @@ fn parse_plan_command(input: String) -> Option { Some(InputResult::Plan(options)) } -fn print_help() { +fn help_text() -> String { let newline_key = get_newline_key().to_ascii_uppercase(); let modes = GooseMode::VARIANTS.join(", "); - println!( + 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!( "Available commands: /exit or /quit - Exit the session /t - Toggle Light/Dark/Ansi theme @@ -425,7 +432,7 @@ fn print_help() { /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. -/status - Show session status: model, provider, mode, and token usage. +{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 [...]) @@ -436,7 +443,23 @@ 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 @@ -535,6 +558,19 @@ 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 76d7ac9151..1c02ececb9 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; -mod editor; +pub mod editor; mod elicitation; mod export; mod input; @@ -53,7 +53,7 @@ use std::collections::{HashMap, HashSet}; use std::io::IsTerminal; use std::path::PathBuf; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use tokio; use tokio_util::sync::CancellationToken; use tracing::warn; @@ -219,22 +219,23 @@ 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 model_config = provider.get_model_config(); - let (result, _usage) = provider - .complete( + let (result, _usage) = goose::session_context::with_session_id( + Some(session_id.to_string()), + 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") { @@ -486,10 +487,6 @@ 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 @@ -655,6 +652,8 @@ 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)) => {} @@ -722,8 +721,8 @@ impl CliSession { RunMode::Plan => { let mut plan_messages = self.messages.clone(); plan_messages.push(Message::user().with_text(content)); - let reasoner = get_reasoner().await?; - self.plan_with_reasoner_model(plan_messages, reasoner) + let (reasoner, reasoner_model_config) = get_reasoner().await?; + self.plan_with_reasoner_model(plan_messages, reasoner, reasoner_model_config) .await?; } } @@ -810,7 +809,10 @@ 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 = provider.get_model_config(); + let current_model_config = self + .agent + .model_config_for_session(&self.session_id) + .await?; let current_model_name = current_model_config.model_name.clone(); if model.is_none() { @@ -859,13 +861,12 @@ impl CliSession { } let extensions = self.agent.get_extension_configs().await; - let new_provider = - goose::providers::create(¤t_provider_name, new_model_config, extensions) - .await - .map_err(|e| anyhow::anyhow!("Failed to create provider: {e}"))?; + let new_provider = goose::providers::create(¤t_provider_name, extensions) + .await + .map_err(|e| anyhow::anyhow!("Failed to create provider: {e}"))?; self.agent - .update_provider(new_provider, &self.session_id) + .update_provider(new_provider, new_model_config, &self.session_id) .await?; let mode = self.agent.goose_mode().await; @@ -888,8 +889,9 @@ impl CliSession { let mut plan_messages = self.messages.clone(); plan_messages.push(Message::user().with_text(&options.message_text)); - let reasoner = get_reasoner().await?; - self.plan_with_reasoner_model(plan_messages, reasoner).await + let (reasoner, reasoner_model_config) = get_reasoner().await?; + self.plan_with_reasoner_model(plan_messages, reasoner, reasoner_model_config) + .await } async fn handle_clear(&mut self) -> Result<()> { @@ -1051,25 +1053,24 @@ 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 model_config = reasoner.get_model_config(); - let (plan_response, _usage) = reasoner - .complete( - &model_config, - &self.session_id, - &plan_prompt, - plan_messages.messages(), - &[], - ) - .await?; + 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?; 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?; @@ -1134,9 +1135,6 @@ 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) @@ -1328,6 +1326,7 @@ 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, @@ -1596,8 +1595,14 @@ impl CliSession { /// Display enhanced context usage with session totals pub async fn display_context_usage(&self) -> Result<()> { let provider = self.agent.provider().await?; - let model_config = provider.get_model_config(); - let context_limit = model_config.context_limit(); + 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 config = Config::global(); let show_cost = config @@ -1764,25 +1769,54 @@ fn print_run_stats( 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(|| usage.and_then(|usage| usage.stats.as_ref()?.output_tokens)); + .or_else(|| stats.and_then(|stats| stats.output_tokens)); let tokens_per_second = output_tokens.map(|tokens| { - if elapsed.as_secs_f64() > 0.0 { - tokens as f64 / elapsed.as_secs_f64() + 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:"); - 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"), + 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), @@ -1792,10 +1826,7 @@ fn print_run_stats( eprintln!(" Output tokens: {tokens}"); } - if let Some(draft) = usage - .and_then(|usage| usage.stats.as_ref()) - .and_then(|stats| stats.draft.as_ref()) - { + 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: {}", @@ -2213,7 +2244,8 @@ fn handle_agent_error(e: &anyhow::Error, is_stream_json_mode: bool) { } } -async fn get_reasoner() -> Result, anyhow::Error> { +async fn get_reasoner( +) -> Result<(Arc, goose_providers::model::ModelConfig), anyhow::Error> { use goose::providers::create; let config = Config::global(); @@ -2252,9 +2284,9 @@ async fn get_reasoner() -> Result, anyhow::Error> { goose::model_config::model_config_from_user_config(&provider, model.as_str())? .with_context_limit(planner_context_limit); let extensions = goose::config::extensions::get_enabled_extensions_with_config(config); - let reasoner = create(&provider, model_config, extensions).await?; + let reasoner = create(&provider, extensions).await?; - Ok(reasoner) + Ok((reasoner, model_config)) } /// Format elapsed time duration @@ -2434,7 +2466,6 @@ mod tests { 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"]), @@ -2444,7 +2475,7 @@ mod tests { let switched = build_switched_model_config("openai", "gpt-5.4", ¤t_model_config).unwrap(); - let expected = goose_providers::model::ModelConfig::new_or_fail("gpt-5.4") + let expected = goose_providers::model::ModelConfig::new("gpt-5.4") .with_canonical_limits("openai") .with_temperature(Some(0.25)) .with_toolshim(true) @@ -2471,7 +2502,7 @@ mod tests { ("GOOSE_THINKING_EFFORT", None::<&str>), ]); - let current = goose_providers::model::ModelConfig::new_or_fail("gpt-5.4-high") + let current = goose_providers::model::ModelConfig::new("gpt-5.4-high") .with_canonical_limits("openai"); assert_eq!(current.model_name, "gpt-5.4"); assert_eq!( diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 7d1b56ea54..17648a0892 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -248,7 +248,8 @@ pub fn render_message(message: &Message, debug: bool) { } MessageContent::SystemNotification(notification) => { match notification.notification_type { - SystemNotificationType::ThinkingMessage => { + SystemNotificationType::ThinkingMessage + | SystemNotificationType::ProgressMessage => { show_thinking(); set_thinking_message(¬ification.msg); } @@ -330,7 +331,8 @@ pub fn render_message_streaming( } MessageContent::SystemNotification(notification) => { match notification.notification_type { - SystemNotificationType::ThinkingMessage => { + SystemNotificationType::ThinkingMessage + | SystemNotificationType::ProgressMessage => { show_thinking(); set_thinking_message(¬ification.msg); } diff --git a/crates/goose-cli/src/session/streaming_buffer.rs b/crates/goose-cli/src/session/streaming_buffer.rs index ac8ddbd95f..cb42cce61e 100644 --- a/crates/goose-cli/src/session/streaming_buffer.rs +++ b/crates/goose-cli/src/session/streaming_buffer.rs @@ -524,10 +524,8 @@ 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-download-manager/Cargo.toml b/crates/goose-download-manager/Cargo.toml new file mode 100644 index 0000000000..e0b99e8d73 --- /dev/null +++ b/crates/goose-download-manager/Cargo.toml @@ -0,0 +1,21 @@ +[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 new file mode 100644 index 0000000000..89942e527c --- /dev/null +++ b/crates/goose-download-manager/src/lib.rs @@ -0,0 +1,691 @@ +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 new file mode 100644 index 0000000000..c3ae342747 --- /dev/null +++ b/crates/goose-local-inference/Cargo.toml @@ -0,0 +1,54 @@ +[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/src/providers/local_inference/backend.rs b/crates/goose-local-inference/src/backend.rs similarity index 83% rename from crates/goose/src/providers/local_inference/backend.rs rename to crates/goose-local-inference/src/backend.rs index 78a2cc3a91..c36420c366 100644 --- a/crates/goose/src/providers/local_inference/backend.rs +++ b/crates/goose-local-inference/src/backend.rs @@ -1,10 +1,10 @@ use rmcp::model::Tool; use std::any::Any; -use crate::conversation::message::Message; -use crate::providers::local_inference::local_model_registry::ModelSettings; -use goose_providers::errors::ProviderError; -use goose_providers::request_log::RequestLogHandle; +use crate::local_model_registry::ModelSettings; +use goose_provider_types::conversation::message::Message; +use goose_provider_types::errors::ProviderError; +use goose_provider_types::request_log::RequestLogHandle; use super::{ResolvedModelPaths, StreamSender}; @@ -22,6 +22,7 @@ pub(super) struct LocalGenerationRequest<'a> { pub temperature: Option, pub max_tokens: Option, pub context_limit: usize, + pub model_load_ms: Option, pub resolved_model: &'a ResolvedModelPaths, pub draft_model_path: Option, pub message_id: &'a str, diff --git a/crates/goose-local-inference/src/config_resolver.rs b/crates/goose-local-inference/src/config_resolver.rs new file mode 100644 index 0000000000..3c5a610222 --- /dev/null +++ b/crates/goose-local-inference/src/config_resolver.rs @@ -0,0 +1,30 @@ +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/src/providers/local_inference/hf_models.rs b/crates/goose-local-inference/src/hf_models.rs similarity index 89% rename from crates/goose/src/providers/local_inference/hf_models.rs rename to crates/goose-local-inference/src/hf_models.rs index bb31fb8deb..a63d34a122 100644 --- a/crates/goose/src/providers/local_inference/hf_models.rs +++ b/crates/goose-local-inference/src/hf_models.rs @@ -8,7 +8,7 @@ use super::local_model_registry::{get_registry, model_id_from_repo, LocalModelSt use serde::{Deserialize, Serialize}; use std::sync::{Arc, Mutex}; -use crate::providers::huggingface_auth; +use crate::huggingface_auth; use utoipa::ToSchema; @@ -1085,20 +1085,13 @@ mod tests { } #[test] - fn mlx_compatible_repo_accepts_supported_tokenizer_formats() { + fn mlx_compatible_repo_accepts_tokenizer_json() { let config = Some(serde_json::json!({ "model_type": "llama" })); - for tokenizer_files in [ - vec!["tokenizer.json"], - vec!["tokenizer.model"], - vec!["tokenizer.tiktoken"], - vec!["vocab.json", "merges.txt"], - ] { - assert!( - is_mlx_compatible_repo(&config, &mlx_siblings(&tokenizer_files)), - "{tokenizer_files:?}" - ); - } + assert!(is_mlx_compatible_repo( + &config, + &mlx_siblings(&["tokenizer.json"]) + )); } #[test] @@ -1107,8 +1100,11 @@ mod tests { 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)), @@ -1117,6 +1113,75 @@ mod tests { } } + #[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(), @@ -1157,6 +1222,13 @@ mod tests { 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![ @@ -1468,7 +1540,13 @@ async fn search_mlx_models_with_query(query: &str, limit: usize) -> Result= limit { break; } - if let Some(model) = get_local_model_info_for_repo_with_client(&client, &info.id).await? { + if let Some(model) = get_local_model_info_for_repo_with_client_and_downloads( + &client, + &info.id, + info.downloads, + ) + .await? + { results.push(model); } } @@ -1484,6 +1562,14 @@ async fn get_local_model_info_for_repo(repo_id: &str) -> Result 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 @@ -1495,7 +1581,7 @@ async fn get_local_model_info_for_repo_with_client( ]) .send() .await?; - model_info_to_local_model_info(info).await + model_info_to_local_model_info(&repo, info, downloads_hint).await } async fn get_exact_name_local_model_info(model_name: &str) -> Result> { @@ -1508,7 +1594,11 @@ async fn get_exact_name_local_model_info(model_name: &str) -> Result Result> { +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 @@ -1516,7 +1606,13 @@ async fn model_info_to_local_model_info(info: ModelInfo) -> Result Result downloads, + None => get_repo_downloads(&repo_id).await?.unwrap_or(0), + }; Ok(Some(HfModelInfo { repo_id, author, model_name, - downloads: info.downloads.unwrap_or(0), + 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 @@ -1570,25 +1698,39 @@ pub async fn get_repo_mlx_variants(repo_id: &str) -> Result> ]) .send() .await?; - Ok(mlx_variants_from_model_info(repo_id, &info)) + 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)) } -fn mlx_variants_from_model_info(repo_id: &str, info: &ModelInfo) -> Vec { +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_filenames(siblings) - .into_iter() - .filter_map(|filename| { - siblings - .iter() - .find(|s| s.rfilename == filename) - .and_then(|s| s.size) - }) - .sum(); + let size_bytes = mlx_download_size_bytes(info, siblings); let variant_id = mlx_variant_id(repo_id, &info.config); vec![HfModelVariant { @@ -1601,20 +1743,24 @@ fn mlx_variants_from_model_info(repo_id: &str, info: &ModelInfo) -> Vec 1, - supported: is_mlx_runtime_supported(&info.config) + supported: is_mlx_runtime_supported(mlx_config) && cfg!(target_os = "macos") && cfg!(feature = "mlx"), - unsupported_reason: mlx_unsupported_reason(&info.config), + 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); @@ -1625,16 +1771,50 @@ fn is_mlx_compatible_repo(config: &Option, siblings: &[RepoSi 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 { - let has_file = |filename: &str| siblings.iter().any(|s| s.rfilename == filename); siblings .iter() .any(|s| is_standalone_mlx_tokenizer_file(&s.rfilename)) - || (has_file("vocab.json") && has_file("merges.txt")) } fn is_standalone_mlx_tokenizer_file(filename: &str) -> bool { - filename == "tokenizer.json" || filename.ends_with(".model") || filename.ends_with(".tiktoken") + filename == "tokenizer.json" } fn mlx_model_type(config: &Option) -> Option<&str> { @@ -1644,22 +1824,8 @@ fn mlx_model_type(config: &Option) -> Option<&str> { .and_then(|value| value.as_str()) } -fn is_mlx_runtime_supported_model_type(model_type: &str) -> bool { - matches!(model_type, "gemma4" | "gemma4_text" | "llama" | "qwen3") -} - -fn is_mlx_moe_model(config: &Option) -> bool { - config - .as_ref() - .and_then(|config| config.get("text_config")) - .and_then(|text_config| text_config.get("enable_moe_block")) - .and_then(|value| value.as_bool()) - .unwrap_or(false) -} - fn is_mlx_runtime_supported(config: &Option) -> bool { - mlx_model_type(config).is_some_and(is_mlx_runtime_supported_model_type) - && !is_mlx_moe_model(config) + mlx_config_support(config).is_none() } fn mlx_unsupported_reason(config: &Option) -> Option { @@ -1670,16 +1836,23 @@ fn mlx_unsupported_reason(config: &Option) -> Option return Some("MLX support was not compiled in".to_string()); } - let model_type = mlx_model_type(config)?; - if !is_mlx_runtime_supported_model_type(model_type) { - return Some(format!( - "MLX backend does not support '{}' models yet", - model_type - )); - } - if is_mlx_moe_model(config) { - return Some("MLX backend does not support Gemma 4 MoE models yet".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 } @@ -1771,6 +1944,10 @@ fn should_download_for_mlx(filename: &str) -> bool { || 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" @@ -1780,7 +1957,7 @@ fn should_download_for_mlx(filename: &str) -> bool { 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", "4bit", "8bit"] { + for marker in ["bf16", "f16", "fp16", "f32", "fp32", "fp8", "4bit", "8bit"] { if repo_lower.contains(marker) { return marker.to_string(); } @@ -1954,20 +2131,12 @@ async fn resolve_mlx_model(repo_id: &str, variant_id: &str) -> Result 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 new file mode 100644 index 0000000000..e36ee9bed0 --- /dev/null +++ b/crates/goose-local-inference/src/lib.rs @@ -0,0 +1,950 @@ +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/src/providers/local_inference/llamacpp/inference_emulated_tools.rs b/crates/goose-local-inference/src/llamacpp/inference_emulated_tools.rs similarity index 99% rename from crates/goose/src/providers/local_inference/llamacpp/inference_emulated_tools.rs rename to crates/goose-local-inference/src/llamacpp/inference_emulated_tools.rs index 703a51a19b..27f7d4d0cb 100644 --- a/crates/goose/src/providers/local_inference/llamacpp/inference_emulated_tools.rs +++ b/crates/goose-local-inference/src/llamacpp/inference_emulated_tools.rs @@ -20,17 +20,16 @@ //! These are inherent to text-based tool emulation. Models with native tool-calling //! support should use the `inference_native_tools` path instead. -use crate::conversation::message::{Message, MessageContent}; -use goose_providers::errors::ProviderError; +use goose_provider_types::conversation::message::{Message, MessageContent}; +use goose_provider_types::errors::ProviderError; use rmcp::model::{CallToolRequestParams, Tool}; use serde_json::json; use std::borrow::Cow; use uuid::Uuid; -use super::super::{finalize_usage, StreamSender}; +use super::super::{finalize_usage, thinking_output::ThinkingOutputFilter, StreamSender}; use super::inference_engine::{ - generation_loop, prepare_generation, GenerationContext, StopSuffixTrimmer, - ThinkingOutputFilter, TokenAction, + generation_loop, prepare_generation, GenerationContext, StopSuffixTrimmer, TokenAction, }; const SHELL_TOOL: &str = "developer__shell"; diff --git a/crates/goose/src/providers/local_inference/llamacpp/inference_engine.rs b/crates/goose-local-inference/src/llamacpp/inference_engine.rs similarity index 85% rename from crates/goose/src/providers/local_inference/llamacpp/inference_engine.rs rename to crates/goose-local-inference/src/llamacpp/inference_engine.rs index 166f6476c9..4b3e083b57 100644 --- a/crates/goose/src/providers/local_inference/llamacpp/inference_engine.rs +++ b/crates/goose-local-inference/src/llamacpp/inference_engine.rs @@ -1,9 +1,8 @@ -use crate::providers::local_inference::backend::LocalInferenceBackend; -use crate::providers::local_inference::local_model_registry::ModelSettings; -use crate::providers::local_inference::multimodal::ExtractedImage; -use goose_providers::errors::ProviderError; -use goose_providers::request_log::{LoggerHandleExt, RequestLogHandle}; -use goose_providers::thinking::{FilterOut, ThinkFilter}; +use crate::backend::LocalInferenceBackend; +use crate::local_model_registry::ModelSettings; +use crate::multimodal::ExtractedImage; +use goose_provider_types::errors::ProviderError; +use goose_provider_types::request_log::{LoggerHandleExt, RequestLogHandle}; use llama_cpp_2::context::params::LlamaContextParams; use llama_cpp_2::llama_batch::LlamaBatch; use llama_cpp_2::model::{AddBos, ChatTemplateResult, LlamaChatTemplate, LlamaModel}; @@ -48,86 +47,6 @@ pub(super) struct PreparedGeneration<'model> { pub effective_ctx: usize, } -pub(super) struct ThinkingOutputFilter { - enabled: bool, - saw_structured_reasoning: bool, - think_filter: ThinkFilter, - pending_inline_thinking: String, - accumulated_thinking: String, -} - -impl ThinkingOutputFilter { - pub(super) 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(super) 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(super) 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(super) 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(super) fn accumulated_thinking(&self) -> &str { - &self.accumulated_thinking - } -} - pub(super) struct StopSuffixTrimmer { pending: String, stops: Vec, @@ -246,7 +165,7 @@ pub(super) fn estimate_max_context_for_memory( } pub(super) fn context_cap( - settings: &crate::providers::local_inference::local_model_registry::ModelSettings, + settings: &crate::local_model_registry::ModelSettings, context_limit: usize, n_ctx_train: usize, memory_max_ctx: Option, @@ -276,7 +195,7 @@ pub(super) fn context_cap( pub(super) fn effective_context_size( prompt_token_count: usize, - settings: &crate::providers::local_inference::local_model_registry::ModelSettings, + settings: &crate::local_model_registry::ModelSettings, context_limit: usize, n_ctx_train: usize, memory_max_ctx: Option, @@ -296,7 +215,7 @@ pub(super) fn effective_context_size( pub(super) fn build_context_params( ctx_size: u32, - settings: &crate::providers::local_inference::local_model_registry::ModelSettings, + settings: &crate::local_model_registry::ModelSettings, ) -> LlamaContextParams { let mut params = LlamaContextParams::default().with_n_ctx(NonZeroU32::new(ctx_size)); @@ -315,10 +234,8 @@ pub(super) fn build_context_params( params } -pub(super) fn build_sampler( - settings: &crate::providers::local_inference::local_model_registry::ModelSettings, -) -> LlamaSampler { - use crate::providers::local_inference::local_model_registry::SamplingConfig; +pub(super) fn build_sampler(settings: &crate::local_model_registry::ModelSettings) -> LlamaSampler { + use crate::local_model_registry::SamplingConfig; let has_penalties = settings.repeat_penalty != 1.0 || settings.frequency_penalty != 0.0 @@ -371,7 +288,7 @@ pub(super) fn validate_and_compute_context( backend: &LlamaCppBackend, prompt_token_count: usize, context_limit: usize, - settings: &crate::providers::local_inference::local_model_registry::ModelSettings, + settings: &crate::local_model_registry::ModelSettings, ) -> Result<(usize, usize), ProviderError> { let n_ctx_train = loaded.model.n_ctx_train() as usize; let mmproj_overhead = if loaded.mtmd_ctx.is_some() { @@ -412,7 +329,7 @@ pub(super) fn create_and_prefill_context<'model>( backend: &LlamaCppBackend, tokens: &[llama_cpp_2::token::LlamaToken], effective_ctx: usize, - settings: &crate::providers::local_inference::local_model_registry::ModelSettings, + settings: &crate::local_model_registry::ModelSettings, ) -> Result, ProviderError> { let ctx_params = build_context_params(effective_ctx as u32, settings); let mut ctx = loaded @@ -633,7 +550,7 @@ pub(super) enum TokenAction { pub(super) fn generation_loop( model: &LlamaModel, ctx: &mut llama_cpp_2::context::LlamaContext<'_>, - settings: &crate::providers::local_inference::local_model_registry::ModelSettings, + settings: &crate::local_model_registry::ModelSettings, prompt_token_count: usize, effective_ctx: usize, mut on_piece: impl FnMut(&str) -> Result, @@ -695,7 +612,7 @@ pub(super) fn generation_loop( #[cfg(test)] mod tests { use super::*; - use crate::providers::local_inference::local_model_registry::ModelSettings; + use crate::local_model_registry::ModelSettings; fn default_settings() -> ModelSettings { ModelSettings::default() diff --git a/crates/goose/src/providers/local_inference/llamacpp/inference_native_tools.rs b/crates/goose-local-inference/src/llamacpp/inference_native_tools.rs similarity index 98% rename from crates/goose/src/providers/local_inference/llamacpp/inference_native_tools.rs rename to crates/goose-local-inference/src/llamacpp/inference_native_tools.rs index 63d1e721ae..ed44c78403 100644 --- a/crates/goose/src/providers/local_inference/llamacpp/inference_native_tools.rs +++ b/crates/goose-local-inference/src/llamacpp/inference_native_tools.rs @@ -1,14 +1,14 @@ -use crate::conversation::message::{Message, MessageContent}; -use goose_providers::errors::ProviderError; +use goose_provider_types::conversation::message::{Message, MessageContent}; +use goose_provider_types::errors::ProviderError; use rmcp::model::CallToolRequestParams; use serde_json::Value; use std::borrow::Cow; use uuid::Uuid; use super::super::finalize_usage; +use super::super::thinking_output::ThinkingOutputFilter; use super::inference_engine::{ - generation_loop, prepare_generation, GenerationContext, StopSuffixTrimmer, - ThinkingOutputFilter, TokenAction, + generation_loop, prepare_generation, GenerationContext, StopSuffixTrimmer, TokenAction, }; pub(super) fn generate_with_native_tools( diff --git a/crates/goose/src/providers/local_inference/llamacpp/mod.rs b/crates/goose-local-inference/src/llamacpp/mod.rs similarity index 96% rename from crates/goose/src/providers/local_inference/llamacpp/mod.rs rename to crates/goose-local-inference/src/llamacpp/mod.rs index 9a77d181a1..bdfd8cf41e 100644 --- a/crates/goose/src/providers/local_inference/llamacpp/mod.rs +++ b/crates/goose-local-inference/src/llamacpp/mod.rs @@ -18,19 +18,13 @@ use self::inference_emulated_tools::{ }; use self::inference_engine::{GenerationContext, LoadedChatTemplates, LoadedModel}; use self::inference_native_tools::generate_with_native_tools; -use crate::providers::local_inference::backend::{ - BackendLoadedModel, LocalGenerationRequest, LocalInferenceBackend, -}; -use crate::providers::local_inference::local_model_registry::{ - ChatTemplate, ModelSettings, ToolCallingMode, -}; -use crate::providers::local_inference::multimodal::ExtractedImage; -use crate::providers::local_inference::tool_parsing::compact_tools_json; -use crate::providers::local_inference::{ - build_openai_messages_json, build_openai_text_messages_json, ResolvedModelPaths, -}; -use goose_providers::errors::ProviderError; -use goose_providers::formats::openai::format_tools; +use crate::backend::{BackendLoadedModel, LocalGenerationRequest, LocalInferenceBackend}; +use crate::local_model_registry::{ChatTemplate, ModelSettings, ToolCallingMode}; +use crate::multimodal::ExtractedImage; +use crate::tool_parsing::compact_tools_json; +use crate::{build_openai_messages_json, build_openai_text_messages_json, ResolvedModelPaths}; +use goose_provider_types::errors::ProviderError; +use goose_provider_types::formats::openai::format_tools; pub(super) const LLAMACPP_BACKEND_ID: &str = "llamacpp"; @@ -342,7 +336,7 @@ impl LlamaCppBackend { fn init_mtmd_context( model: &LlamaModel, mmproj_path: &Option, - settings: &crate::providers::local_inference::local_model_registry::ModelSettings, + settings: &crate::local_model_registry::ModelSettings, ) -> Option { use llama_cpp_2::mtmd::{MtmdContext, MtmdContextParams}; @@ -383,7 +377,7 @@ impl LocalInferenceBackend for LlamaCppBackend { &self, model_id: &str, resolved: &ResolvedModelPaths, - settings: &crate::providers::local_inference::local_model_registry::ModelSettings, + settings: &crate::local_model_registry::ModelSettings, ) -> Result, ProviderError> { let model_path = &resolved.model_path; diff --git a/crates/goose/src/providers/local_inference/local_model_registry.rs b/crates/goose-local-inference/src/local_model_registry.rs similarity index 99% rename from crates/goose/src/providers/local_inference/local_model_registry.rs rename to crates/goose-local-inference/src/local_model_registry.rs index ed5a7a008d..893d4e369b 100644 --- a/crates/goose/src/providers/local_inference/local_model_registry.rs +++ b/crates/goose-local-inference/src/local_model_registry.rs @@ -1,5 +1,5 @@ -use crate::config::paths::Paths; use crate::download_manager::{get_download_manager, DownloadStatus}; +use crate::paths::Paths; use anyhow::Result; use serde::{Deserialize, Serialize}; use std::path::PathBuf; diff --git a/crates/goose-local-inference/src/management.rs b/crates/goose-local-inference/src/management.rs new file mode 100644 index 0000000000..31d90d53ba --- /dev/null +++ b/crates/goose-local-inference/src/management.rs @@ -0,0 +1,860 @@ +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 new file mode 100644 index 0000000000..6b6ddb0fd4 --- /dev/null +++ b/crates/goose-local-inference/src/mlx.rs @@ -0,0 +1,1044 @@ +#[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/src/providers/local_inference/multimodal.rs b/crates/goose-local-inference/src/multimodal.rs similarity index 98% rename from crates/goose/src/providers/local_inference/multimodal.rs rename to crates/goose-local-inference/src/multimodal.rs index 1a464cd02e..2389ace53b 100644 --- a/crates/goose/src/providers/local_inference/multimodal.rs +++ b/crates/goose-local-inference/src/multimodal.rs @@ -1,8 +1,8 @@ use base64::prelude::*; use serde_json::Value; -use crate::conversation::message::{Message, MessageContent}; -use goose_providers::errors::ProviderError; +use goose_provider_types::conversation::message::{Message, MessageContent}; +use goose_provider_types::errors::ProviderError; #[derive(Debug)] pub struct ExtractedImage { diff --git a/crates/goose/src/providers/local_inference/native_tool_parsing.rs b/crates/goose-local-inference/src/native_tool_parsing.rs similarity index 97% rename from crates/goose/src/providers/local_inference/native_tool_parsing.rs rename to crates/goose-local-inference/src/native_tool_parsing.rs index ffbe7f71d8..3773c6b95f 100644 --- a/crates/goose/src/providers/local_inference/native_tool_parsing.rs +++ b/crates/goose-local-inference/src/native_tool_parsing.rs @@ -1,7 +1,7 @@ -use crate::conversation::message::{Message, MessageContent}; -use goose_providers::errors::ProviderError; -use goose_providers::formats::openai::is_valid_function_name; -use goose_providers::json::safely_parse_json; +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; diff --git a/crates/goose-local-inference/src/paths.rs b/crates/goose-local-inference/src/paths.rs new file mode 100644 index 0000000000..4a79cbf306 --- /dev/null +++ b/crates/goose-local-inference/src/paths.rs @@ -0,0 +1,88 @@ +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 new file mode 100644 index 0000000000..046fc2402f --- /dev/null +++ b/crates/goose-local-inference/src/prompt_template.rs @@ -0,0 +1,43 @@ +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 new file mode 100644 index 0000000000..2a05d84160 --- /dev/null +++ b/crates/goose-local-inference/src/prompts/tiny_model_system.md @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000000..1300c1f669 --- /dev/null +++ b/crates/goose-local-inference/src/provider_utils.rs @@ -0,0 +1,24 @@ +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 new file mode 100644 index 0000000000..b47d23bc9f --- /dev/null +++ b/crates/goose-local-inference/src/thinking_output.rs @@ -0,0 +1,81 @@ +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/src/providers/local_inference/tool_emulation.rs b/crates/goose-local-inference/src/tool_emulation.rs similarity index 99% rename from crates/goose/src/providers/local_inference/tool_emulation.rs rename to crates/goose-local-inference/src/tool_emulation.rs index 9bb645d6cd..ac528be232 100644 --- a/crates/goose/src/providers/local_inference/tool_emulation.rs +++ b/crates/goose-local-inference/src/tool_emulation.rs @@ -4,7 +4,7 @@ //! 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 crate::conversation::message::{Message, MessageContent}; +use goose_provider_types::conversation::message::{Message, MessageContent}; use rmcp::model::{CallToolRequestParams, Tool}; use serde_json::json; use std::borrow::Cow; diff --git a/crates/goose/src/providers/local_inference/tool_parsing.rs b/crates/goose-local-inference/src/tool_parsing.rs similarity index 100% rename from crates/goose/src/providers/local_inference/tool_parsing.rs rename to crates/goose-local-inference/src/tool_parsing.rs diff --git a/crates/goose-mcp/Cargo.toml b/crates/goose-mcp/Cargo.toml index 45ac8577f2..7aa7c2631d 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.41", default-features = false } +lopdf = { version = "0.42", 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 = "2", default-features = false } +umya-spreadsheet = { version = "3", default-features = false } shell-words = { workspace = true } process-wrap = { version = "9", default-features = false, features = ["std"] } diff --git a/crates/goose-mcp/src/computercontroller/pdf_tool.rs b/crates/goose-mcp/src/computercontroller/pdf_tool.rs index defbd5fdcb..6d92cc96d1 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 f588dee07f..4e7b794155 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::{Spreadsheet, Worksheet}; +use umya_spreadsheet::{structs::Workbook, Worksheet}; #[derive(Debug, Serialize, Deserialize)] pub struct WorksheetInfo { @@ -28,7 +28,7 @@ pub struct RangeData { } pub struct XlsxTool { - workbook: Spreadsheet, + workbook: Workbook, } 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.get_sheet_collection().iter().enumerate() { + for (index, worksheet) in self.workbook.sheet_collection().iter().enumerate() { let (column_count, row_count) = self.get_worksheet_dimensions(worksheet)?; worksheets.push(WorksheetInfo { - name: worksheet.get_name().to_string(), + name: worksheet.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 - .get_sheet_by_name(name) + .sheet_by_name(name) .context("Worksheet not found") } pub fn get_worksheet_by_index(&self, index: usize) -> Result<&Worksheet> { self.workbook - .get_sheet_collection() + .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.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); + 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); } } } @@ -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.get_highest_column() { - if let Some(cell) = worksheet.get_cell((col_num, 1)) { - names.push(cell.get_value().into_owned()); + for col_num in 1..=worksheet.highest_column() { + if let Some(cell) = worksheet.cell((col_num, 1)) { + names.push(cell.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.get_cell((col_idx, row_idx)) { + let cell_value = if let Some(cell) = worksheet.cell((col_idx, row_idx)) { CellValue { - value: cell.get_value().into_owned(), - formula: if cell.get_formula().is_empty() { + value: cell.value().into_owned(), + formula: if cell.formula().is_empty() { None } else { - Some(cell.get_formula().to_string()) + Some(cell.formula().to_string()) }, } } else { @@ -142,12 +142,10 @@ impl XlsxTool { ) -> Result<()> { let worksheet = self .workbook - .get_sheet_by_name_mut(worksheet_name) + .sheet_by_name_mut(worksheet_name) .context("Worksheet not found")?; - worksheet - .get_cell_mut((col, row)) - .set_value(value.to_string()); + worksheet.cell_mut((col, row)).set_value(value.to_string()); Ok(()) } @@ -171,18 +169,18 @@ impl XlsxTool { search_text.to_string() }; - 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)) { + 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 cell_value = if !case_sensitive { - cell.get_value().to_lowercase() + cell.value().to_lowercase() } else { - cell.get_value().to_string() + cell.value().to_string() }; if cell_value.contains(&search_text) { - let coord = cell.get_coordinate(); - matches.push((*coord.get_row_num(), *coord.get_col_num())); + let coord = cell.coordinate(); + matches.push((coord.row_num(), coord.col_num())); } } } @@ -192,14 +190,14 @@ impl XlsxTool { } pub fn get_cell_value(&self, worksheet: &Worksheet, row: u32, col: u32) -> Result { - let cell = worksheet.get_cell((col, row)).context("Cell not found")?; + let cell = worksheet.cell((col, row)).context("Cell not found")?; Ok(CellValue { - value: cell.get_value().into_owned(), - formula: if cell.get_formula().is_empty() { + value: cell.value().into_owned(), + formula: if cell.formula().is_empty() { None } else { - Some(cell.get_formula().to_string()) + Some(cell.formula().to_string()) }, }) } diff --git a/crates/goose-mcp/src/lib.rs b/crates/goose-mcp/src/lib.rs index f586fde8b2..1bb0b77a7a 100644 --- a/crates/goose-mcp/src/lib.rs +++ b/crates/goose-mcp/src/lib.rs @@ -19,8 +19,6 @@ mod memory; pub mod peekaboo; pub mod subprocess; pub mod tutorial; -#[cfg(windows)] -pub mod windows_job; pub use autovisualiser::AutoVisualiserRouter; pub use computercontroller::ComputerControllerServer; diff --git a/crates/goose-mcp/src/memory/mod.rs b/crates/goose-mcp/src/memory/mod.rs index 715a9229f5..b0f086874a 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_iter().flat_map(|(_, v)| v).collect(), + category_memories.into_values().flatten().collect(), ); } } diff --git a/crates/goose-mcp/src/subprocess.rs b/crates/goose-mcp/src/subprocess.rs index a8f66980cd..30399f75ff 100644 --- a/crates/goose-mcp/src/subprocess.rs +++ b/crates/goose-mcp/src/subprocess.rs @@ -64,7 +64,7 @@ fn resolve_login_shell_path() -> Option { .stdout(Stdio::piped()) .stderr(Stdio::null()); - // Spawn in a new session so that interactive shells job-control setup + // Spawn in a new session so that interactive shell job-control setup // cannot steal the terminal foreground from the parent goose process. cmd.wrap(ProcessSession); diff --git a/crates/goose-mcp/src/windows_job.rs b/crates/goose-mcp/src/windows_job.rs deleted file mode 100644 index 67ee7a4cee..0000000000 --- a/crates/goose-mcp/src/windows_job.rs +++ /dev/null @@ -1,22 +0,0 @@ -// Stub Windows Job Object helpers for MCP path (no-ops on Windows). -// This file provides the minimal surface required by crates/goose-mcp/src/subprocess.rs -// when built on Windows. The real Windows Job Object integration lives in the goose crate. - -#[cfg(windows)] -pub type HANDLE = *mut std::ffi::c_void; - -#[cfg(windows)] -pub fn ensure_job_object() -> Option { - None -} - -#[cfg(windows)] -pub fn attach_pid_to_job(_pid: u32) {} - -#[cfg(windows)] -pub fn init_windows_cleanup() {} - -#[cfg(windows)] -pub fn windows_cleanup_enabled() -> bool { - false -} diff --git a/crates/goose-provider-types/Cargo.toml b/crates/goose-provider-types/Cargo.toml new file mode 100644 index 0000000000..0e651492b2 --- /dev/null +++ b/crates/goose-provider-types/Cargo.toml @@ -0,0 +1,41 @@ +[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-providers/src/base.rs b/crates/goose-provider-types/src/base.rs similarity index 90% rename from crates/goose-providers/src/base.rs rename to crates/goose-provider-types/src/base.rs index 8ee3034303..357215fdaa 100644 --- a/crates/goose-providers/src/base.rs +++ b/crates/goose-provider-types/src/base.rs @@ -41,6 +41,10 @@ pub struct ProviderMetadata { /// 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 { @@ -66,6 +70,7 @@ impl ProviderMetadata { config_keys, setup_steps: vec![], model_selection_hint: None, + fast_model: None, } } @@ -88,6 +93,7 @@ impl ProviderMetadata { config_keys, setup_steps: vec![], model_selection_hint: None, + fast_model: None, } } @@ -102,6 +108,7 @@ impl ProviderMetadata { config_keys: vec![], setup_steps: vec![], model_selection_hint: None, + fast_model: None, } } @@ -114,6 +121,11 @@ impl ProviderMetadata { 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 @@ -291,12 +303,12 @@ pub fn model_info_for_provider_model(provider_name: &str, model_name: &str) -> M let reasoning = canonical .as_ref() .and_then(|model| model.reasoning) - .unwrap_or_else(|| ModelConfig::new_or_fail(model_name).is_reasoning_model()); + .unwrap_or_else(|| ModelConfig::new(model_name).is_reasoning_model()); ModelInfo { name: model_name.to_string(), resolved_model: None, - context_limit: ModelConfig::new_or_fail(model_name) + context_limit: ModelConfig::new(model_name) .with_canonical_limits(provider_name) .context_limit(), input_token_cost: None, @@ -371,75 +383,34 @@ pub trait Provider: Send + Sync { fn get_name(&self) -> &str; /// Primary streaming method that all providers must implement. - /// - /// Note: Do not add `#[instrument]` here โ€” the call sites (`complete` and - /// `stream_response_from_provider`) create the telemetry span so that - /// `session.id` is set once rather than in every provider. async fn stream( &self, model_config: &ModelConfig, - session_id: &str, system: &str, messages: &[Message], tools: &[Tool], ) -> Result; - /// Complete with a specific model config. - #[tracing::instrument( - skip(self, model_config, session_id, system, messages, tools), - fields(session.id = %session_id, gen_ai.request.model = %model_config.model_name) - )] async fn complete( &self, model_config: &ModelConfig, - session_id: &str, system: &str, messages: &[Message], tools: &[Tool], ) -> Result<(Message, ProviderUsage), ProviderError> { - let stream = self - .stream(model_config, session_id, system, messages, tools) - .await?; + let stream = self.stream(model_config, system, messages, tools).await?; collect_stream(stream).await } - /// Try fast model first, fall back to regular model on failure. - async fn complete_fast( - &self, - session_id: &str, - system: &str, - messages: &[Message], - tools: &[Tool], - ) -> Result<(Message, ProviderUsage), ProviderError> { - let model_config = self.get_model_config(); - let fast_config = model_config.use_fast_model(); - - let result = self - .complete(&fast_config, session_id, system, messages, tools) - .await; - - match result { - Ok(response) => Ok(response), - Err(e) => { - if fast_config.model_name != model_config.model_name { - tracing::warn!( - "Fast model {} failed with error: {}. Falling back to regular model {}", - fast_config.model_name, - e, - model_config.model_name - ); - self.complete(&model_config, session_id, system, messages, tools) - .await - } else { - Err(e) - } - } - } + /// 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()) } - /// Get the model config from the provider - fn get_model_config(&self) -> ModelConfig; - fn retry_config(&self) -> RetryConfig { RetryConfig::default() } @@ -466,7 +437,10 @@ pub trait Provider: Send + Sync { } /// Fetch inventory models filtered by canonical registry and usability. - async fn fetch_recommended_models(&self) -> Result, ProviderError> { + /// + /// 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() { @@ -496,7 +470,7 @@ pub trait Provider: Send + Sync { return None; } - if !canonical_model.tool_call && !self.get_model_config().toolshim { + if !canonical_model.tool_call && !toolshim { return None; } @@ -526,9 +500,12 @@ pub trait Provider: Send + Sync { } } - async fn fetch_recommended_model_info(&self) -> Result, ProviderError> { + async fn fetch_recommended_model_info( + &self, + toolshim: bool, + ) -> Result, ProviderError> { Ok(self - .fetch_recommended_models() + .fetch_recommended_models(toolshim) .await? .iter() .map(|model_name| model_info_for_provider_model(self.get_name(), model_name)) @@ -558,10 +535,6 @@ pub trait Provider: Send + Sync { false } - async fn supports_cache_control(&self) -> bool { - false - } - /// Configure OAuth authentication for this provider /// /// This method is called when a provider has configuration keys marked with oauth_flow = true. diff --git a/crates/goose-providers/src/canonical.rs b/crates/goose-provider-types/src/canonical.rs similarity index 97% rename from crates/goose-providers/src/canonical.rs rename to crates/goose-provider-types/src/canonical.rs index 9445e1eb72..cb92b606cd 100644 --- a/crates/goose-providers/src/canonical.rs +++ b/crates/goose-provider-types/src/canonical.rs @@ -28,7 +28,7 @@ impl ModelMapping { /// /// This avoids network calls by looking up all known models for the provider, /// filtering to text-input + tool-calling models, and sorting by release date. -/// The returned names are the canonical short names (e.g. "claude-3.5-sonnet"). +/// The returned names are the canonical short names (e.g. "claude-sonnet-4.5"). /// /// TODO: This trades speed for correctness โ€” the canonical registry may not perfectly /// match what the provider API returns (new models not yet in the registry, deprecated @@ -110,8 +110,8 @@ mod tests { #[test] fn cloud_provider_retains_cost() { - let canonical = maybe_get_canonical_model("anthropic", "claude-3-5-sonnet-20241022") - .expect("claude-3.5-sonnet should resolve"); + let canonical = maybe_get_canonical_model("anthropic", "claude-sonnet-4-5-20250929") + .expect("claude-sonnet-4.5 should resolve"); assert!(canonical.cost.input.is_some()); assert!(canonical.cost.output.is_some()); } diff --git a/crates/goose-providers/src/canonical/README.md b/crates/goose-provider-types/src/canonical/README.md similarity index 74% rename from crates/goose-providers/src/canonical/README.md rename to crates/goose-provider-types/src/canonical/README.md index f0c682f2ce..4b00a8e09f 100644 --- a/crates/goose-providers/src/canonical/README.md +++ b/crates/goose-provider-types/src/canonical/README.md @@ -1,8 +1,8 @@ # Canonical Model System Provides a unified view of model metadata (pricing, capabilities, context limits) across different LLM providers. -Normalizes provider-specific model names (e.g., `claude-3-5-sonnet-20241022`) -to canonical IDs (e.g., `anthropic/claude-3.5-sonnet`). +Normalizes provider-specific model names (e.g., `claude-sonnet-4-5-20250929`) +to canonical IDs (e.g., `anthropic/claude-sonnet-4.5`). ## Build Canonical Models Fetches latest model metadata from OpenRouter and validates provider mappings: @@ -13,11 +13,11 @@ cargo run --bin build_canonical_models --no-check # Build only, skip checker This script performs two operations by default: 1. **Builds canonical models** - Fetches from OpenRouter API and updates the registry - - Writes to: `crates/goose-providers/src/canonical/data/canonical_models.json` + - Writes to: `crates/goose-provider-types/src/canonical/data/canonical_models.json` 2. **Checks model mappings** (unless `--no-check` is passed) - Tests provider mappings and tracks changes over time - Reports unmapped models - Compares with previous runs (like a lock file) - Shows changed/added/removed mappings - - Writes to: `crates/goose-providers/src/canonical/data/canonical_mapping_report.json` + - Writes to: `crates/goose-provider-types/src/canonical/data/canonical_mapping_report.json` The script is currently built from `crates/goose/src/bin/build_canonical_models.rs` and writes into this crate's `src/canonical/data` directory. diff --git a/crates/goose-providers/src/canonical/catalog.rs b/crates/goose-provider-types/src/canonical/catalog.rs similarity index 100% rename from crates/goose-providers/src/canonical/catalog.rs rename to crates/goose-provider-types/src/canonical/catalog.rs diff --git a/crates/goose-providers/src/canonical/data/canonical_mapping_report.json b/crates/goose-provider-types/src/canonical/data/canonical_mapping_report.json similarity index 100% rename from crates/goose-providers/src/canonical/data/canonical_mapping_report.json rename to crates/goose-provider-types/src/canonical/data/canonical_mapping_report.json diff --git a/crates/goose-providers/src/canonical/data/canonical_models.json b/crates/goose-provider-types/src/canonical/data/canonical_models.json similarity index 96% rename from crates/goose-providers/src/canonical/data/canonical_models.json rename to crates/goose-provider-types/src/canonical/data/canonical_models.json index 44ef3ee132..793c3fa836 100644 --- a/crates/goose-providers/src/canonical/data/canonical_models.json +++ b/crates/goose-provider-types/src/canonical/data/canonical_models.json @@ -4852,6 +4852,70 @@ "output": 128000 } }, + { + "id": "aihubmix/claude-opus-4.8", + "name": "Claude Opus 4.8", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01", + "release_date": "2026-05-28", + "last_updated": "2026-05-28", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 200000, + "output": 32000 + } + }, + { + "id": "aihubmix/claude-opus-4.8-think", + "name": "Claude Opus 4.8", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01", + "release_date": "2026-05-28", + "last_updated": "2026-05-28", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 200000, + "output": 32000 + } + }, { "id": "aihubmix/claude-sonnet-4.6", "name": "Claude Sonnet 4.6", @@ -5511,6 +5575,35 @@ "output": 65536 } }, + { + "id": "aihubmix/glm-5.2", + "name": "GLM-5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.1268, + "output": 3.9438, + "cache_read": 0.2817 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "aihubmix/glm-5v-turbo", "name": "GLM 5 Vision Turbo", @@ -6046,6 +6139,67 @@ "output": 64000 } }, + { + "id": "aihubmix/qwen3.7-max", + "name": "Qwen3.7 Max", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-05-21", + "last_updated": "2026-05-21", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.69, + "output": 5.07, + "cache_read": 0.169, + "cache_write": 2.1125 + }, + "limit": { + "context": 991000, + "output": 64000 + } + }, + { + "id": "aihubmix/qwen3.7-plus", + "name": "Qwen3.7 Plus", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2026-06-02", + "last_updated": "2026-06-02", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.282, + "output": 1.128, + "cache_read": 0.0564, + "cache_write": 0.3525 + }, + "limit": { + "context": 991000, + "output": 64000 + } + }, { "id": "aihubmix/xiaomi-mimo-v2.5", "name": "Xiaomi MiMo-V2.5", @@ -6656,6 +6810,36 @@ "output": 128000 } }, + { + "id": "alibaba-cn/glm-5.2", + "name": "GLM-5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.1, + "output": 3.851, + "cache_read": 0.275, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "alibaba-cn/kimi-k2-thinking", "name": "Moonshot Kimi K2 Thinking", @@ -12114,6 +12298,39 @@ "output": 8192 } }, + { + "id": "amazon-bedrock/anthropic.claude-fable-5", + "name": "Claude Fable 5", + "family": "claude-fable", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-09", + "last_updated": "2026-06-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 10.0, + "output": 50.0, + "cache_read": 1.0, + "cache_write": 12.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "amazon-bedrock/anthropic.claude-haiku-4.5-20251001-v1:0", "name": "Claude Haiku 4.5", @@ -12287,6 +12504,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -12377,6 +12595,39 @@ "output": 64000 } }, + { + "id": "amazon-bedrock/anthropic.claude-sonnet-5", + "name": "Claude Sonnet 5", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "amazon-bedrock/au.anthropic.claude-haiku-4.5-20251001-v1:0", "name": "Claude Haiku 4.5 (AU)", @@ -12451,6 +12702,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -12541,6 +12793,39 @@ "output": 128000 } }, + { + "id": "amazon-bedrock/au.anthropic.claude-sonnet-5", + "name": "Claude Sonnet 5 (AU)", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "amazon-bedrock/deepseek.r1-v1:0", "name": "DeepSeek-R1", @@ -12642,7 +12927,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -12683,10 +12969,10 @@ }, "open_weights": false, "cost": { - "input": 1.0, - "output": 5.0, - "cache_read": 0.1, - "cache_write": 1.25 + "input": 1.1, + "output": 5.5, + "cache_read": 0.11, + "cache_write": 1.375 }, "limit": { "context": 200000, @@ -12716,10 +13002,10 @@ }, "open_weights": false, "cost": { - "input": 5.0, - "output": 25.0, - "cache_read": 0.5, - "cache_write": 6.25 + "input": 5.5, + "output": 27.5, + "cache_read": 0.55, + "cache_write": 6.875 }, "limit": { "context": 200000, @@ -12800,6 +13086,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -12890,6 +13177,39 @@ "output": 64000 } }, + { + "id": "amazon-bedrock/eu.anthropic.claude-sonnet-5", + "name": "Claude Sonnet 5 (EU)", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.2, + "output": 11.0, + "cache_read": 0.22, + "cache_write": 2.75 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "amazon-bedrock/global.anthropic.claude-fable-5", "name": "Claude Fable 5 (Global)", @@ -12904,7 +13224,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -13062,6 +13383,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -13152,6 +13474,39 @@ "output": 64000 } }, + { + "id": "amazon-bedrock/global.anthropic.claude-sonnet-5", + "name": "Claude Sonnet 5 (Global)", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "amazon-bedrock/google.gemma-3-12b-it", "name": "Google Gemma 3 12B", @@ -13241,6 +13596,39 @@ "output": 4096 } }, + { + "id": "amazon-bedrock/jp.anthropic.claude-haiku-4.5-20251001-v1:0", + "name": "Claude Haiku 4.5 (JP)", + "family": "claude-haiku", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-02-28", + "release_date": "2025-10-15", + "last_updated": "2025-10-15", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 5.0, + "cache_read": 0.1, + "cache_write": 1.25 + }, + "limit": { + "context": 200000, + "output": 64000 + } + }, { "id": "amazon-bedrock/jp.anthropic.claude-opus-4.7", "name": "Claude Opus 4.7 (JP)", @@ -13282,6 +13670,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -13372,6 +13761,39 @@ "output": 64000 } }, + { + "id": "amazon-bedrock/jp.anthropic.claude-sonnet-5", + "name": "Claude Sonnet 5 (JP)", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "amazon-bedrock/meta.llama3-1-70b-instruct-v1:0", "name": "Llama 3.1 70B Instruct", @@ -14478,7 +14900,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -14669,6 +15092,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -14759,6 +15183,39 @@ "output": 64000 } }, + { + "id": "amazon-bedrock/us.anthropic.claude-sonnet-5", + "name": "Claude Sonnet 5 (US)", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "amazon-bedrock/us.deepseek.r1-v1:0", "name": "DeepSeek-R1 (US)", @@ -14904,6 +15361,36 @@ "output": 8192 } }, + { + "id": "amazon-bedrock/xai.grok-4.3", + "name": "Grok 4.3", + "family": "grok", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-17", + "last_updated": "2026-06-28", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "amazon-bedrock/zai.glm-4.7", "name": "GLM-4.7", @@ -15085,204 +15572,6 @@ "output": 131072 } }, - { - "id": "anthropic/claude-3-haiku", - "name": "Claude Haiku 3", - "family": "claude-haiku", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2023-08-31", - "release_date": "2024-03-13", - "last_updated": "2024-03-13", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.25, - "output": 1.25, - "cache_read": 0.03, - "cache_write": 0.3 - }, - "limit": { - "context": 200000, - "output": 4096 - } - }, - { - "id": "anthropic/claude-3-opus", - "name": "Claude Opus 3", - "family": "claude-opus", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2023-08-31", - "release_date": "2024-02-29", - "last_updated": "2024-02-29", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 15.0, - "output": 75.0, - "cache_read": 1.5, - "cache_write": 18.75 - }, - "limit": { - "context": 200000, - "output": 4096 - } - }, - { - "id": "anthropic/claude-3-sonnet", - "name": "Claude Sonnet 3", - "family": "claude-sonnet", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2023-08-31", - "release_date": "2024-03-04", - "last_updated": "2024-03-04", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 3.0, - "output": 15.0, - "cache_read": 0.3, - "cache_write": 0.3 - }, - "limit": { - "context": 200000, - "output": 4096 - } - }, - { - "id": "anthropic/claude-3.5-haiku", - "name": "Claude Haiku 3.5 (latest)", - "family": "claude-haiku", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-07-31", - "release_date": "2024-10-22", - "last_updated": "2024-10-22", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.8, - "output": 4.0, - "cache_read": 0.08, - "cache_write": 1.0 - }, - "limit": { - "context": 200000, - "output": 8192 - } - }, - { - "id": "anthropic/claude-3.5-sonnet", - "name": "Claude Sonnet 3.5 v2", - "family": "claude-sonnet", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-04-30", - "release_date": "2024-10-22", - "last_updated": "2024-10-22", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 3.0, - "output": 15.0, - "cache_read": 0.3, - "cache_write": 3.75 - }, - "limit": { - "context": 200000, - "output": 8192 - } - }, - { - "id": "anthropic/claude-3.7-sonnet", - "name": "Claude Sonnet 3.7", - "family": "claude-sonnet", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-10-31", - "release_date": "2025-02-19", - "last_updated": "2025-02-19", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 3.0, - "output": 15.0, - "cache_read": 0.3, - "cache_write": 3.75 - }, - "limit": { - "context": 200000, - "output": 64000 - } - }, { "id": "anthropic/claude-fable-5", "name": "Claude Fable 5", @@ -15292,7 +15581,7 @@ "thinking_mode": "always_on_adaptive", "tool_call": true, "temperature": false, - "release_date": "2026-06-09", + "release_date": "2026-06-07", "last_updated": "2026-06-09", "modalities": { "input": [ @@ -15349,72 +15638,6 @@ "output": 64000 } }, - { - "id": "anthropic/claude-opus-4", - "name": "Claude Opus 4", - "family": "claude-opus", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-03-31", - "release_date": "2025-05-22", - "last_updated": "2025-05-22", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 15.0, - "output": 75.0, - "cache_read": 1.5, - "cache_write": 18.75 - }, - "limit": { - "context": 200000, - "output": 32000 - } - }, - { - "id": "anthropic/claude-opus-4.0", - "name": "Claude Opus 4 (latest)", - "family": "claude-opus", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-03-31", - "release_date": "2025-05-22", - "last_updated": "2025-05-22", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 15.0, - "output": 75.0, - "cache_read": 1.5, - "cache_write": 18.75 - }, - "limit": { - "context": 200000, - "output": 32000 - } - }, { "id": "anthropic/claude-opus-4.1", "name": "Claude Opus 4.1 (latest)", @@ -15456,7 +15679,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", + "knowledge": "2025-05", "release_date": "2025-11-24", "last_updated": "2025-11-24", "modalities": { @@ -15491,7 +15714,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-05-31", - "release_date": "2026-02-05", + "release_date": "2026-02-04", "last_updated": "2026-03-13", "modalities": { "input": [ @@ -15525,7 +15748,7 @@ "tool_call": true, "temperature": false, "knowledge": "2026-01-31", - "release_date": "2026-04-16", + "release_date": "2026-04-14", "last_updated": "2026-04-16", "modalities": { "input": [ @@ -15558,6 +15781,7 @@ "thinking_mode": "adaptive", "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -15582,72 +15806,6 @@ "output": 128000 } }, - { - "id": "anthropic/claude-sonnet-4", - "name": "Claude Sonnet 4", - "family": "claude-sonnet", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-03-31", - "release_date": "2025-05-22", - "last_updated": "2025-05-22", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 3.0, - "output": 15.0, - "cache_read": 0.3, - "cache_write": 3.75 - }, - "limit": { - "context": 200000, - "output": 64000 - } - }, - { - "id": "anthropic/claude-sonnet-4.0", - "name": "Claude Sonnet 4 (latest)", - "family": "claude-sonnet", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-03-31", - "release_date": "2025-05-22", - "last_updated": "2025-05-22", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 3.0, - "output": 15.0, - "cache_read": 0.3, - "cache_write": 3.75 - }, - "limit": { - "context": 200000, - "output": 64000 - } - }, { "id": "anthropic/claude-sonnet-4.5", "name": "Claude Sonnet 4.5 (latest)", @@ -15677,7 +15835,7 @@ "cache_write": 3.75 }, "limit": { - "context": 200000, + "context": 1000000, "output": 64000 } }, @@ -15712,7 +15870,41 @@ }, "limit": { "context": 1000000, - "output": 64000 + "output": 128000 + } + }, + { + "id": "anthropic/claude-sonnet-5", + "name": "Claude Sonnet 5", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "thinking_mode": "adaptive", + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-29", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "limit": { + "context": 1000000, + "output": 128000 } }, { @@ -17290,6 +17482,39 @@ "output": 128000 } }, + { + "id": "azure-cognitive-services/claude-opus-4.8", + "name": "Claude Opus 4.8", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-12-31", + "release_date": "2026-05-28", + "last_updated": "2026-05-28", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 200000, + "output": 128000 + } + }, { "id": "azure-cognitive-services/claude-sonnet-4.5", "name": "Claude Sonnet 4.5", @@ -20027,6 +20252,39 @@ "output": 128000 } }, + { + "id": "azure/claude-opus-4.8", + "name": "Claude Opus 4.8", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-12-31", + "release_date": "2026-05-28", + "last_updated": "2026-05-28", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 200000, + "output": 128000 + } + }, { "id": "azure/claude-sonnet-4.5", "name": "Claude Sonnet 4.5", @@ -20093,6 +20351,39 @@ "output": 64000 } }, + { + "id": "azure/claude-sonnet-5", + "name": "Claude Sonnet 5", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "azure/codestral", "name": "Codestral 25.01", @@ -23102,8 +23393,8 @@ "cache_read": 0.145 }, "limit": { - "context": 131000, - "output": 131000 + "context": 262144, + "output": 262144 } }, { @@ -23401,8 +23692,8 @@ "cache_read": 0.3 }, "limit": { - "context": 131072, - "output": 131072 + "context": 202720, + "output": 202720 } }, { @@ -23578,8 +23869,8 @@ }, "open_weights": true, "cost": { - "input": 0.44, - "output": 0.99 + "input": 0.22, + "output": 0.83 }, "limit": { "context": 128000, @@ -23615,6 +23906,63 @@ "output": 8192 } }, + { + "id": "berget/zai-org/GLM-5.2", + "name": "GLM-5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.54, + "output": 4.84 + }, + "limit": { + "context": 524288, + "output": 32768 + } + }, + { + "id": "cerebras/gemma-4-31b", + "name": "Gemma 4 31B IT", + "family": "gemma", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-02", + "last_updated": "2026-07-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.99, + "output": 1.49 + }, + "limit": { + "context": 131072, + "output": 40960 + } + }, { "id": "cerebras/gpt-oss-120b", "name": "GPT OSS 120B", @@ -23702,73 +24050,16 @@ } }, { - "id": "chutes/NousResearch/DeepHermes-3-Mistral-24B-Preview", - "name": "DeepHermes 3 Mistral 24B Preview", - "family": "nousresearch", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0245, - "output": 0.0978, - "cache_read": 0.01225 - }, - "limit": { - "context": 32768, - "output": 32768 - } - }, - { - "id": "chutes/NousResearch/Hermes-4-14B", - "name": "Hermes 4 14B", - "family": "nousresearch", + "id": "chutes/Qwen/Qwen3-235B-A22B-Thinking-2507-TEE", + "name": "Qwen3 235B A22B Thinking 2507 TEE", + "family": "qwen", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0136, - "output": 0.0543, - "cache_read": 0.0068 - }, - "limit": { - "context": 40960, - "output": 40960 - } - }, - { - "id": "chutes/Qwen/Qwen2.5-72B-Instruct", - "name": "Qwen2.5 72B Instruct", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", + "knowledge": "2025-04", + "release_date": "2025-07", + "last_updated": "2026-06-21", "modalities": { "input": [ "text" @@ -23783,158 +24074,11 @@ "output": 1.1957, "cache_read": 0.14945 }, - "limit": { - "context": 32768, - "output": 32768 - } - }, - { - "id": "chutes/Qwen/Qwen2.5-Coder-32B-Instruct", - "name": "Qwen2.5 Coder 32B Instruct", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0272, - "output": 0.1087, - "cache_read": 0.0136 - }, - "limit": { - "context": 32768, - "output": 32768 - } - }, - { - "id": "chutes/Qwen/Qwen2.5-VL-32B-Instruct", - "name": "Qwen2.5 VL 32B Instruct", - "family": "qwen", - "attachment": true, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0543, - "output": 0.2174, - "cache_read": 0.02715 - }, - "limit": { - "context": 16384, - "output": 16384 - } - }, - { - "id": "chutes/Qwen/Qwen3-235B-A22B-Instruct-2507-TEE", - "name": "Qwen3 235B A22B Instruct 2507 TEE", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2025-04", - "release_date": "2025-04", - "last_updated": "2025-04", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.1, - "output": 0.6, - "cache_read": 0.05 - }, - "limit": { - "context": 262144, - "output": 65536 - } - }, - { - "id": "chutes/Qwen/Qwen3-235B-A22B-Thinking", - "name": "Qwen3 235B A22B Thinking 2507", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.11, - "output": 0.6, - "cache_read": 0.055 - }, "limit": { "context": 262144, "output": 262144 } }, - { - "id": "chutes/Qwen/Qwen3-30B-A3B", - "name": "Qwen3 30B A3B", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.06, - "output": 0.22, - "cache_read": 0.03 - }, - "limit": { - "context": 40960, - "output": 40960 - } - }, { "id": "chutes/Qwen/Qwen3-32B-TEE", "name": "Qwen3 32B TEE", @@ -23956,73 +24100,15 @@ }, "open_weights": true, "cost": { - "input": 0.08, - "output": 0.24, - "cache_read": 0.04 + "input": 0.104, + "output": 0.416, + "cache_read": 0.052 }, "limit": { "context": 40960, "output": 40960 } }, - { - "id": "chutes/Qwen/Qwen3-Coder-Next-TEE", - "name": "Qwen3 Coder Next TEE", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2026-04-25", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.12, - "output": 0.75, - "cache_read": 0.06 - }, - "limit": { - "context": 262144, - "output": 65536 - } - }, - { - "id": "chutes/Qwen/Qwen3-Next-80B-A3B-Instruct", - "name": "Qwen3 Next 80B A3B Instruct", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.1, - "output": 0.8, - "cache_read": 0.05 - }, - "limit": { - "context": 262144, - "output": 262144 - } - }, { "id": "chutes/Qwen/Qwen3.5-397B-A17B-TEE", "name": "Qwen3.5 397B A17B TEE", @@ -24044,9 +24130,9 @@ }, "open_weights": true, "cost": { - "input": 0.39, - "output": 2.34, - "cache_read": 0.195 + "input": 0.45, + "output": 3.0, + "cache_read": 0.225 }, "limit": { "context": 262144, @@ -24074,191 +24160,15 @@ }, "open_weights": true, "cost": { - "input": 0.195, - "output": 1.56, - "cache_read": 0.0975 + "input": 0.3, + "output": 2.0, + "cache_read": 0.15 }, "limit": { "context": 262144, "output": 65536 } }, - { - "id": "chutes/Qwen/Qwen3Guard-Gen-0.6B", - "name": "Qwen3Guard Gen 0.6B", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.01, - "output": 0.0109, - "cache_read": 0.005 - }, - "limit": { - "context": 32768, - "output": 8192 - } - }, - { - "id": "chutes/XiaomiMiMo/MiMo-V2-Flash-TEE", - "name": "MiMo V2 Flash TEE", - "family": "mimo", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-12-01", - "release_date": "2025-12-16", - "last_updated": "2026-02-04", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.09, - "output": 0.29, - "cache_read": 0.045 - }, - "limit": { - "context": 262144, - "output": 65536 - } - }, - { - "id": "chutes/deepseek-ai/DeepSeek-R1-0528-TEE", - "name": "DeepSeek R1 0528 TEE", - "family": "deepseek-thinking", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-07", - "release_date": "2025-01-20", - "last_updated": "2025-05-29", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.45, - "output": 2.15, - "cache_read": 0.225 - }, - "limit": { - "context": 163840, - "output": 65536 - } - }, - { - "id": "chutes/deepseek-ai/DeepSeek-R1-Distill-Llama-70B", - "name": "DeepSeek R1 Distill Llama 70B", - "family": "deepseek-thinking", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0272, - "output": 0.1087, - "cache_read": 0.0136 - }, - "limit": { - "context": 131072, - "output": 131072 - } - }, - { - "id": "chutes/deepseek-ai/DeepSeek-V3-0324-TEE", - "name": "DeepSeek V3 0324 TEE", - "family": "deepseek", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.25, - "output": 1.0, - "cache_read": 0.125 - }, - "limit": { - "context": 163840, - "output": 65536 - } - }, - { - "id": "chutes/deepseek-ai/DeepSeek-V3.1-TEE", - "name": "DeepSeek V3.1 TEE", - "family": "deepseek", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.27, - "output": 1.0, - "cache_read": 0.135 - }, - "limit": { - "context": 163840, - "output": 65536 - } - }, { "id": "chutes/deepseek-ai/DeepSeek-V3.2-TEE", "name": "DeepSeek V3.2 TEE", @@ -24267,8 +24177,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", + "release_date": "2025-12", + "last_updated": "2026-06-21", "modalities": { "input": [ "text" @@ -24279,9 +24189,9 @@ }, "open_weights": true, "cost": { - "input": 0.28, - "output": 0.42, - "cache_read": 0.14 + "input": 1.0, + "output": 1.0, + "cache_read": 0.5 }, "limit": { "context": 131072, @@ -24309,9 +24219,9 @@ }, "open_weights": true, "cost": { - "input": 0.13, - "output": 0.38, - "cache_read": 0.065 + "input": 0.12, + "output": 0.37, + "cache_read": 0.06 }, "limit": { "context": 131072, @@ -24373,9 +24283,9 @@ }, "open_weights": true, "cost": { - "input": 0.95, - "output": 4.0, - "cache_read": 0.475 + "input": 0.66, + "output": 3.5, + "cache_read": 0.33 }, "limit": { "context": 262144, @@ -24383,48 +24293,19 @@ } }, { - "id": "chutes/openai/gpt-oss-120b-TEE", - "name": "gpt oss 120b TEE", - "family": "gpt-oss", + "id": "chutes/unsloth/Mistral-Nemo-Instruct-2407-TEE", + "name": "Mistral Nemo Instruct 2407 TEE", + "family": "mistral-nemo", "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.09, - "output": 0.36, - "cache_read": 0.045 - }, - "limit": { - "context": 131072, - "output": 65536 - } - }, - { - "id": "chutes/rednote-hilab/dots.ocr", - "name": "dots.ocr", - "family": "rednote", - "attachment": true, "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", + "knowledge": "2024-07", + "release_date": "2024-07-01", + "last_updated": "2024-07-01", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" @@ -24432,310 +24313,15 @@ }, "open_weights": true, "cost": { - "input": 0.01, - "output": 0.0109, - "cache_read": 0.005 + "input": 0.0245, + "output": 0.0978, + "cache_read": 0.01225 }, "limit": { "context": 131072, "output": 131072 } }, - { - "id": "chutes/tngtech/DeepSeek-TNG-R1T2-Chimera-TEE", - "name": "DeepSeek TNG R1T2 Chimera TEE", - "family": "deepseek", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-04-25", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.3, - "output": 1.1, - "cache_read": 0.15 - }, - "limit": { - "context": 163840, - "output": 163840 - } - }, - { - "id": "chutes/unsloth/Llama-3.2-1B-Instruct", - "name": "Llama 3.2 1B Instruct", - "family": "unsloth", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2026-01-27", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.01, - "output": 0.0109, - "cache_read": 0.005 - }, - "limit": { - "context": 16384, - "output": 8192 - } - }, - { - "id": "chutes/unsloth/Llama-3.2-3B-Instruct", - "name": "Llama 3.2 3B Instruct", - "family": "unsloth", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-02-12", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.01, - "output": 0.0136, - "cache_read": 0.005 - }, - "limit": { - "context": 16384, - "output": 16384 - } - }, - { - "id": "chutes/unsloth/Mistral-Nemo-Instruct", - "name": "Mistral Nemo Instruct 2407", - "family": "unsloth", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.02, - "output": 0.04, - "cache_read": 0.01 - }, - "limit": { - "context": 131072, - "output": 131072 - } - }, - { - "id": "chutes/unsloth/gemma-3-12b-it", - "name": "gemma 3 12b it", - "family": "unsloth", - "attachment": true, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.03, - "output": 0.1, - "cache_read": 0.015 - }, - "limit": { - "context": 131072, - "output": 131072 - } - }, - { - "id": "chutes/unsloth/gemma-3-27b-it", - "name": "gemma 3 27b it", - "family": "unsloth", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0272, - "output": 0.1087, - "cache_read": 0.0136 - }, - "limit": { - "context": 128000, - "output": 65536 - } - }, - { - "id": "chutes/unsloth/gemma-3-4b-it", - "name": "gemma 3 4b it", - "family": "unsloth", - "attachment": true, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.01, - "output": 0.0272, - "cache_read": 0.005 - }, - "limit": { - "context": 96000, - "output": 96000 - } - }, - { - "id": "chutes/zai-org/GLM-4.6V", - "name": "GLM 4.6V", - "family": "glm", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.3, - "output": 0.9, - "cache_read": 0.15 - }, - "limit": { - "context": 131072, - "output": 65536 - } - }, - { - "id": "chutes/zai-org/GLM-4.7-FP8", - "name": "GLM 4.7 FP8", - "family": "glm", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-01-27", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.2989, - "output": 1.1957, - "cache_read": 0.14945 - }, - "limit": { - "context": 202752, - "output": 65535 - } - }, - { - "id": "chutes/zai-org/GLM-4.7-TEE", - "name": "GLM 4.7 TEE", - "family": "glm", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-04", - "release_date": "2025-12-22", - "last_updated": "2025-12-22", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.39, - "output": 1.75, - "cache_read": 0.195 - }, - "limit": { - "context": 202752, - "output": 65535 - } - }, { "id": "chutes/zai-org/GLM-5-TEE", "name": "GLM 5 TEE", @@ -24765,35 +24351,6 @@ "output": 65535 } }, - { - "id": "chutes/zai-org/GLM-5-Turbo", - "name": "GLM 5 Turbo", - "family": "glm", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-03-11", - "last_updated": "2026-04-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.4891, - "output": 1.9565, - "cache_read": 0.24455 - }, - "limit": { - "context": 202752, - "output": 65535 - } - }, { "id": "chutes/zai-org/GLM-5.1-TEE", "name": "GLM 5.1 TEE", @@ -24814,15 +24371,44 @@ }, "open_weights": true, "cost": { - "input": 1.05, - "output": 3.5, - "cache_read": 0.525 + "input": 0.98, + "output": 3.08, + "cache_read": 0.49 }, "limit": { "context": 202752, "output": 65535 } }, + { + "id": "chutes/zai-org/GLM-5.2-TEE", + "name": "GLM 5.2 TEE", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.7 + }, + "limit": { + "context": 1048576, + "output": 65535 + } + }, { "id": "clarifai/arcee_ai/AFM/models/trinity-mini", "name": "Trinity Mini", @@ -25777,6 +25363,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -25900,6 +25487,39 @@ "output": 64000 } }, + { + "id": "cloudflare-ai-gateway/anthropic/claude-sonnet-5", + "name": "Claude Sonnet 5", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "cloudflare-ai-gateway/openai/gpt-3.5-turbo", "name": "GPT-3.5-turbo", @@ -28850,6 +28470,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -29472,7 +29093,7 @@ "name": "GPT Oss 120b", "family": "gpt-oss", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "knowledge": "2024-01", @@ -30888,7 +30509,7 @@ "name": "MiniMax-M2.5", "family": "minimax", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2026-02-12", @@ -31009,6 +30630,1184 @@ "output": 262144 } }, + { + "id": "crossmodel/anthropic/claude-fable-5", + "name": "Claude Fable 5", + "family": "claude-fable", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-09", + "last_updated": "2026-06-09", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 10.0, + "output": 50.0, + "cache_read": 1.0, + "cache_write": 12.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "crossmodel/anthropic/claude-haiku-4.5", + "name": "Claude Haiku 4.5 (latest)", + "family": "claude-haiku", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-02-28", + "release_date": "2025-10-15", + "last_updated": "2025-10-15", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 5.0, + "cache_read": 0.1, + "cache_write": 1.25 + }, + "limit": { + "context": 200000, + "output": 64000 + } + }, + { + "id": "crossmodel/anthropic/claude-opus-4.7", + "name": "Claude Opus 4.7", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "crossmodel/anthropic/claude-opus-4.8", + "name": "Claude Opus 4.8", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01", + "release_date": "2026-05-28", + "last_updated": "2026-05-28", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "crossmodel/anthropic/claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-08-31", + "release_date": "2026-02-17", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 15.0, + "cache_read": 0.3, + "cache_write": 3.75 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "crossmodel/anthropic/claude-sonnet-5", + "name": "Claude Sonnet 5", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "crossmodel/deepseek/deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "family": "deepseek-flash", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.16, + "output": 0.32, + "cache_read": 0.004, + "cache_write": 0.16 + }, + "limit": { + "context": 1000000, + "output": 65000 + } + }, + { + "id": "crossmodel/deepseek/deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "family": "deepseek-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.47, + "output": 0.94, + "cache_read": 0.005, + "cache_write": 0.47 + }, + "limit": { + "context": 1000000, + "output": 65000 + } + }, + { + "id": "crossmodel/gemini/gemini-2.5-flash", + "name": "Gemini 2.5 Flash", + "family": "gemini-flash", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 2.5, + "cache_read": 0.03, + "cache_write": 0.3 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "crossmodel/gemini/gemini-2.5-flash-lite", + "name": "Gemini 2.5 Flash-Lite", + "family": "gemini-flash-lite", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.1, + "output": 0.4, + "cache_read": 0.01, + "cache_write": 0.1 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "crossmodel/gemini/gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "family": "gemini-pro", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125, + "cache_write": 1.25 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "crossmodel/gemini/gemini-3-flash-preview", + "name": "Gemini 3 Flash Preview", + "family": "gemini-flash", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2025-12-17", + "last_updated": "2025-12-17", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.5, + "output": 3.0, + "cache_read": 0.05, + "cache_write": 0.5 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "crossmodel/gemini/gemini-3.1-pro-preview", + "name": "Gemini 3.1 Pro Preview", + "family": "gemini-pro", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-02-19", + "last_updated": "2026-02-19", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 12.0, + "cache_read": 0.2, + "cache_write": 2.0 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "crossmodel/gemini/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "family": "gemini-flash", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-05-19", + "last_updated": "2026-05-19", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.5, + "output": 9.0, + "cache_read": 0.15, + "cache_write": 1.5 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "crossmodel/minimax/minimax-m2.7", + "name": "MiniMax-M2.7", + "family": "minimax", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-18", + "last_updated": "2026-03-18", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.33, + "output": 1.32, + "cache_read": 0.066, + "cache_write": 0.42 + }, + "limit": { + "context": 204800, + "output": 131072 + } + }, + { + "id": "crossmodel/minimax/minimax-m3", + "name": "MiniMax-M3", + "family": "minimax", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-01", + "last_updated": "2026-06-01", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.33, + "output": 1.32, + "cache_read": 0.066, + "cache_write": 0.33 + }, + "limit": { + "context": 1024000, + "output": 512000 + } + }, + { + "id": "crossmodel/moonshot/kimi-k2.5", + "name": "Kimi K2.5", + "family": "kimi-k2", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-01", + "release_date": "2026-01", + "last_updated": "2026-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.62, + "output": 3.3, + "cache_read": 0.11, + "cache_write": 0.62 + }, + "limit": { + "context": 262000, + "output": 262000 + } + }, + { + "id": "crossmodel/moonshot/kimi-k2.6", + "name": "Kimi K2.6", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.0, + "output": 4.16, + "cache_read": 0.18, + "cache_write": 1.0 + }, + "limit": { + "context": 262000, + "output": 262000 + } + }, + { + "id": "crossmodel/moonshot/kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.0, + "output": 4.16, + "cache_read": 0.18, + "cache_write": 1.0 + }, + "limit": { + "context": 262000, + "output": 262000 + } + }, + { + "id": "crossmodel/openai/gpt-4o-mini", + "name": "GPT-4o mini", + "family": "gpt-mini", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2023-09", + "release_date": "2024-07-18", + "last_updated": "2024-07-18", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.15, + "output": 0.6, + "cache_read": 0.075, + "cache_write": 0.15 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, + { + "id": "crossmodel/openai/gpt-5.4", + "name": "GPT-5.4", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-05", + "last_updated": "2026-03-05", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.5, + "output": 15.0, + "cache_read": 0.25, + "cache_write": 2.5 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "crossmodel/openai/gpt-5.4-mini", + "name": "GPT-5.4 mini", + "family": "gpt-mini", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-17", + "last_updated": "2026-03-17", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.75, + "output": 4.5, + "cache_read": 0.075, + "cache_write": 0.75 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "crossmodel/openai/gpt-5.4-nano", + "name": "GPT-5.4 nano", + "family": "gpt-nano", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-17", + "last_updated": "2026-03-17", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.2, + "output": 1.25, + "cache_read": 0.02, + "cache_write": 0.2 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "crossmodel/openai/gpt-5.5", + "name": "GPT-5.5", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-12-01", + "release_date": "2026-04-23", + "last_updated": "2026-04-23", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 30.0, + "cache_read": 0.5, + "cache_write": 5.0 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "crossmodel/openai/gpt-5.5-pro", + "name": "GPT-5.5 Pro", + "family": "gpt-pro", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-12-01", + "release_date": "2026-04-23", + "last_updated": "2026-04-23", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 30.0, + "output": 180.0 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "crossmodel/qwen/qwen3.6-flash", + "name": "Qwen3.6 Flash", + "family": "qwen3.6", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-27", + "last_updated": "2026-04-27", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.19, + "output": 1.13, + "cache_read": 0.019, + "cache_write": 0.24 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "crossmodel/qwen/qwen3.6-plus", + "name": "Qwen3.6 Plus", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.32, + "output": 1.88, + "cache_read": 0.032, + "cache_write": 0.4 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "crossmodel/qwen/qwen3.7-max", + "name": "Qwen3.7 Max", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-05-21", + "last_updated": "2026-05-21", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.88, + "output": 5.63, + "cache_read": 0.375, + "cache_write": 2.35 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "crossmodel/qwen/qwen3.7-plus", + "name": "Qwen3.7 Plus", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2026-06-02", + "last_updated": "2026-06-02", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.32, + "output": 1.25, + "cache_read": 0.032, + "cache_write": 0.4 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "crossmodel/tencent/hy3-preview", + "name": "Hy3 preview", + "family": "Hy", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-20", + "last_updated": "2026-04-20", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.19, + "output": 0.63, + "cache_read": 0.063, + "cache_write": 0.19 + }, + "limit": { + "context": 256000, + "output": 128000 + } + }, + { + "id": "crossmodel/xiaomi/mimo-v2.5", + "name": "MiMo-V2.5", + "family": "mimo", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-12", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.16, + "output": 0.32, + "cache_read": 0.004, + "cache_write": 0.16 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "crossmodel/xiaomi/mimo-v2.5-pro", + "name": "MiMo-V2.5-Pro", + "family": "mimo", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-12", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.47, + "output": 0.94, + "cache_read": 0.005, + "cache_write": 0.47 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "crossmodel/z-ai/glm-4.7", + "name": "GLM-4.7", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2025-12-22", + "last_updated": "2025-12-22", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.47, + "output": 2.16, + "cache_read": 0.1, + "cache_write": 0.47 + }, + "limit": { + "context": 200000, + "output": 128000 + } + }, + { + "id": "crossmodel/z-ai/glm-5", + "name": "GLM-5", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-12", + "last_updated": "2026-02-12", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.6, + "output": 3.0, + "cache_read": 0.16, + "cache_write": 0.6 + }, + "limit": { + "context": 200000, + "output": 128000 + } + }, + { + "id": "crossmodel/z-ai/glm-5-turbo", + "name": "GLM-5-Turbo", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-16", + "last_updated": "2026-03-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.9, + "output": 3.7, + "cache_read": 0.18, + "cache_write": 0.9 + }, + "limit": { + "context": 200000, + "output": 128000 + } + }, + { + "id": "crossmodel/z-ai/glm-5.1", + "name": "GLM-5.1", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-07", + "last_updated": "2026-04-07", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.0, + "output": 3.8, + "cache_read": 0.2, + "cache_write": 1.0 + }, + "limit": { + "context": 200000, + "output": 128000 + } + }, + { + "id": "crossmodel/z-ai/glm-5.2", + "name": "GLM-5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.2, + "output": 4.4, + "cache_read": 0.3, + "cache_write": 1.2 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "databricks/databricks-claude-haiku-4.5", "name": "Claude Haiku 4.5 (latest)", @@ -31083,7 +31882,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", + "knowledge": "2025-05", "release_date": "2025-11-24", "last_updated": "2025-11-24", "modalities": { @@ -31837,14 +32636,102 @@ "cost": { "input": 0.15, "output": 1.15, - "cache_read": 0.03, - "cache_write": 0.375 + "cache_read": 0.03 }, "limit": { "context": 196608, "output": 131072 } }, + { + "id": "deepinfra/MiniMaxAI/MiniMax-M2.7", + "name": "MiniMax-M2.7", + "family": "minimax", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-18", + "last_updated": "2026-03-18", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.25, + "output": 1.0, + "cache_read": 0.05 + }, + "limit": { + "context": 196608, + "output": 131072 + } + }, + { + "id": "deepinfra/MiniMaxAI/MiniMax-M3", + "name": "MiniMax-M3", + "family": "minimax", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-01", + "last_updated": "2026-06-01", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 + }, + "limit": { + "context": 524288, + "output": 128000 + } + }, + { + "id": "deepinfra/Qwen/Qwen3-32B", + "name": "Qwen3 32B", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2025-04", + "last_updated": "2025-04", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.08, + "output": 0.28 + }, + "limit": { + "context": 40960, + "output": 16384 + } + }, { "id": "deepinfra/Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo", "name": "Qwen3 Coder 480B A35B Instruct Turbo", @@ -31867,13 +32754,135 @@ "open_weights": true, "cost": { "input": 0.3, - "output": 1.0 + "output": 1.0, + "cache_read": 0.1 }, "limit": { "context": 262144, "output": 66536 } }, + { + "id": "deepinfra/Qwen/Qwen3-Max", + "name": "Qwen3 Max", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2025-09-23", + "last_updated": "2025-09-23", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.2, + "output": 6.0, + "cache_read": 0.24 + }, + "limit": { + "context": 256000, + "output": 65536 + } + }, + { + "id": "deepinfra/Qwen/Qwen3-Next-80B-A3B-Instruct", + "name": "Qwen3-Next 80B-A3B Instruct", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2025-09", + "last_updated": "2025-09", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.09, + "output": 1.1 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, + { + "id": "deepinfra/Qwen/Qwen3.5-122B-A10B", + "name": "Qwen3.5 122B-A10B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-23", + "last_updated": "2026-02-23", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.29, + "output": 2.4 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, + { + "id": "deepinfra/Qwen/Qwen3.5-27B", + "name": "Qwen3.5 27B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-23", + "last_updated": "2026-02-23", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.26, + "output": 2.6 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, { "id": "deepinfra/Qwen/Qwen3.5-35B-A3B", "name": "Qwen 3.5 35B A3B", @@ -31938,6 +32947,67 @@ "output": 81920 } }, + { + "id": "deepinfra/Qwen/Qwen3.5-9B", + "name": "Qwen3.5 9B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-23", + "last_updated": "2026-02-23", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.1, + "output": 0.15 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, + { + "id": "deepinfra/Qwen/Qwen3.6-27B", + "name": "Qwen3.6 27B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.32, + "output": 3.2 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, { "id": "deepinfra/Qwen/Qwen3.6-35B-A3B", "name": "Qwen3.6 35B A3B", @@ -31968,6 +33038,35 @@ "output": 81920 } }, + { + "id": "deepinfra/Qwen/Qwen3.7-Max", + "name": "Qwen3.7 Max", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-05-21", + "last_updated": "2026-05-21", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.5, + "output": 7.5, + "cache_read": 0.5 + }, + "limit": { + "context": 256000, + "output": 65536 + } + }, { "id": "deepinfra/XiaomiMiMo/MiMo-V2.5", "name": "MiMo-V2.5", @@ -32005,7 +33104,7 @@ "id": "deepinfra/XiaomiMiMo/MiMo-V2.5-Pro", "name": "MiMo-V2.5-Pro", "family": "mimo", - "attachment": false, + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, @@ -32014,7 +33113,8 @@ "last_updated": "2026-04-22", "modalities": { "input": [ - "text" + "text", + "audio" ], "output": [ "text" @@ -32110,9 +33210,9 @@ }, "open_weights": true, "cost": { - "input": 0.1, - "output": 0.2, - "cache_read": 0.02 + "input": 0.09, + "output": 0.18, + "cache_read": 0.018 }, "limit": { "context": 1048576, @@ -32191,7 +33291,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "video" ], "output": [ "text" @@ -32238,7 +33339,7 @@ "id": "deepinfra/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", "name": "Llama 4 Maverick 17B FP8", "family": "llama", - "attachment": false, + "attachment": true, "reasoning": false, "tool_call": false, "release_date": "2025-04-05", @@ -32266,7 +33367,7 @@ "id": "deepinfra/meta-llama/Llama-4-Scout-17B-16E-Instruct", "name": "Llama 4 Scout 17B", "family": "llama", - "attachment": false, + "attachment": true, "reasoning": false, "tool_call": true, "release_date": "2025-04-05", @@ -32354,6 +33455,125 @@ "output": 16384 } }, + { + "id": "deepinfra/moonshotai/Kimi-K2.7-Code", + "name": "Kimi K2.7 Code", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.74, + "output": 3.5, + "cache_read": 0.15 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "deepinfra/nvidia/Llama-3.3-Nemotron-Super-49B-v1.5", + "name": "Llama 3.3 Nemotron Super 49B v1.5", + "family": "nemotron", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-07-25", + "last_updated": "2025-07-25", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.4, + "output": 0.4 + }, + "limit": { + "context": 131072, + "output": 131072 + } + }, + { + "id": "deepinfra/nvidia/Nemotron-3-Nano-30B-A3B", + "name": "Nemotron 3 Nano 30B A3B", + "family": "nemotron", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-12-15", + "last_updated": "2025-12-15", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.05, + "output": 0.2 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "deepinfra/nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning", + "name": "Nemotron 3 Nano Omni 30B A3B Reasoning", + "family": "nemotron", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-28", + "last_updated": "2026-04-28", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.2, + "output": 0.8 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, { "id": "deepinfra/openai/gpt-oss-120b", "name": "GPT OSS 120B", @@ -32374,8 +33594,8 @@ }, "open_weights": true, "cost": { - "input": 0.039, - "output": 0.19 + "input": 0.037, + "output": 0.17 }, "limit": { "context": 131072, @@ -32492,7 +33712,8 @@ "open_weights": true, "cost": { "input": 0.06, - "output": 0.4 + "output": 0.4, + "cache_read": 0.01 }, "limit": { "context": 202752, @@ -32559,6 +33780,35 @@ "output": 16384 } }, + { + "id": "deepinfra/zai-org/GLM-5.2", + "name": "GLM-5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.93, + "output": 3.0, + "cache_read": 0.18 + }, + "limit": { + "context": 1048576, + "output": 32768 + } + }, { "id": "deepseek/deepseek-chat", "name": "DeepSeek Chat", @@ -33653,6 +34903,64 @@ "output": 128000 } }, + { + "id": "digitalocean/glm-5.1", + "name": "GLM-5.1", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-07", + "last_updated": "2026-04-07", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.975, + "output": 4.3, + "cache_read": 0.26 + }, + "limit": { + "context": 200000, + "output": 131072 + } + }, + { + "id": "digitalocean/glm-5.2", + "name": "GLM-5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.05, + "output": 4.4, + "cache_read": 0.21 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "digitalocean/gte-large-en-v1.5", "name": "GTE Large (v1.5)", @@ -35275,41 +36583,14 @@ }, "open_weights": true, "cost": { - "input": 0.00236, - "output": 0.00236 + "input": 0.0023, + "output": 0.0023 }, "limit": { "context": 448, "output": 448 } }, - { - "id": "evroc/Qwen/Qwen3-30B-A3B-Instruct-2507-FP8", - "name": "Qwen3 30B 2507", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "release_date": "2025-07-30", - "last_updated": "2025-07-30", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.35, - "output": 1.42 - }, - "limit": { - "context": 64000, - "output": 64000 - } - }, { "id": "evroc/Qwen/Qwen3-Embedding-8B", "name": "Qwen3 Embedding 8B", @@ -35329,14 +36610,41 @@ }, "open_weights": true, "cost": { - "input": 0.12, - "output": 0.12 + "input": 0.115, + "output": 0.115 }, "limit": { "context": 40960, "output": 4096 } }, + { + "id": "evroc/Qwen/Qwen3-Reranker-4B", + "name": "Qwen3 Reranker 4B", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2025-07-30", + "last_updated": "2025-07-30", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0575, + "output": 0.0 + }, + "limit": { + "context": 32000, + "output": 4096 + } + }, { "id": "evroc/Qwen/Qwen3-VL-30B-A3B-Instruct", "name": "Qwen3 VL 30B", @@ -35358,14 +36666,104 @@ }, "open_weights": true, "cost": { - "input": 0.24, - "output": 0.94 + "input": 0.23, + "output": 0.92 }, "limit": { "context": 100000, "output": 100000 } }, + { + "id": "evroc/Qwen/Qwen3.6-35B-A3B-FP8", + "name": "Qwen3.6 35B-A3B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-17", + "last_updated": "2026-04-17", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.345, + "output": 1.38 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, + { + "id": "evroc/evroc/roc", + "name": "roc", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2026-01", + "release_date": "2026-06-06", + "last_updated": "2026-06-06", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.875, + "output": 11.516 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "evroc/google/gemma-4-26B-A4B-it", + "name": "Gemma 4 26B A4B IT", + "family": "gemma", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.144, + "output": 0.575 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, { "id": "evroc/intfloat/multilingual-e5-large-instruct", "name": "E5 Multi-Lingual Large Embeddings 0.6B", @@ -35385,8 +36783,8 @@ }, "open_weights": true, "cost": { - "input": 0.12, - "output": 0.12 + "input": 0.114, + "output": 0.114 }, "limit": { "context": 512, @@ -35394,14 +36792,15 @@ } }, { - "id": "evroc/microsoft/Phi-4-multimodal-instruct", - "name": "Phi-4 15B", - "family": "phi", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-01-01", - "last_updated": "2025-01-01", + "id": "evroc/mistralai/Mistral-Medium-3.5-128B", + "name": "Mistral Medium 3.5", + "family": "mistral-medium", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-29", + "last_updated": "2026-04-29", "modalities": { "input": [ "text", @@ -35413,39 +36812,12 @@ }, "open_weights": true, "cost": { - "input": 0.24, - "output": 0.47 + "input": 1.725, + "output": 6.9 }, "limit": { - "context": 32000, - "output": 32000 - } - }, - { - "id": "evroc/mistralai/Magistral-Small", - "name": "Magistral Small 1.2 24B", - "family": "magistral-small", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-06-01", - "last_updated": "2025-06-01", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.59, - "output": 2.36 - }, - "limit": { - "context": 131072, - "output": 131072 + "context": 262144, + "output": 262144 } }, { @@ -35468,8 +36840,8 @@ }, "open_weights": true, "cost": { - "input": 0.00236, - "output": 0.00236 + "input": 0.0023, + "output": 0.0023 }, "limit": { "context": 32000, @@ -35477,41 +36849,16 @@ } }, { - "id": "evroc/mistralai/devstral-small-2-24b-instruct", - "name": "Devstral Small 2 24B Instruct 2512", - "family": "devstral", - "attachment": false, - "reasoning": false, - "tool_call": true, - "release_date": "2025-12-01", - "last_updated": "2025-12-01", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.12, - "output": 0.47 - }, - "limit": { - "context": 32768, - "output": 32768 - } - }, - { - "id": "evroc/moonshotai/Kimi-K2.5", - "name": "Kimi K2.5", + "id": "evroc/moonshotai/Kimi-K2.6", + "name": "Kimi K2.6", "family": "kimi-k2", - "attachment": false, + "attachment": true, "reasoning": true, "tool_call": true, - "release_date": "2026-01-27", - "last_updated": "2026-01-27", + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", "modalities": { "input": [ "text", @@ -35524,8 +36871,8 @@ }, "open_weights": true, "cost": { - "input": 1.47, - "output": 5.9 + "input": 1.4375, + "output": 5.75 }, "limit": { "context": 262144, @@ -35534,13 +36881,15 @@ }, { "id": "evroc/nvidia/Llama-3.3-70B-Instruct-FP8", - "name": "Llama 3.3 70B", + "name": "Llama-3.3-70B-Instruct", "family": "llama", - "attachment": false, + "attachment": true, "reasoning": false, - "tool_call": false, - "release_date": "2024-12-01", - "last_updated": "2024-12-01", + "tool_call": true, + "temperature": true, + "knowledge": "2023-12", + "release_date": "2024-12-06", + "last_updated": "2024-12-06", "modalities": { "input": [ "text" @@ -35551,12 +36900,12 @@ }, "open_weights": true, "cost": { - "input": 1.18, - "output": 1.18 + "input": 1.15, + "output": 1.15 }, "limit": { - "context": 131072, - "output": 32768 + "context": 128000, + "output": 4096 } }, { @@ -35578,8 +36927,8 @@ }, "open_weights": true, "cost": { - "input": 0.24, - "output": 0.94 + "input": 0.23, + "output": 0.92 }, "limit": { "context": 65536, @@ -35605,14 +36954,41 @@ }, "open_weights": true, "cost": { - "input": 0.00236, - "output": 0.00236 + "input": 0.0023, + "output": 0.0023 }, "limit": { "context": 448, "output": 4096 } }, + { + "id": "evroc/openai/whisper-large-v3-turbo", + "name": "Whisper Large v3 Turbo", + "family": "whisper", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2024-10-01", + "last_updated": "2024-10-01", + "modalities": { + "input": [ + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0023, + "output": 0.0023 + }, + "limit": { + "context": 448, + "output": 448 + } + }, { "id": "fastrouter/anthropic/claude-opus-4.1", "name": "Claude Opus 4.1", @@ -35654,6 +37030,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -37100,10 +38477,10 @@ "cost": { "input": 1.4, "output": 4.4, - "cache_read": 0.26 + "cache_read": 0.14 }, "limit": { - "context": 1048576, + "context": 1048575, "output": 131072 } }, @@ -37342,6 +38719,35 @@ "output": 131072 } }, + { + "id": "fireworks-ai/accounts/fireworks/routers/glm-5p2-fast", + "name": "GLM 5.2 Fast", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-26", + "last_updated": "2026-06-26", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 2.1, + "output": 6.6, + "cache_read": 0.21 + }, + "limit": { + "context": 1048575, + "output": 131072 + } + }, { "id": "fireworks-ai/accounts/fireworks/routers/kimi-k2p6-fast", "name": "Kimi K2.6 Fast", @@ -37572,6 +38978,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -37794,9 +39201,10 @@ "name": "Qwen3 235B A22B Instruct 2507", "family": "qwen", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-04", "release_date": "2025-07-29", "last_updated": "2026-01-29", "modalities": { @@ -37818,71 +39226,15 @@ } }, { - "id": "friendli/meta-llama/Llama-3.1-8B-Instruct", - "name": "Llama 3.1 8B Instruct", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2024-08-01", - "last_updated": "2025-12-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.1, - "output": 0.1 - }, - "limit": { - "context": 131072, - "output": 8000 - } - }, - { - "id": "friendli/meta-llama/Llama-3.3-70B-Instruct", - "name": "Llama 3.3 70B Instruct", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2024-08-01", - "last_updated": "2025-12-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.6, - "output": 0.6 - }, - "limit": { - "context": 131072, - "output": 131072 - } - }, - { - "id": "friendli/zai-org/GLM-5", - "name": "GLM-5", - "family": "glm", + "id": "friendli/deepseek-ai/DeepSeek-V3.2", + "name": "DeepSeek-V3.2", + "family": "deepseek", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-12", - "last_updated": "2026-02-12", + "release_date": "2025-12-01", + "last_updated": "2025-12-01", "modalities": { "input": [ "text" @@ -37893,13 +39245,42 @@ }, "open_weights": true, "cost": { - "input": 1.0, - "output": 3.2, - "cache_read": 0.5 + "input": 0.5, + "output": 1.5, + "cache_read": 0.25 }, "limit": { - "context": 202752, - "output": 202752 + "context": 163840, + "output": 163840 + } + }, + { + "id": "friendli/google/gemma-4-31B-it", + "name": "Gemma 4 31B IT", + "family": "gemma", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.14, + "output": 0.4 + }, + "limit": { + "context": 262144, + "output": 32768 } }, { @@ -37931,6 +39312,35 @@ "output": 202752 } }, + { + "id": "friendli/zai-org/GLM-5.2", + "name": "GLM-5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.26 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "frogbot/claude-haiku-4.5", "name": "Claude Haiku 4.5", @@ -38817,7 +40227,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", + "knowledge": "2025-05", "release_date": "2025-11-24", "last_updated": "2025-11-24", "modalities": { @@ -38916,6 +40326,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -39039,6 +40450,39 @@ "output": 32000 } }, + { + "id": "github-copilot/claude-sonnet-5", + "name": "Claude Sonnet 5", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "github-copilot/gemini-2.5-pro", "name": "Gemini 2.5 Pro", @@ -39459,6 +40903,67 @@ "output": 128000 } }, + { + "id": "github-copilot/kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.19 + }, + "limit": { + "context": 256000, + "output": 32000 + } + }, + { + "id": "github-copilot/mai-code-1-flash-picker", + "name": "MAI-Code-1-Flash", + "family": "mai", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-12", + "release_date": "2026-06-02", + "last_updated": "2026-06-08", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.75, + "output": 4.5, + "cache_read": 0.075 + }, + "limit": { + "context": 256000, + "output": 128000 + } + }, { "id": "github-models/ai21-labs/ai21-jamba-1.5-large", "name": "AI21 Jamba 1.5 Large", @@ -41046,6 +42551,39 @@ "output": 8192 } }, + { + "id": "gitlab/duo-chat-fable-5", + "name": "Agentic Chat (Claude Fable 5)", + "family": "claude-fable", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-09", + "last_updated": "2026-06-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "gitlab/duo-chat-gpt-5-codex", "name": "Agentic Chat (GPT-5 Codex)", @@ -41581,6 +43119,69 @@ "output": 64000 } }, + { + "id": "gitlab/duo-chat-sonnet-5", + "name": "Agentic Chat (Claude Sonnet 5)", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "gmicloud/Qwen/Qwen3.7-Max", + "name": "Qwen3.7 Max", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-05-21", + "last_updated": "2026-05-21", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.5, + "output": 7.5, + "cache_read": 0.25, + "cache_write": 3.125 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "gmicloud/anthropic/claude-opus-4.6", "name": "Claude Opus 4.6", @@ -41641,6 +43242,38 @@ "output": 128000 } }, + { + "id": "gmicloud/anthropic/claude-opus-4.8", + "name": "Claude Opus 4.8", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01", + "release_date": "2026-05-28", + "last_updated": "2026-05-28", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "gmicloud/anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6", @@ -41761,6 +43394,68 @@ "output": 65536 } }, + { + "id": "gmicloud/moonshotai/kimi-k2.7-code-highspeed", + "name": "Kimi K2.7 Code Highspeed", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.9, + "output": 8.0, + "cache_read": 0.38 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "gmicloud/openai/gpt-5.5", + "name": "GPT-5.5", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-12-01", + "release_date": "2026-04-23", + "last_updated": "2026-04-23", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 30.0, + "cache_read": 0.5 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, { "id": "gmicloud/zai-org/GLM-5-FP8", "name": "GLM-5", @@ -41819,6 +43514,35 @@ "output": 131072 } }, + { + "id": "gmicloud/zai-org/GLM-5.2-FP8", + "name": "GLM-5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.979, + "output": 3.08, + "cache_read": 0.182 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "google-vertex-anthropic/claude-3.5-haiku", "name": "Claude Haiku 3.5", @@ -41959,7 +43683,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", + "knowledge": "2025-05", "release_date": "2025-11-01", "last_updated": "2025-11-01", "modalities": { @@ -42058,6 +43782,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -42181,6 +43906,39 @@ "output": 128000 } }, + { + "id": "google-vertex-anthropic/claude-sonnet-5@default", + "name": "Claude Sonnet 5", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "google-vertex/claude-3.5-haiku", "name": "Claude Haiku 3.5", @@ -42321,7 +44079,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", + "knowledge": "2025-05", "release_date": "2025-11-01", "last_updated": "2025-11-01", "modalities": { @@ -42420,6 +44178,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -42543,6 +44302,39 @@ "output": 128000 } }, + { + "id": "google-vertex/claude-sonnet-5@default", + "name": "Claude Sonnet 5", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "google-vertex/deepseek-ai/deepseek-v3.1-maas", "name": "DeepSeek V3.1", @@ -43953,6 +45745,36 @@ "output": 65536 } }, + { + "id": "google/gemini-omni-flash-preview", + "name": "Gemini Omni Flash Preview", + "family": "gemini", + "attachment": true, + "reasoning": true, + "tool_call": false, + "temperature": true, + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": { + "input": 1.5, + "output": 17.5 + }, + "limit": { + "context": 131072, + "output": 65536 + } + }, { "id": "google/gemma-4-26b-a4b-it", "name": "Gemma 4 26B A4B IT", @@ -44316,7 +46138,7 @@ "tool_call": true, "temperature": true, "release_date": "2025-10-29", - "last_updated": "2025-10-29", + "last_updated": "2026-06-29", "modalities": { "input": [ "text" @@ -44328,8 +46150,7 @@ "open_weights": true, "cost": { "input": 0.075, - "output": 0.3, - "cache_read": 0.037 + "output": 0.3 }, "limit": { "context": 131072, @@ -47731,6 +49552,35 @@ "output": 65536 } }, + { + "id": "huggingface/Qwen/Qwen3.6-27B", + "name": "Qwen3.6 27B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.47, + "output": 3.19 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, { "id": "huggingface/Qwen/Qwen3.6-35B-A3B", "name": "Qwen3.6 35B-A3B", @@ -47789,6 +49639,35 @@ "output": 4096 } }, + { + "id": "huggingface/XiaomiMiMo/MiMo-V2.5-Pro", + "name": "MiMo-V2.5-Pro", + "family": "mimo", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-12", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.0, + "output": 3.0 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, { "id": "huggingface/deepseek-ai/DeepSeek-R1", "name": "DeepSeek-R1", @@ -48146,6 +50025,62 @@ "output": 262144 } }, + { + "id": "huggingface/openai/gpt-oss-120b", + "name": "GPT OSS 120B", + "family": "gpt-oss", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.25, + "output": 0.69 + }, + "limit": { + "context": 131072, + "output": 32768 + } + }, + { + "id": "huggingface/openai/gpt-oss-20b", + "name": "GPT OSS 20B", + "family": "gpt-oss", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.1, + "output": 0.5 + }, + "limit": { + "context": 131072, + "output": 32768 + } + }, { "id": "huggingface/stepfun-ai/Step-3.5-Flash", "name": "Step 3.5 Flash", @@ -48174,6 +50109,35 @@ "output": 256000 } }, + { + "id": "huggingface/stepfun-ai/Step-3.7-Flash", + "name": "Step 3.7 Flash", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2026-01-01", + "release_date": "2026-05-29", + "last_updated": "2026-05-29", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.2, + "output": 1.15 + }, + "limit": { + "context": 262144, + "output": 256000 + } + }, { "id": "huggingface/zai-org/GLM-4.5", "name": "GLM-4.5", @@ -48892,9 +50856,9 @@ }, "open_weights": true, "cost": { - "input": 0.24, + "input": 0.15, "output": 0.9, - "cache_read": 0.03, + "cache_read": 0.05, "cache_write": 0.0 }, "limit": { @@ -48924,7 +50888,7 @@ }, "open_weights": true, "cost": { - "input": 0.78, + "input": 0.66, "output": 3.5, "cache_read": 0.2, "cache_write": 0.0 @@ -48935,19 +50899,20 @@ } }, { - "id": "inceptron/nvidia/llama-3.3-70b-instruct-fp8", - "name": "Llama 3.3 70B Instruct", - "family": "llama", + "id": "inceptron/moonshotai/Kimi-K2.6-Fast", + "name": "Kimi K2.6 Fast", + "family": "kimi-k2", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2023-12", - "release_date": "2024-12-06", - "last_updated": "2024-12-06", + "knowledge": "2025-01", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -48955,14 +50920,46 @@ }, "open_weights": true, "cost": { - "input": 0.12, - "output": 0.38, - "cache_read": 0.0, + "input": 1.32, + "output": 7.0, + "cache_read": 0.4, "cache_write": 0.0 }, "limit": { - "context": 131072, - "output": 131072 + "context": 262144, + "output": 262144 + } + }, + { + "id": "inceptron/moonshotai/Kimi-K2.7-Code", + "name": "Kimi K2.7 Code", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.75, + "output": 3.5, + "cache_read": 0.2, + "cache_write": 0.0 + }, + "limit": { + "context": 262144, + "output": 262144 } }, { @@ -48995,6 +50992,36 @@ "output": 202752 } }, + { + "id": "inceptron/zai-org/GLM-5.2", + "name": "GLM 5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.2, + "output": 4.2, + "cache_read": 0.26, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "inference/google/gemma-3", "name": "Google Gemma 3", @@ -50140,7 +52167,7 @@ "name": "gemini-2.5-flash", "family": "gemini-flash", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2026-01", @@ -50171,7 +52198,7 @@ "name": "gemini-2.5-flash-lite", "family": "gemini-flash-lite", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2026-01", @@ -50202,7 +52229,7 @@ "name": "gemini-2.5-flash-lite-preview-06-17", "family": "gemini-flash-lite", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2026-01", @@ -50264,7 +52291,7 @@ "name": "gemini-2.5-flash-preview-05-20", "family": "gemini-flash", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2026-01", @@ -50295,7 +52322,7 @@ "name": "gemini-2.5-pro", "family": "gemini-pro", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2026-01", @@ -50326,7 +52353,7 @@ "name": "gemini-2.5-pro-preview-06-05", "family": "gemini-pro", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2026-01", @@ -51558,6 +53585,688 @@ "output": 128000 } }, + { + "id": "kenari/claude-opus-4.7", + "name": "Claude Opus 4.7", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "kenari/claude-opus-4.8", + "name": "Claude Opus 4.8", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01", + "release_date": "2026-05-28", + "last_updated": "2026-05-28", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "kenari/claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-08-31", + "release_date": "2026-02-17", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "kenari/deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "family": "deepseek-flash", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "kenari/deepseek-v4-flash:free", + "name": "DeepSeek V4 Flash (Free)", + "family": "deepseek-flash", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "kenari/deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "family": "deepseek-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "kenari/deepseek-v4-pro:free", + "name": "DeepSeek V4 Pro (Free)", + "family": "deepseek-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "kenari/gemma-4-31b-it", + "name": "Gemma 4 31B IT", + "family": "gemma", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, + { + "id": "kenari/glm-5.1", + "name": "GLM-5.1", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-07", + "last_updated": "2026-04-07", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 200000, + "output": 131072 + } + }, + { + "id": "kenari/glm-5.2", + "name": "GLM-5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "kenari/gpt-5.4-mini", + "name": "GPT-5.4 mini", + "family": "gpt-mini", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-17", + "last_updated": "2026-03-17", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "kenari/gpt-5.5", + "name": "GPT-5.5", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-12-01", + "release_date": "2026-04-23", + "last_updated": "2026-04-23", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "kenari/gpt-image-2", + "name": "GPT-Image-2", + "family": "gpt-image", + "attachment": true, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 272000, + "output": 16384 + } + }, + { + "id": "kenari/gpt-oss-120b", + "name": "GPT OSS 120B", + "family": "gpt-oss", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 131072, + "output": 32768 + } + }, + { + "id": "kenari/gpt-oss-20b", + "name": "GPT OSS 20B", + "family": "gpt-oss", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 131072, + "output": 32768 + } + }, + { + "id": "kenari/grok-4.3", + "name": "Grok 4.3", + "family": "grok", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-17", + "last_updated": "2026-04-17", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 1000000, + "output": 30000 + } + }, + { + "id": "kenari/grok-build-0.1", + "name": "Grok Build 0.1", + "family": "grok-build", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 256000, + "output": 256000 + } + }, + { + "id": "kenari/kimi-k2-6", + "name": "Kimi K2.6", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "kenari/kimi-k2-7-code", + "name": "Kimi K2.7 Code", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "kenari/mimo-v2-5", + "name": "MiMo-V2.5", + "family": "mimo", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-12", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, + { + "id": "kenari/mimo-v2-5-pro", + "name": "MiMo-V2.5-Pro", + "family": "mimo", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-12", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, + { + "id": "kenari/minimax-m3", + "name": "MiniMax-M3", + "family": "minimax", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-01", + "last_updated": "2026-06-01", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 512000, + "output": 128000 + } + }, + { + "id": "kenari/qwen3-7-plus", + "name": "Qwen3.7 Plus", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2026-06-02", + "last_updated": "2026-06-02", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, { "id": "kilo/ai21/jamba-large-1.7", "name": "AI21: Jamba Large 1.7", @@ -52644,7 +55353,7 @@ "id": "kilo/baidu/qianfan-ocr-fast", "name": "Baidu: Qianfan-OCR-Fast", "attachment": true, - "reasoning": true, + "reasoning": false, "tool_call": false, "temperature": true, "release_date": "2026-04-20", @@ -52979,7 +55688,7 @@ "id": "kilo/deepseek/deepseek-chat-v3", "name": "DeepSeek: DeepSeek V3 0324", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "release_date": "2025-03-24", @@ -55147,6 +57856,37 @@ "output": 131072 } }, + { + "id": "kilo/minimax/minimax-m3", + "name": "MiniMax: MiniMax M3", + "family": "minimax", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-01", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 + }, + "limit": { + "context": 1048576, + "output": 512000 + } + }, { "id": "kilo/mistralai/codestral", "name": "Mistral: Codestral 2508", @@ -61200,19 +63940,20 @@ } }, { - "id": "lilac/minimaxai/minimax-m2.7", - "name": "MiniMax M2.7", - "family": "minimax", - "attachment": false, + "id": "lilac/minimaxai/minimax-m3", + "name": "MiniMax M3", + "family": "minimax-m3", + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-01", - "release_date": "2026-03-18", - "last_updated": "2026-03-18", + "release_date": "2026-06-01", + "last_updated": "2026-06-01", "modalities": { "input": [ - "text" + "text", + "image", + "video" ], "output": [ "text" @@ -61220,13 +63961,13 @@ }, "open_weights": true, "cost": { - "input": 0.3, - "output": 1.2, - "cache_read": 0.055 + "input": 0.28, + "output": 1.1, + "cache_read": 0.05 }, "limit": { - "context": 204800, - "output": 204800 + "context": 1048576, + "output": 1048576 } }, { @@ -61261,16 +64002,15 @@ } }, { - "id": "lilac/zai-org/glm-5.1", - "name": "GLM 5.1", + "id": "lilac/zai-org/glm-5.2", + "name": "GLM 5.2", "family": "glm", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-04", - "release_date": "2026-04-07", - "last_updated": "2026-04-07", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "modalities": { "input": [ "text" @@ -61286,8 +64026,8 @@ "cache_read": 0.27 }, "limit": { - "context": 202800, - "output": 131072 + "context": 524288, + "output": 524288 } }, { @@ -61342,7 +64082,8 @@ "cost": { "input": 15.0, "output": 75.0, - "cache_read": 1.5 + "cache_read": 1.5, + "cache_write": 18.75 }, "limit": { "context": 200000, @@ -61350,45 +64091,16 @@ } }, { - "id": "llmgateway/claude-3.5-haiku", - "name": "Claude 3.5 Haiku", - "family": "claude", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2024-10-22", - "last_updated": "2024-10-22", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.8, - "output": 4.0, - "cache_read": 0.08 - }, - "limit": { - "context": 200000, - "output": 8192 - } - }, - { - "id": "llmgateway/claude-3.5-sonnet", - "name": "Claude Sonnet 3.5 v2", - "family": "claude-sonnet", + "id": "llmgateway/claude-fable-5", + "name": "Claude Fable 5", + "family": "claude-fable", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, - "temperature": true, - "knowledge": "2024-04-30", - "release_date": "2024-10-22", - "last_updated": "2024-10-22", + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-09", + "last_updated": "2026-06-09", "modalities": { "input": [ "text", @@ -61401,43 +64113,14 @@ }, "open_weights": false, "cost": { - "input": 3.0, - "output": 15.0, - "cache_read": 0.3, - "cache_write": 3.75 + "input": 10.0, + "output": 50.0, + "cache_read": 1.0, + "cache_write": 12.5 }, "limit": { - "context": 200000, - "output": 8192 - } - }, - { - "id": "llmgateway/claude-3.7-sonnet", - "name": "Claude 3.7 Sonnet", - "family": "claude", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-02-24", - "last_updated": "2025-02-24", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 3.0, - "output": 15.0, - "cache_read": 0.3 - }, - "limit": { - "context": 200000, - "output": 8192 + "context": 1000000, + "output": 128000 } }, { @@ -61474,21 +64157,20 @@ } }, { - "id": "llmgateway/claude-opus-4", - "name": "Claude Opus 4", - "family": "claude-opus", + "id": "llmgateway/claude-haiku-4.5-free", + "name": "Claude Haiku 4.5 (latest)", + "family": "claude-haiku", "attachment": true, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", - "release_date": "2025-05-22", - "last_updated": "2025-05-22", + "knowledge": "2025-02-28", + "release_date": "2025-10-15", + "last_updated": "2025-10-15", "modalities": { "input": [ "text", - "image", - "pdf" + "image" ], "output": [ "text" @@ -61496,14 +64178,12 @@ }, "open_weights": false, "cost": { - "input": 15.0, - "output": 75.0, - "cache_read": 1.5, - "cache_write": 18.75 + "input": 0.0, + "output": 0.0 }, "limit": { "context": 200000, - "output": 32000 + "output": 200000 } }, { @@ -61547,7 +64227,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", + "knowledge": "2025-05", "release_date": "2025-11-01", "last_updated": "2025-11-01", "modalities": { @@ -61646,6 +64326,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -61670,39 +64351,6 @@ "output": 128000 } }, - { - "id": "llmgateway/claude-sonnet-4", - "name": "Claude Sonnet 4", - "family": "claude-sonnet", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-03-31", - "release_date": "2025-05-22", - "last_updated": "2025-05-22", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 3.0, - "output": 15.0, - "cache_read": 0.3, - "cache_write": 3.75 - }, - "limit": { - "context": 200000, - "output": 64000 - } - }, { "id": "llmgateway/claude-sonnet-4.5", "name": "Claude Sonnet 4.5 (latest)", @@ -61769,6 +64417,38 @@ "output": 64000 } }, + { + "id": "llmgateway/claude-sonnet-5", + "name": "Claude Sonnet 5", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": false, + "temperature": true, + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "limit": { + "context": 1000000, + "output": 1000000 + } + }, { "id": "llmgateway/codestral", "name": "Codestral", @@ -61826,34 +64506,6 @@ "output": 16384 } }, - { - "id": "llmgateway/deepseek-r1", - "name": "DeepSeek R1 (0528)", - "family": "deepseek", - "attachment": false, - "reasoning": true, - "tool_call": false, - "temperature": true, - "release_date": "2025-05-28", - "last_updated": "2025-05-28", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.55, - "output": 2.19 - }, - "limit": { - "context": 64000, - "output": 16384 - } - }, { "id": "llmgateway/deepseek-v3.1", "name": "DeepSeek V3.1", @@ -61877,7 +64529,7 @@ "cost": { "input": 0.56, "output": 1.68, - "cache_read": 0.07 + "cache_read": 0.112 }, "limit": { "context": 128000, @@ -61905,9 +64557,9 @@ }, "open_weights": true, "cost": { - "input": 0.28, - "output": 0.42, - "cache_read": 0.056 + "input": 0.26, + "output": 0.38, + "cache_read": 0.13 }, "limit": { "context": 163840, @@ -61940,7 +64592,7 @@ "cache_read": 0.0028 }, "limit": { - "context": 1000000, + "context": 1050000, "output": 384000 } }, @@ -61970,7 +64622,7 @@ "cache_read": 0.003625 }, "limit": { - "context": 1000000, + "context": 1050000, "output": 384000 } }, @@ -62028,28 +64680,23 @@ "output": 0.3 }, "limit": { - "context": 128000, + "context": 131072, "output": 128000 } }, { - "id": "llmgateway/gemini-2.0-flash", - "name": "Gemini 2.0 Flash", - "family": "gemini-flash", + "id": "llmgateway/fugu-ultra", + "name": "Fugu Ultra", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2024-06", - "release_date": "2024-12-11", - "last_updated": "2024-12-11", + "release_date": "2026-06-22", + "last_updated": "2026-06-22", "modalities": { "input": [ "text", - "image", - "audio", - "video", - "pdf" + "image" ], "output": [ "text" @@ -62057,46 +64704,13 @@ }, "open_weights": false, "cost": { - "input": 0.1, - "output": 0.4, - "cache_read": 0.025 + "input": 5.0, + "output": 30.0, + "cache_read": 0.5 }, "limit": { - "context": 1048576, - "output": 8192 - } - }, - { - "id": "llmgateway/gemini-2.0-flash-lite", - "name": "Gemini 2.0 Flash-Lite", - "family": "gemini-flash-lite", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-06", - "release_date": "2024-12-11", - "last_updated": "2024-12-11", - "modalities": { - "input": [ - "text", - "image", - "audio", - "video", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.075, - "output": 0.3 - }, - "limit": { - "context": 1048576, - "output": 8192 + "context": 1000000, + "output": 1000000 } }, { @@ -62262,41 +64876,8 @@ "cost": { "input": 0.25, "output": 1.5, - "cache_read": 0.025 - }, - "limit": { - "context": 1048576, - "output": 65536 - } - }, - { - "id": "llmgateway/gemini-3.1-flash-lite-preview", - "name": "Gemini 3.1 Flash Lite Preview", - "family": "gemini-flash-lite", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-01", - "release_date": "2026-03-03", - "last_updated": "2026-03-03", - "modalities": { - "input": [ - "text", - "image", - "video", - "audio", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.25, - "output": 1.5, - "cache_read": 0.025 + "cache_read": 0.025, + "cache_write": 0.08333 }, "limit": { "context": 1048576, @@ -62364,7 +64945,8 @@ "cost": { "input": 1.5, "output": 9.0, - "cache_read": 0.15 + "cache_read": 0.15, + "cache_write": 0.08333 }, "limit": { "context": 1048576, @@ -62401,91 +64983,6 @@ "output": 65536 } }, - { - "id": "llmgateway/gemma-2-27b-it-together", - "name": "Gemma 2 27B IT", - "family": "gemma", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2024-06-27", - "last_updated": "2024-06-27", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.08, - "output": 0.08 - }, - "limit": { - "context": 8192, - "output": 16384 - } - }, - { - "id": "llmgateway/gemma-3-1b-it", - "name": "Gemma 3 1B IT", - "family": "gemma", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-03-12", - "last_updated": "2025-03-12", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.08, - "output": 0.3 - }, - "limit": { - "context": 1000000, - "output": 16384 - } - }, - { - "id": "llmgateway/gemma-3-27b", - "name": "Gemma 3 27B", - "family": "gemma", - "attachment": true, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-03-12", - "last_updated": "2025-03-12", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.27, - "output": 0.27 - }, - "limit": { - "context": 128000, - "output": 16384 - } - }, { "id": "llmgateway/gemma-4-26b-a4b-it", "name": "Gemma 4 26B A4B IT", @@ -62599,7 +65096,7 @@ "cache_write": 0.0 }, "limit": { - "context": 131072, + "context": 131000, "output": 98304 } }, @@ -62624,13 +65121,13 @@ }, "open_weights": true, "cost": { - "input": 0.2, - "output": 1.1, - "cache_read": 0.03, + "input": 0.13, + "output": 0.85, + "cache_read": 0.025, "cache_write": 0.0 }, "limit": { - "context": 131072, + "context": 131000, "output": 98304 } }, @@ -62663,37 +65160,6 @@ "output": 16384 } }, - { - "id": "llmgateway/glm-4.5-flash", - "name": "GLM-4.5-Flash", - "family": "glm-flash", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-04", - "release_date": "2025-07-28", - "last_updated": "2025-07-28", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0, - "cache_read": 0.0, - "cache_write": 0.0 - }, - "limit": { - "context": 131072, - "output": 98304 - } - }, { "id": "llmgateway/glm-4.5-x", "name": "GLM-4.5 X", @@ -62747,10 +65213,11 @@ "open_weights": true, "cost": { "input": 0.6, - "output": 1.8 + "output": 1.8, + "cache_read": 0.11 }, "limit": { - "context": 64000, + "context": 128000, "output": 16384 } }, @@ -62775,7 +65242,7 @@ }, "open_weights": true, "cost": { - "input": 0.6, + "input": 0.55, "output": 2.2, "cache_read": 0.11, "cache_write": 0.0 @@ -62809,42 +65276,14 @@ "open_weights": true, "cost": { "input": 0.3, - "output": 0.9 + "output": 0.9, + "cache_read": 0.05 }, "limit": { - "context": 128000, + "context": 131072, "output": 32768 } }, - { - "id": "llmgateway/glm-4.6v-flash", - "name": "GLM-4.6V Flash", - "family": "glm", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-08", - "last_updated": "2025-12-08", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 128000, - "output": 16000 - } - }, { "id": "llmgateway/glm-4.6v-flashx", "name": "GLM-4.6V FlashX", @@ -62896,9 +65335,9 @@ }, "open_weights": true, "cost": { - "input": 0.6, - "output": 2.2, - "cache_read": 0.11, + "input": 0.38, + "output": 1.98, + "cache_read": 0.19, "cache_write": 0.0 }, "limit": { @@ -62927,9 +65366,9 @@ }, "open_weights": true, "cost": { - "input": 0.0, - "output": 0.0, - "cache_read": 0.0, + "input": 0.06, + "output": 0.4, + "cache_read": 0.01, "cache_write": 0.0 }, "limit": { @@ -62988,13 +65427,13 @@ }, "open_weights": true, "cost": { - "input": 1.0, - "output": 3.2, - "cache_read": 0.2, + "input": 0.72, + "output": 2.3, + "cache_read": 0.144, "cache_write": 0.0 }, "limit": { - "context": 204800, + "context": 203000, "output": 131072 } }, @@ -63018,13 +65457,13 @@ }, "open_weights": true, "cost": { - "input": 6.0, - "output": 24.0, - "cache_read": 1.3, + "input": 0.931, + "output": 2.93, + "cache_read": 0.173, "cache_write": 0.0 }, "limit": { - "context": 200000, + "context": 204800, "output": 131072 } }, @@ -63050,11 +65489,10 @@ "cost": { "input": 1.4, "output": 4.4, - "cache_read": 0.26, - "cache_write": 0.0 + "cache_read": 0.26 }, "limit": { - "context": 1000000, + "context": 1024000, "output": 131072 } }, @@ -63175,7 +65613,7 @@ "cache_read": 0.5 }, "limit": { - "context": 1047576, + "context": 1000000, "output": 32768 } }, @@ -63207,7 +65645,7 @@ "cache_read": 0.1 }, "limit": { - "context": 1047576, + "context": 1000000, "output": 32768 } }, @@ -63238,7 +65676,7 @@ "cache_read": 0.025 }, "limit": { - "context": 1047576, + "context": 1000000, "output": 32768 } }, @@ -64043,9 +66481,9 @@ }, { "id": "llmgateway/grok-4", - "name": "Grok 4 (0709)", + "name": "Grok 4", "family": "grok", - "attachment": false, + "attachment": true, "reasoning": false, "tool_call": true, "temperature": true, @@ -64053,7 +66491,8 @@ "last_updated": "2025-07-09", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -64062,7 +66501,8 @@ "open_weights": false, "cost": { "input": 3.0, - "output": 15.0 + "output": 15.0, + "cache_read": 0.75 }, "limit": { "context": 256000, @@ -64091,12 +66531,12 @@ }, "open_weights": false, "cost": { - "input": 1.25, - "output": 2.5, + "input": 2.0, + "output": 6.0, "cache_read": 0.2 }, "limit": { - "context": 1000000, + "context": 2000000, "output": 30000 } }, @@ -64122,12 +66562,12 @@ }, "open_weights": false, "cost": { - "input": 1.25, - "output": 2.5, + "input": 2.0, + "output": 6.0, "cache_read": 0.2 }, "limit": { - "context": 1000000, + "context": 2000000, "output": 30000 } }, @@ -64158,7 +66598,7 @@ "cache_read": 0.2 }, "limit": { - "context": 1000000, + "context": 2000000, "output": 30000 } }, @@ -64189,20 +66629,20 @@ "cache_read": 0.2 }, "limit": { - "context": 1000000, + "context": 2000000, "output": 30000 } }, { - "id": "llmgateway/grok-4-fast-reasoning", - "name": "Grok 4 Fast Reasoning", + "id": "llmgateway/grok-4.1-fast-non-reasoning", + "name": "Grok 4.1 Fast Non-Reasoning", "family": "grok", "attachment": true, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-07-09", - "last_updated": "2025-07-09", + "release_date": "2025-11-19", + "last_updated": "2025-11-19", "modalities": { "input": [ "text", @@ -64220,7 +66660,7 @@ }, "limit": { "context": 2000000, - "output": 30000 + "output": 2000000 } }, { @@ -64277,13 +66717,43 @@ "cost": { "input": 1.25, "output": 2.5, - "cache_read": 0.2 + "cache_read": 0.3125 }, "limit": { "context": 1000000, "output": 30000 } }, + { + "id": "llmgateway/grok-4.5", + "name": "Grok 4.5", + "family": "grok", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-07-08", + "last_updated": "2026-07-08", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 6.0, + "cache_read": 0.5 + }, + "limit": { + "context": 500000, + "output": 500000 + } + }, { "id": "llmgateway/grok-build-0.1", "name": "Grok Build 0.1", @@ -64315,34 +66785,6 @@ "output": 256000 } }, - { - "id": "llmgateway/hermes-2-pro-llama-3-8b", - "name": "Hermes 2 Pro Llama 3 8B", - "family": "hermes", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2024-05-27", - "last_updated": "2024-05-27", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.14, - "output": 0.14 - }, - "limit": { - "context": 8192, - "output": 8192 - } - }, { "id": "llmgateway/kimi-k2", "name": "Kimi K2", @@ -64363,12 +66805,12 @@ }, "open_weights": true, "cost": { - "input": 0.6, - "output": 2.5, - "cache_read": 0.12 + "input": 0.57, + "output": 2.3, + "cache_read": 0.5 }, "limit": { - "context": 131072, + "context": 256000, "output": 16384 } }, @@ -64395,37 +66837,7 @@ "cost": { "input": 0.6, "output": 2.5, - "cache_read": 0.15 - }, - "limit": { - "context": 262144, - "output": 262144 - } - }, - { - "id": "llmgateway/kimi-k2-thinking-turbo", - "name": "Kimi K2 Thinking Turbo", - "family": "kimi-thinking", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-08", - "release_date": "2025-11-06", - "last_updated": "2025-11-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 1.15, - "output": 8.0, - "cache_read": 0.15 + "cache_read": 0.06 }, "limit": { "context": 262144, @@ -64455,9 +66867,9 @@ }, "open_weights": true, "cost": { - "input": 0.6, - "output": 3.0, - "cache_read": 0.1 + "input": 0.405, + "output": 1.98, + "cache_read": 0.225 }, "limit": { "context": 262144, @@ -64487,9 +66899,9 @@ }, "open_weights": true, "cost": { - "input": 0.95, - "output": 4.0, - "cache_read": 0.16 + "input": 0.4, + "output": 2.2, + "cache_read": 0.08 }, "limit": { "context": 262144, @@ -64644,38 +67056,10 @@ "output": 2048 } }, - { - "id": "llmgateway/llama-3.1-8b-instruct", - "name": "Llama 3.1 8B Instruct", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2024-07-23", - "last_updated": "2024-07-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.22, - "output": 0.22 - }, - "limit": { - "context": 128000, - "output": 2048 - } - }, { "id": "llmgateway/llama-3.1-nemotron-ultra-253b", "name": "Llama 3.1 Nemotron Ultra 253B", - "family": "llama", + "family": "nemotron", "attachment": false, "reasoning": false, "tool_call": false, @@ -64777,11 +67161,11 @@ }, "open_weights": true, "cost": { - "input": 0.0, - "output": 0.0 + "input": 0.13, + "output": 0.4 }, "limit": { - "context": 128000, + "context": 131072, "output": 4096 } }, @@ -64806,42 +67190,14 @@ }, "open_weights": true, "cost": { - "input": 0.24, - "output": 0.97 + "input": 0.27, + "output": 0.85 }, "limit": { - "context": 8192, + "context": 1048576, "output": 2048 } }, - { - "id": "llmgateway/llama-4-scout", - "name": "Llama 4 Scout", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-04-05", - "last_updated": "2025-04-05", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.18, - "output": 0.59 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, { "id": "llmgateway/llama-4-scout-17b-instruct", "name": "Llama 4 Scout 17B Instruct", @@ -64863,44 +67219,14 @@ }, "open_weights": true, "cost": { - "input": 0.17, - "output": 0.66 + "input": 0.18, + "output": 0.59 }, "limit": { - "context": 8192, + "context": 131072, "output": 2048 } }, - { - "id": "llmgateway/mimo-v2-flash", - "name": "MiMo-V2-Flash", - "family": "mimo", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-12-01", - "release_date": "2025-12-16", - "last_updated": "2026-02-04", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.1, - "output": 0.3, - "cache_read": 0.01 - }, - "limit": { - "context": 262144, - "output": 65536 - } - }, { "id": "llmgateway/mimo-v2-omni", "name": "MiMo-V2-Omni", @@ -64931,7 +67257,7 @@ "cache_read": 0.08 }, "limit": { - "context": 262144, + "context": 256000, "output": 131072 } }, @@ -64961,7 +67287,7 @@ "cache_read": 0.2 }, "limit": { - "context": 1048576, + "context": 1000000, "output": 131072 } }, @@ -64989,12 +67315,12 @@ }, "open_weights": true, "cost": { - "input": 0.4, - "output": 2.0, - "cache_read": 0.08 + "input": 0.14, + "output": 0.28, + "cache_read": 0.0028 }, "limit": { - "context": 1048576, + "context": 1000000, "output": 131072 } }, @@ -65019,12 +67345,12 @@ }, "open_weights": true, "cost": { - "input": 1.0, - "output": 3.0, - "cache_read": 0.2 + "input": 0.435, + "output": 0.87, + "cache_read": 0.0036 }, "limit": { - "context": 1048576, + "context": 1000000, "output": 131072 } }, @@ -65048,8 +67374,9 @@ }, "open_weights": true, "cost": { - "input": 0.3, - "output": 1.2 + "input": 0.2, + "output": 1.0, + "cache_read": 0.03 }, "limit": { "context": 196608, @@ -65076,8 +67403,8 @@ }, "open_weights": true, "cost": { - "input": 0.3, - "output": 1.2 + "input": 0.27, + "output": 1.1 }, "limit": { "context": 204800, @@ -65138,7 +67465,7 @@ "cache_write": 0.375 }, "limit": { - "context": 204800, + "context": 228700, "output": 131072 } }, @@ -65164,7 +67491,7 @@ "cost": { "input": 0.6, "output": 2.4, - "cache_read": 0.06, + "cache_read": 0.03, "cache_write": 0.375 }, "limit": { @@ -65463,7 +67790,7 @@ "cache_read": 0.15 }, "limit": { - "context": 1000000, + "context": 262144, "output": 128000 } }, @@ -65614,8 +67941,8 @@ }, "open_weights": true, "cost": { - "input": 2.0, - "output": 6.0 + "input": 4.0, + "output": 12.0 }, "limit": { "context": 128000, @@ -65672,7 +67999,9 @@ "open_weights": false, "cost": { "input": 0.05, - "output": 0.4 + "output": 0.4, + "cache_read": 0.01, + "cache_write": 0.0625 }, "limit": { "context": 1000000, @@ -65733,8 +68062,8 @@ }, "open_weights": false, "cost": { - "input": 0.07, - "output": 0.27 + "input": 0.2, + "output": 0.8 }, "limit": { "context": 32768, @@ -65763,10 +68092,12 @@ "open_weights": false, "cost": { "input": 0.4, - "output": 1.2 + "output": 1.2, + "cache_read": 0.08, + "cache_write": 0.5 }, "limit": { - "context": 1000000, + "context": 131072, "output": 32768 } }, @@ -65852,7 +68183,7 @@ "open_weights": false, "cost": { "input": 0.21, - "output": 0.63 + "output": 0.64 }, "limit": { "context": 131072, @@ -65910,39 +68241,11 @@ }, "open_weights": true, "cost": { - "input": 2.8, - "output": 8.4 + "input": 0.13, + "output": 0.4 }, "limit": { - "context": 131072, - "output": 8192 - } - }, - { - "id": "llmgateway/qwen25-coder-7b", - "name": "Qwen2.5 Coder 7B", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2024-09-19", - "last_updated": "2024-09-19", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.05, - "output": 0.05 - }, - "limit": { - "context": 131072, + "context": 32768, "output": 8192 } }, @@ -65970,7 +68273,7 @@ "output": 0.8 }, "limit": { - "context": 131072, + "context": 40960, "output": 8192 } }, @@ -65998,7 +68301,7 @@ "output": 0.58 }, "limit": { - "context": 131072, + "context": 262144, "output": 8192 } }, @@ -66026,35 +68329,7 @@ "output": 0.6 }, "limit": { - "context": 131072, - "output": 8192 - } - }, - { - "id": "llmgateway/qwen3-30b-a3b-fp8", - "name": "Qwen3 30B A3B FP8", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-04-28", - "last_updated": "2025-04-28", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.1, - "output": 0.1 - }, - "limit": { - "context": 131072, + "context": 262000, "output": 8192 } }, @@ -66082,35 +68357,7 @@ "output": 0.3 }, "limit": { - "context": 131072, - "output": 8192 - } - }, - { - "id": "llmgateway/qwen3-30b-a3b-thinking", - "name": "Qwen3 30B A3B Thinking (2507)", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-07-08", - "last_updated": "2025-07-08", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.1, - "output": 0.1 - }, - "limit": { - "context": 131072, + "context": 262000, "output": 8192 } }, @@ -66134,41 +68381,13 @@ ] }, "open_weights": true, - "cost": { - "input": 0.7, - "output": 2.8 - }, - "limit": { - "context": 131072, - "output": 16384 - } - }, - { - "id": "llmgateway/qwen3-32b-fp8", - "name": "Qwen3 32B FP8", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-04-28", - "last_updated": "2025-04-28", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, "cost": { "input": 0.1, - "output": 0.1 + "output": 0.3 }, "limit": { - "context": 131072, - "output": 8192 + "context": 32768, + "output": 16384 } }, { @@ -66195,7 +68414,7 @@ "output": 0.03 }, "limit": { - "context": 131072, + "context": 128000, "output": 8192 } }, @@ -66220,11 +68439,11 @@ }, "open_weights": true, "cost": { - "input": 0.45, - "output": 2.25 + "input": 0.07, + "output": 0.27 }, "limit": { - "context": 262144, + "context": 262000, "output": 65536 } }, @@ -66249,8 +68468,8 @@ }, "open_weights": true, "cost": { - "input": 1.5, - "output": 7.5 + "input": 0.3, + "output": 1.3 }, "limit": { "context": 262144, @@ -66279,7 +68498,9 @@ "open_weights": false, "cost": { "input": 0.3, - "output": 1.5 + "output": 1.5, + "cache_read": 0.06, + "cache_write": 0.375 }, "limit": { "context": 1000000, @@ -66307,7 +68528,8 @@ "open_weights": false, "cost": { "input": 0.108, - "output": 0.675 + "output": 0.675, + "cache_read": 0.06 }, "limit": { "context": 262144, @@ -66335,11 +68557,13 @@ }, "open_weights": false, "cost": { - "input": 1.0, - "output": 5.0 + "input": 6.0, + "output": 60.0, + "cache_read": 1.2, + "cache_write": 7.5 }, "limit": { - "context": 1048576, + "context": 1000000, "output": 65536 } }, @@ -66364,8 +68588,10 @@ }, "open_weights": false, "cost": { - "input": 1.2, - "output": 6.0 + "input": 0.845, + "output": 3.38, + "cache_read": 0.6, + "cache_write": 3.75 }, "limit": { "context": 262144, @@ -66393,8 +68619,8 @@ }, "open_weights": true, "cost": { - "input": 0.5, - "output": 2.0 + "input": 0.15, + "output": 1.2 }, "limit": { "context": 131072, @@ -66422,8 +68648,8 @@ }, "open_weights": true, "cost": { - "input": 0.5, - "output": 6.0 + "input": 0.15, + "output": 1.2 }, "limit": { "context": 131072, @@ -66480,8 +68706,8 @@ }, "open_weights": true, "cost": { - "input": 0.5, - "output": 2.0 + "input": 0.98, + "output": 3.95 }, "limit": { "context": 131072, @@ -66596,12 +68822,12 @@ }, "open_weights": false, "cost": { - "input": 0.022, - "output": 0.215, - "cache_read": 0.0044 + "input": 0.05, + "output": 0.4, + "cache_read": 0.01 }, "limit": { - "context": 1000000, + "context": 262144, "output": 32000 } }, @@ -66628,7 +68854,9 @@ "open_weights": false, "cost": { "input": 0.2, - "output": 1.6 + "output": 1.6, + "cache_read": 0.04, + "cache_write": 0.25 }, "limit": { "context": 262144, @@ -66694,6 +68922,37 @@ "output": 65536 } }, + { + "id": "llmgateway/qwen3.6-flash", + "name": "Qwen3.6 Flash", + "family": "qwen3.6", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-27", + "last_updated": "2026-04-27", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.15, + "output": 0.9, + "cache_read": 0.015 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, { "id": "llmgateway/qwen3.6-max-preview", "name": "Qwen3.6 Max Preview", @@ -66754,7 +69013,7 @@ "cache_write": 0.625 }, "limit": { - "context": 1000000, + "context": 262144, "output": 65536 } }, @@ -66778,9 +69037,9 @@ }, "open_weights": false, "cost": { - "input": 2.5, - "output": 7.5, - "cache_read": 0.5, + "input": 1.25, + "output": 3.75, + "cache_read": 0.125, "cache_write": 3.125 }, "limit": { @@ -67025,7 +69284,7 @@ "output": 1.0 }, "limit": { - "context": 128000, + "context": 130000, "output": 4096 } }, @@ -67343,6 +69602,35 @@ "output": 65536 } }, + { + "id": "longcat/LongCat-2.0", + "name": "LongCat-2.0", + "family": "longcat", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.75, + "output": 2.95, + "cache_read": 0.015 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "lucidquery/lucidnova-rf1-100b", "name": "LucidNova RF1 100B", @@ -68172,7 +70460,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", + "knowledge": "2025-05", "release_date": "2025-11-01", "last_updated": "2025-11-01", "modalities": { @@ -68271,6 +70559,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -70881,6 +73170,37 @@ "output": 4096 } }, + { + "id": "meta/muse-spark-1.1", + "name": "Muse Spark 1.1", + "family": "muse", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-08", + "last_updated": "2026-07-09", + "modalities": { + "input": [ + "text", + "image", + "pdf", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 4.25 + }, + "limit": { + "context": 1000000, + "output": 32000 + } + }, { "id": "minimax-cn-coding-plan/MiniMax-M2", "name": "MiniMax-M2", @@ -71066,7 +73386,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-06-01", - "last_updated": "2026-06-01", + "last_updated": "2026-06-25", "modalities": { "input": [ "text", @@ -71085,7 +73405,7 @@ "cache_write": 0.0 }, "limit": { - "context": 512000, + "context": 1000000, "output": 128000 } }, @@ -71274,7 +73594,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-06-01", - "last_updated": "2026-06-01", + "last_updated": "2026-06-25", "modalities": { "input": [ "text", @@ -71287,12 +73607,12 @@ }, "open_weights": true, "cost": { - "input": 0.6, - "output": 2.4, - "cache_read": 0.12 + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 }, "limit": { - "context": 512000, + "context": 1000000, "output": 128000 } }, @@ -71481,7 +73801,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-06-01", - "last_updated": "2026-06-01", + "last_updated": "2026-06-25", "modalities": { "input": [ "text", @@ -71500,7 +73820,7 @@ "cache_write": 0.0 }, "limit": { - "context": 512000, + "context": 1000000, "output": 128000 } }, @@ -71689,7 +74009,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-06-01", - "last_updated": "2026-06-01", + "last_updated": "2026-06-25", "modalities": { "input": [ "text", @@ -71702,12 +74022,12 @@ }, "open_weights": true, "cost": { - "input": 0.6, - "output": 2.4, - "cache_read": 0.12 + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 }, "limit": { - "context": 512000, + "context": 1000000, "output": 128000 } }, @@ -91938,6 +94258,34 @@ "output": 16384 } }, + { + "id": "nebius/zai-org/GLM-5.2", + "name": "GLM-5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.4, + "output": 4.4 + }, + "limit": { + "context": 432000, + "output": 432000 + } + }, { "id": "neon/claude-haiku-4.5", "name": "Claude Haiku 4.5 (latest)", @@ -92012,7 +94360,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", + "knowledge": "2025-05", "release_date": "2025-11-24", "last_updated": "2025-11-24", "modalities": { @@ -92103,6 +94451,39 @@ "output": 128000 } }, + { + "id": "neon/claude-opus-4.8", + "name": "Claude Opus 4.8", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01", + "release_date": "2026-05-28", + "last_updated": "2026-05-28", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "neon/claude-sonnet-4", "name": "Claude Sonnet 4.5", @@ -92406,6 +94787,70 @@ "output": 65536 } }, + { + "id": "neon/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "family": "gemini-flash", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-05-19", + "last_updated": "2026-05-19", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.5, + "output": 9.0, + "cache_read": 0.15 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "neon/gemma-3-12b", + "name": "Gemma 3 12B", + "family": "gemma", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2024-08-31", + "release_date": "2025-03-13", + "last_updated": "2025-03-13", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.15, + "output": 0.5 + }, + "limit": { + "context": 131072, + "output": 16384 + } + }, { "id": "neon/gpt-5", "name": "GPT-5", @@ -92423,7 +94868,8 @@ "image" ], "output": [ - "text" + "text", + "image" ] }, "open_weights": false, @@ -92454,7 +94900,8 @@ "image" ], "output": [ - "text" + "text", + "image" ] }, "open_weights": false, @@ -92485,7 +94932,8 @@ "image" ], "output": [ - "text" + "text", + "image" ] }, "open_weights": false, @@ -92516,7 +94964,8 @@ "image" ], "output": [ - "text" + "text", + "image" ] }, "open_weights": false, @@ -92530,6 +94979,70 @@ "output": 128000 } }, + { + "id": "neon/gpt-5.1-codex-max", + "name": "GPT-5.1 Codex Max", + "family": "gpt-codex", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2024-09-30", + "release_date": "2025-11-13", + "last_updated": "2025-11-13", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text", + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "neon/gpt-5.1-codex-mini", + "name": "GPT-5.1 Codex mini", + "family": "gpt-codex", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2024-09-30", + "release_date": "2025-11-13", + "last_updated": "2025-11-13", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text", + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.25, + "output": 2.0, + "cache_read": 0.025 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, { "id": "neon/gpt-5.2", "name": "GPT-5.2", @@ -92547,7 +95060,74 @@ "image" ], "output": [ - "text" + "text", + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 1.75, + "output": 14.0, + "cache_read": 0.175 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "neon/gpt-5.2-codex", + "name": "GPT-5.2 Codex", + "family": "gpt-codex", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2025-12-11", + "last_updated": "2025-12-11", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text", + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 1.75, + "output": 14.0, + "cache_read": 0.175 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "neon/gpt-5.3-codex", + "name": "GPT-5.3 Codex", + "family": "gpt-codex", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-02-05", + "last_updated": "2026-02-05", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text", + "image" ] }, "open_weights": false, @@ -92579,7 +95159,8 @@ "pdf" ], "output": [ - "text" + "text", + "image" ] }, "open_weights": false, @@ -92610,7 +95191,8 @@ "image" ], "output": [ - "text" + "text", + "image" ] }, "open_weights": false, @@ -92641,7 +95223,8 @@ "image" ], "output": [ - "text" + "text", + "image" ] }, "open_weights": false, @@ -92655,38 +95238,6 @@ "output": 128000 } }, - { - "id": "neon/gpt-5.5", - "name": "GPT-5.5", - "family": "gpt", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": false, - "knowledge": "2025-12-01", - "release_date": "2026-04-23", - "last_updated": "2026-04-23", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 5.0, - "output": 30.0, - "cache_read": 0.5 - }, - "limit": { - "context": 1050000, - "output": 128000 - } - }, { "id": "neon/gpt-oss-120b", "name": "GPT OSS 120B", @@ -92743,6 +95294,151 @@ "output": 32768 } }, + { + "id": "neon/llama-4-maverick", + "name": "Llama 4 Maverick 17B Instruct", + "family": "llama", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2024-08", + "release_date": "2025-04-05", + "last_updated": "2025-04-05", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.5, + "output": 1.5 + }, + "limit": { + "context": 1000000, + "output": 16384 + } + }, + { + "id": "neon/meta-llama-3.1-8b-instruct", + "name": "Llama 3.1 8B Instruct", + "family": "llama", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2023-12-31", + "release_date": "2024-07-23", + "last_updated": "2024-07-23", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.15, + "output": 0.45 + }, + "limit": { + "context": 131072, + "output": 16384 + } + }, + { + "id": "neon/meta-llama-3.3-70b-instruct", + "name": "Llama-3.3-70B-Instruct", + "family": "llama", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2023-12", + "release_date": "2024-12-06", + "last_updated": "2024-12-06", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.5, + "output": 1.5 + }, + "limit": { + "context": 128000, + "output": 4096 + } + }, + { + "id": "neon/qwen3-next-80b-a3b-instruct", + "name": "Qwen3-Next 80B-A3B Instruct", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2025-09", + "last_updated": "2025-09", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.15, + "output": 1.2 + }, + "limit": { + "context": 131072, + "output": 32768 + } + }, + { + "id": "neon/qwen35-122b-a10b", + "name": "Qwen3.5 122B-A10B", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-23", + "last_updated": "2026-02-23", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.22, + "output": 2.2 + }, + "limit": { + "context": 262144, + "output": 8000 + } + }, { "id": "neuralwatt/Qwen/Qwen3.5-397B-A17B-FP8", "name": "Qwen3.5 397B A17B FP8", @@ -92764,7 +95460,8 @@ "open_weights": true, "cost": { "input": 0.69, - "output": 4.14 + "output": 4.14, + "cache_read": 0.1725 }, "limit": { "context": 262128, @@ -92793,69 +95490,14 @@ "open_weights": true, "cost": { "input": 0.29, - "output": 1.15 + "output": 1.15, + "cache_read": 0.0725 }, "limit": { "context": 131056, "output": 131056 } }, - { - "id": "neuralwatt/glm-5-fast", - "name": "GLM 5 Fast", - "family": "glm", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2026-04-07", - "last_updated": "2026-04-07", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 1.1, - "output": 3.6 - }, - "limit": { - "context": 202736, - "output": 202736 - } - }, - { - "id": "neuralwatt/glm-5.1-fast", - "name": "GLM 5.1 Fast", - "family": "glm", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2026-04-07", - "last_updated": "2026-04-07", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 1.1, - "output": 3.6 - }, - "limit": { - "context": 202736, - "output": 202736 - } - }, { "id": "neuralwatt/glm-5.2", "name": "GLM 5.2", @@ -92877,13 +95519,188 @@ "open_weights": true, "cost": { "input": 1.45, - "output": 4.5 + "output": 4.5, + "cache_read": 0.3625 }, "limit": { "context": 1048560, "output": 1048560 } }, + { + "id": "neuralwatt/glm-5.2-fast", + "name": "GLM 5.2 Fast", + "family": "glm", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-17", + "last_updated": "2026-06-17", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.45, + "output": 4.5, + "cache_read": 0.3625 + }, + "limit": { + "context": 1048560, + "output": 1048560 + } + }, + { + "id": "neuralwatt/glm-5.2-flex", + "name": "GLM 5.2 Flex", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-17", + "last_updated": "2026-06-17", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.725, + "output": 2.25, + "cache_read": 0.18125 + }, + "limit": { + "context": 1048560, + "output": 1048560 + } + }, + { + "id": "neuralwatt/glm-5.2-short", + "name": "GLM 5.2 Short", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-17", + "last_updated": "2026-06-17", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.45, + "output": 4.5, + "cache_read": 0.3625 + }, + "limit": { + "context": 199984, + "output": 199984 + } + }, + { + "id": "neuralwatt/glm-5.2-short-fast", + "name": "GLM 5.2 Short Fast", + "family": "glm", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-17", + "last_updated": "2026-06-17", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.45, + "output": 4.5, + "cache_read": 0.3625 + }, + "limit": { + "context": 199984, + "output": 199984 + } + }, + { + "id": "neuralwatt/glm-5.2-short-fast-flex", + "name": "GLM 5.2 Short Fast Flex", + "family": "glm", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-17", + "last_updated": "2026-06-17", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.725, + "output": 2.25, + "cache_read": 0.18125 + }, + "limit": { + "context": 199984, + "output": 199984 + } + }, + { + "id": "neuralwatt/glm-5.2-short-flex", + "name": "GLM 5.2 Short Flex", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-17", + "last_updated": "2026-06-17", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.725, + "output": 2.25, + "cache_read": 0.18125 + }, + "limit": { + "context": 199984, + "output": 199984 + } + }, { "id": "neuralwatt/kimi-k2.5-fast", "name": "Kimi K2.5 Fast", @@ -92906,7 +95723,8 @@ "open_weights": true, "cost": { "input": 0.52, - "output": 2.59 + "output": 2.59, + "cache_read": 0.13 }, "limit": { "context": 262128, @@ -92935,13 +95753,75 @@ "open_weights": true, "cost": { "input": 0.69, - "output": 3.22 + "output": 3.22, + "cache_read": 0.1725 }, "limit": { "context": 262128, "output": 262128 } }, + { + "id": "neuralwatt/kimi-k2.6-flex", + "name": "Kimi K2.6 Flex", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.345, + "output": 1.61, + "cache_read": 0.08625 + }, + "limit": { + "context": 262128, + "output": 262128 + } + }, + { + "id": "neuralwatt/kimi-k2.7-code-flex", + "name": "Kimi K2.7 Code Flex", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.475, + "output": 2.0, + "cache_read": 0.11875 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "neuralwatt/moonshotai/Kimi-K2.5", "name": "Kimi K2.5", @@ -92964,7 +95844,8 @@ "open_weights": true, "cost": { "input": 0.52, - "output": 2.59 + "output": 2.59, + "cache_read": 0.13 }, "limit": { "context": 262128, @@ -92993,7 +95874,8 @@ "open_weights": true, "cost": { "input": 0.69, - "output": 3.22 + "output": 3.22, + "cache_read": 0.1725 }, "limit": { "context": 262128, @@ -93023,7 +95905,8 @@ "open_weights": true, "cost": { "input": 0.95, - "output": 4.0 + "output": 4.0, + "cache_read": 0.2375 }, "limit": { "context": 262144, @@ -93051,7 +95934,8 @@ "open_weights": true, "cost": { "input": 0.69, - "output": 4.14 + "output": 4.14, + "cache_read": 0.1725 }, "limit": { "context": 262128, @@ -93080,41 +95964,14 @@ "open_weights": true, "cost": { "input": 0.29, - "output": 1.15 + "output": 1.15, + "cache_read": 0.0725 }, "limit": { "context": 131056, "output": 131056 } }, - { - "id": "neuralwatt/zai-org/GLM-5.1-FP8", - "name": "GLM 5.1 FP8", - "family": "glm", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-04-07", - "last_updated": "2026-04-07", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 1.1, - "output": 3.6 - }, - "limit": { - "context": 202736, - "output": 202736 - } - }, { "id": "nova/nova-2-lite-v1", "name": "Nova 2 Lite", @@ -93848,9 +96705,9 @@ }, "open_weights": true, "cost": { - "input": 1.69, - "output": 3.38, - "cache_read": 0.13 + "input": 1.6, + "output": 3.2, + "cache_read": 0.135 }, "limit": { "context": 1048576, @@ -94009,7 +96866,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-04-23", - "last_updated": "2026-04-23", + "last_updated": "2026-06-29", "modalities": { "input": [ "text" @@ -94020,8 +96877,9 @@ }, "open_weights": true, "cost": { - "input": 0.0, - "output": 0.0 + "input": 0.3, + "output": 2.5, + "cache_read": 0.06 }, "limit": { "context": 262144, @@ -94634,7 +97492,7 @@ "tool_call": true, "temperature": true, "release_date": "2025-11-07", - "last_updated": "2025-11-07", + "last_updated": "2026-06-29", "modalities": { "input": [ "text" @@ -94646,7 +97504,8 @@ "open_weights": true, "cost": { "input": 0.6, - "output": 2.5 + "output": 2.5, + "cache_read": 0.15 }, "limit": { "context": 262144, @@ -94708,8 +97567,8 @@ }, "open_weights": true, "cost": { - "input": 0.95, - "output": 4.0, + "input": 0.8, + "output": 3.4, "cache_read": 0.16 }, "limit": { @@ -95182,8 +98041,8 @@ }, "open_weights": true, "cost": { - "input": 0.3, - "output": 1.3 + "input": 0.38, + "output": 1.55 }, "limit": { "context": 262144, @@ -95651,7 +98510,7 @@ "cost": { "input": 1.25, "output": 3.75, - "cache_read": 0.125, + "cache_read": 0.25, "cache_write": 1.5625 }, "limit": { @@ -95849,9 +98708,9 @@ }, "open_weights": true, "cost": { - "input": 2.0, - "output": 6.0, - "cache_read": 0.4 + "input": 0.522, + "output": 1.044, + "cache_read": 0.0043 }, "limit": { "context": 1048576, @@ -95937,7 +98796,8 @@ "open_weights": true, "cost": { "input": 0.13, - "output": 0.85 + "output": 0.85, + "cache_read": 0.025 }, "limit": { "context": 131072, @@ -96145,7 +99005,7 @@ }, "open_weights": true, "cost": { - "input": 1.4, + "input": 1.38, "output": 4.4, "cache_read": 0.26 }, @@ -96978,6 +99838,36 @@ "output": 131072 } }, + { + "id": "nvidia/minimaxai/minimax-m3", + "name": "MiniMax-M3", + "family": "minimax", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-01", + "last_updated": "2026-06-01", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 1000000, + "output": 16384 + } + }, { "id": "nvidia/mistralai/magistral-small", "name": "Magistral Small 2506", @@ -98498,15 +101388,15 @@ } }, { - "id": "nvidia/z-ai/glm-5.1", - "name": "GLM-5.1", + "id": "nvidia/z-ai/glm-5.2", + "name": "GLM-5.2", "family": "glm", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-03-27", - "last_updated": "2026-03-27", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "modalities": { "input": [ "text" @@ -98521,7 +101411,7 @@ "output": 0.0 }, "limit": { - "context": 131072, + "context": 1000000, "output": 131072 } }, @@ -100609,6 +103499,138 @@ "output": 128000 } }, + { + "id": "openai/gpt-5.6", + "name": "GPT-5.6", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 30.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "openai/gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "family": "gpt-nano", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 6.0, + "cache_read": 0.1, + "cache_write": 1.25 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "openai/gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 30.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "openai/gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "family": "gpt-mini", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.5, + "output": 15.0, + "cache_read": 0.25, + "cache_write": 3.125 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, { "id": "openai/gpt-image-1", "name": "gpt-image-1", @@ -101426,7 +104448,7 @@ }, { "id": "opencode-go/minimax-m2.5", - "name": "MiniMax M2.5", + "name": "MiniMax-M2.5", "family": "minimax-m2.5", "attachment": false, "reasoning": true, @@ -101456,7 +104478,7 @@ }, { "id": "opencode-go/minimax-m2.7", - "name": "MiniMax M2.7", + "name": "MiniMax-M2.7", "family": "minimax-m2.7", "attachment": false, "reasoning": true, @@ -101486,7 +104508,7 @@ }, { "id": "opencode-go/minimax-m3", - "name": "MiniMax M3 (3x usage)", + "name": "MiniMax-M3", "family": "minimax-m3", "attachment": false, "reasoning": true, @@ -101507,12 +104529,12 @@ }, "open_weights": true, "cost": { - "input": 0.1, - "output": 0.4, - "cache_read": 0.02 + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 }, "limit": { - "context": 512000, + "context": 1000000, "output": 131072 } }, @@ -101914,6 +104936,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -102037,6 +105060,39 @@ "output": 64000 } }, + { + "id": "opencode/claude-sonnet-5", + "name": "Claude Sonnet 5", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "opencode/deepseek-v4-flash", "name": "DeepSeek V4 Flash", @@ -102443,6 +105499,35 @@ "output": 131072 } }, + { + "id": "opencode/glm-5.2", + "name": "GLM-5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.26 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "opencode/gpt-5", "name": "GPT-5", @@ -102977,6 +106062,36 @@ "output": 128000 } }, + { + "id": "opencode/grok-4.5", + "name": "Grok 4.5", + "family": "grok", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-07-08", + "last_updated": "2026-07-08", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 6.0, + "cache_read": 0.5 + }, + "limit": { + "context": 500000, + "output": 500000 + } + }, { "id": "opencode/grok-build-0.1", "name": "Grok Build 0.1", @@ -103037,6 +106152,35 @@ "output": 256000 } }, + { + "id": "opencode/hy3-free", + "name": "Hy3 Free", + "family": "hy3-free", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-26", + "last_updated": "2026-06-26", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0 + }, + "limit": { + "context": 256000, + "output": 64000 + } + }, { "id": "opencode/hy3-preview-free", "name": "Hy3 preview Free", @@ -103223,6 +106367,38 @@ "output": 65536 } }, + { + "id": "opencode/kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.19 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "opencode/ling-2.6-flash-free", "name": "Ling 2.6 Flash Free", @@ -103380,7 +106556,7 @@ }, { "id": "opencode/minimax-m2.1", - "name": "MiniMax M2.1", + "name": "MiniMax-M2.1", "family": "minimax", "attachment": false, "reasoning": true, @@ -103410,7 +106586,7 @@ }, { "id": "opencode/minimax-m2.1-free", - "name": "MiniMax M2.1 Free", + "name": "MiniMax-M2.1 Free", "family": "minimax-free", "attachment": false, "reasoning": true, @@ -103440,7 +106616,7 @@ }, { "id": "opencode/minimax-m2.5", - "name": "MiniMax M2.5", + "name": "MiniMax-M2.5", "family": "minimax", "attachment": false, "reasoning": true, @@ -103470,7 +106646,7 @@ }, { "id": "opencode/minimax-m2.5-free", - "name": "MiniMax M2.5 Free", + "name": "MiniMax-M2.5 Free", "family": "minimax-free", "attachment": false, "reasoning": true, @@ -103500,7 +106676,7 @@ }, { "id": "opencode/minimax-m2.7", - "name": "MiniMax M2.7", + "name": "MiniMax-M2.7", "family": "minimax", "attachment": false, "reasoning": true, @@ -103528,9 +106704,40 @@ "output": 131072 } }, + { + "id": "opencode/minimax-m3", + "name": "MiniMax-M3", + "family": "minimax", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-01", + "last_updated": "2026-06-01", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 + }, + "limit": { + "context": 512000, + "output": 128000 + } + }, { "id": "opencode/minimax-m3-free", - "name": "MiniMax M3 Free", + "name": "MiniMax-M3 Free", "family": "minimax-m3-free", "attachment": false, "reasoning": true, @@ -103863,66 +107070,12 @@ "output": 4096 } }, - { - "id": "openrouter/aion-labs/aion-1.0", - "name": "Aion-1.0", - "attachment": false, - "reasoning": true, - "tool_call": false, - "temperature": true, - "release_date": "2025-02-04", - "last_updated": "2025-02-04", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 4.0, - "output": 8.0 - }, - "limit": { - "context": 131072, - "output": 32768 - } - }, - { - "id": "openrouter/aion-labs/aion-1.0-mini", - "name": "Aion-1.0-Mini", - "attachment": false, - "reasoning": true, - "tool_call": false, - "temperature": true, - "release_date": "2025-02-04", - "last_updated": "2025-02-04", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.7, - "output": 1.4 - }, - "limit": { - "context": 131072, - "output": 32768 - } - }, { "id": "openrouter/aion-labs/aion-2.0", "name": "Aion-2.0", "attachment": false, "reasoning": true, - "tool_call": false, + "tool_call": true, "temperature": true, "release_date": "2026-02-23", "last_updated": "2026-02-23", @@ -103945,6 +107098,62 @@ "output": 32768 } }, + { + "id": "openrouter/aion-labs/aion-3.0", + "name": "Aion-3.0", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-07-07", + "last_updated": "2026-07-07", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 6.0, + "cache_read": 0.75 + }, + "limit": { + "context": 131072, + "output": 32768 + } + }, + { + "id": "openrouter/aion-labs/aion-3.0-mini", + "name": "Aion-3.0-Mini", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-07-07", + "last_updated": "2026-07-07", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.7, + "output": 1.4, + "cache_read": 0.18 + }, + "limit": { + "context": 131072, + "output": 32768 + } + }, { "id": "openrouter/aion-labs/aion-rp-llama-3.1-8b", "name": "Aion-RP 1.0 (8B)", @@ -104212,6 +107421,39 @@ "output": 4096 } }, + { + "id": "openrouter/anthropic/claude-fable-5", + "name": "Claude Fable 5", + "family": "claude-fable", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-09", + "last_updated": "2026-06-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 10.0, + "output": 50.0, + "cache_read": 1.0, + "cache_write": 12.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "openrouter/anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5 (latest)", @@ -104319,7 +107561,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", + "knowledge": "2025-05", "release_date": "2025-11-24", "last_updated": "2025-11-24", "modalities": { @@ -104377,38 +107619,6 @@ "output": 128000 } }, - { - "id": "openrouter/anthropic/claude-opus-4.6-fast", - "name": "Claude Opus 4.6 (Fast)", - "family": "claude-opus", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-04-07", - "last_updated": "2026-04-07", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 30.0, - "output": 150.0, - "cache_read": 3.0, - "cache_write": 37.5 - }, - "limit": { - "context": 1000000, - "output": 128000 - } - }, { "id": "openrouter/anthropic/claude-opus-4.7", "name": "Claude Opus 4.7", @@ -104481,7 +107691,8 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -104637,6 +107848,39 @@ "output": 128000 } }, + { + "id": "openrouter/anthropic/claude-sonnet-5", + "name": "Claude Sonnet 5", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "openrouter/arcee-ai/coder-large", "name": "Coder Large", @@ -105181,8 +108425,8 @@ }, "open_weights": true, "cost": { - "input": 0.2, - "output": 0.77, + "input": 0.24, + "output": 0.9, "cache_read": 0.135 }, "limit": { @@ -105330,7 +108574,8 @@ "open_weights": true, "cost": { "input": 0.2288, - "output": 0.3432 + "output": 0.3432, + "cache_read": 0.02288 }, "limit": { "context": 128000, @@ -105389,10 +108634,10 @@ "cost": { "input": 0.09, "output": 0.18, - "cache_read": 0.02 + "cache_read": 0.018 }, "limit": { - "context": 1000000, + "context": 1048576, "output": 65536 } }, @@ -105426,34 +108671,6 @@ "output": 384000 } }, - { - "id": "openrouter/essentialai/rnj-1-instruct", - "name": "Rnj 1 Instruct", - "family": "rnj", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-07", - "last_updated": "2025-12-07", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.15, - "output": 0.15 - }, - "limit": { - "context": 32768, - "output": 32768 - } - }, { "id": "openrouter/google/gemini-2.5-flash", "name": "Gemini 2.5 Flash", @@ -105519,7 +108736,7 @@ }, "limit": { "context": 32768, - "output": 8192 + "output": 32768 } }, { @@ -105557,41 +108774,6 @@ "output": 65535 } }, - { - "id": "openrouter/google/gemini-2.5-flash-lite-preview-09", - "name": "Gemini 2.5 Flash Lite Preview 09-2025", - "family": "gemini-flash-lite", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-01-31", - "release_date": "2025-09-25", - "last_updated": "2025-09-25", - "modalities": { - "input": [ - "text", - "image", - "pdf", - "audio", - "video" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.1, - "output": 0.4, - "cache_read": 0.01, - "cache_write": 0.083333 - }, - "limit": { - "context": 1048576, - "output": 65535 - } - }, { "id": "openrouter/google/gemini-2.5-pro", "name": "Gemini 2.5 Pro", @@ -105822,8 +109004,8 @@ "output": 3.0 }, "limit": { - "context": 65536, - "output": 65536 + "context": 131072, + "output": 32768 } }, { @@ -105892,6 +109074,37 @@ "output": 65536 } }, + { + "id": "openrouter/google/gemini-3.1-flash-lite-image", + "name": "Nano Banana 2 Lite (Gemini 3.1 Flash Lite Image)", + "family": "gemini", + "attachment": true, + "reasoning": true, + "tool_call": false, + "temperature": true, + "knowledge": "2025-01-01", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "image", + "text" + ], + "output": [ + "image", + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.25, + "output": 1.5 + }, + "limit": { + "context": 65536, + "output": 66000 + } + }, { "id": "openrouter/google/gemini-3.1-flash-lite-preview", "name": "Gemini 3.1 Flash Lite Preview", @@ -106647,34 +109860,6 @@ "output": 80000 } }, - { - "id": "openrouter/liquid/lfm-2-24b-a2b", - "name": "LFM2-24B-A2B", - "family": "liquid", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2026-02-25", - "last_updated": "2026-02-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.03, - "output": 0.12 - }, - "limit": { - "context": 32768, - "output": 32768 - } - }, { "id": "openrouter/liquid/lfm-2.5-1.2b-instruct:free", "name": "LFM2.5-1.2B-Instruct (free)", @@ -106929,12 +110114,12 @@ }, "open_weights": true, "cost": { - "input": 0.0509, - "output": 0.335 + "input": 0.05, + "output": 0.33 }, "limit": { - "context": 80000, - "output": 80000 + "context": 131072, + "output": 131072 } }, { @@ -107135,7 +110320,7 @@ }, "open_weights": true, "cost": { - "input": 0.065, + "input": 0.07, "output": 0.14 }, "limit": { @@ -107143,35 +110328,6 @@ "output": 16384 } }, - { - "id": "openrouter/microsoft/phi-4-mini-instruct", - "name": "Phi 4 Mini Instruct", - "family": "phi", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-10-17", - "last_updated": "2025-10-17", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.08, - "output": 0.35, - "cache_read": 0.08 - }, - "limit": { - "context": 128000, - "output": 128000 - } - }, { "id": "openrouter/microsoft/wizardlm-2-8x22b", "name": "WizardLM-2 8x22B", @@ -107280,12 +110436,11 @@ "open_weights": true, "cost": { "input": 0.255, - "output": 1.0, - "cache_read": 0.03 + "output": 1.02 }, "limit": { - "context": 196608, - "output": 196608 + "context": 204800, + "output": 131072 } }, { @@ -107337,13 +110492,13 @@ }, "open_weights": true, "cost": { - "input": 0.29, - "output": 0.95, + "input": 0.3, + "output": 1.2, "cache_read": 0.03 }, "limit": { - "context": 196608, - "output": 196608 + "context": 204800, + "output": 131072 } }, { @@ -107395,13 +110550,12 @@ }, "open_weights": true, "cost": { - "input": 0.25, - "output": 1.0, - "cache_read": 0.05 + "input": 0.24, + "output": 0.96 }, "limit": { "context": 196608, - "output": 131072 + "output": 196608 } }, { @@ -107980,7 +111134,7 @@ }, "limit": { "context": 131072, - "output": 32768 + "output": 100352 } }, { @@ -108005,11 +111159,12 @@ "open_weights": true, "cost": { "input": 0.6, - "output": 2.5 + "output": 2.5, + "cache_read": 0.15 }, "limit": { "context": 262144, - "output": 262144 + "output": 100352 } }, { @@ -108035,11 +111190,12 @@ "open_weights": true, "cost": { "input": 0.375, - "output": 2.025 + "output": 2.025, + "cache_read": 0.203 }, "limit": { "context": 256000, - "output": 262144 + "output": 256000 } }, { @@ -108064,9 +111220,9 @@ }, "open_weights": true, "cost": { - "input": 0.66, + "input": 0.65, "output": 3.41, - "cache_read": 0.144 + "cache_read": 0.14 }, "limit": { "context": 262144, @@ -108095,9 +111251,9 @@ }, "open_weights": true, "cost": { - "input": 0.612, - "output": 3.069, - "cache_read": 0.1296 + "input": 0.72, + "output": 3.5, + "cache_read": 0.15 }, "limit": { "context": 262144, @@ -108160,13 +111316,43 @@ "output": 131072 } }, + { + "id": "openrouter/nex-agi/nex-n2-mini", + "name": "Nex-N2-Mini", + "family": "agi", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-24", + "last_updated": "2026-06-24", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.025, + "output": 0.1, + "cache_read": 0.0025 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "openrouter/nex-agi/nex-n2-pro", "name": "Nex-N2-Pro", "family": "agi", "attachment": true, "reasoning": true, - "tool_call": false, + "tool_call": true, "temperature": true, "release_date": "2026-06-08", "last_updated": "2026-06-08", @@ -108181,9 +111367,9 @@ }, "open_weights": true, "cost": { - "input": 0.5, - "output": 2.5, - "cache_read": 0.25 + "input": 0.25, + "output": 1.0, + "cache_read": 0.025 }, "limit": { "context": 262144, @@ -108470,12 +111656,12 @@ }, "open_weights": true, "cost": { - "input": 0.09, + "input": 0.08, "output": 0.45 }, "limit": { "context": 262144, - "output": 262144 + "output": 16384 } }, { @@ -109865,6 +113051,204 @@ "output": 128000 } }, + { + "id": "openrouter/openai/gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "family": "gpt-nano", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 6.0, + "cache_read": 0.1, + "cache_write": 1.25 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "openrouter/openai/gpt-5.6-luna-pro", + "name": "GPT-5.6 Luna Pro", + "family": "gpt-nano", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 6.0, + "cache_read": 0.1, + "cache_write": 1.25 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "openrouter/openai/gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 30.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "openrouter/openai/gpt-5.6-sol-pro", + "name": "GPT-5.6 Sol Pro", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 30.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "openrouter/openai/gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "family": "gpt-mini", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.5, + "output": 15.0, + "cache_read": 0.25, + "cache_write": 3.125 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "openrouter/openai/gpt-5.6-terra-pro", + "name": "GPT-5.6 Terra Pro", + "family": "gpt-mini", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.5, + "output": 15.0, + "cache_read": 0.25, + "cache_write": 3.125 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, { "id": "openrouter/openai/gpt-audio", "name": "GPT Audio", @@ -109958,13 +113342,12 @@ }, { "id": "openrouter/openai/gpt-oss-120b", - "name": "gpt-oss-120b", + "name": "GPT OSS 120B", "family": "gpt-oss", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2024-06-30", "release_date": "2025-08-05", "last_updated": "2025-08-05", "modalities": { @@ -109977,12 +113360,12 @@ }, "open_weights": true, "cost": { - "input": 0.039, + "input": 0.036, "output": 0.18 }, "limit": { "context": 131072, - "output": 32768 + "output": 131072 } }, { @@ -109993,7 +113376,6 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2024-06-30", "release_date": "2025-08-05", "last_updated": "2025-08-05", "modalities": { @@ -110016,13 +113398,12 @@ }, { "id": "openrouter/openai/gpt-oss-20b", - "name": "gpt-oss-20b", + "name": "GPT OSS 20B", "family": "gpt-oss", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2024-06-30", "release_date": "2025-08-05", "last_updated": "2025-08-05", "modalities": { @@ -110051,7 +113432,6 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2024-06-30", "release_date": "2025-08-05", "last_updated": "2025-08-05", "modalities": { @@ -110523,34 +113903,6 @@ "output": 128000 } }, - { - "id": "openrouter/openrouter/owl-alpha", - "name": "Owl Alpha", - "family": "alpha", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2026-04-28", - "last_updated": "2026-04-28", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 1048756, - "output": 262144 - } - }, { "id": "openrouter/openrouter/pareto-code", "name": "Pareto Code Router", @@ -110804,14 +114156,14 @@ } }, { - "id": "openrouter/poolside/laguna-xs.2", - "name": "Laguna XS.2", + "id": "openrouter/poolside/laguna-xs-2.1", + "name": "Laguna XS 2.1", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-04-28", - "last_updated": "2026-04-28", + "release_date": "2026-07-02", + "last_updated": "2026-07-02", "modalities": { "input": [ "text" @@ -110822,9 +114174,9 @@ }, "open_weights": true, "cost": { - "input": 0.1, - "output": 0.2, - "cache_read": 0.05 + "input": 0.06, + "output": 0.12, + "cache_read": 0.03 }, "limit": { "context": 262144, @@ -110832,14 +114184,14 @@ } }, { - "id": "openrouter/poolside/laguna-xs.2:free", - "name": "Laguna XS.2 (free)", + "id": "openrouter/poolside/laguna-xs-2.1:free", + "name": "Laguna XS 2.1 (free)", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-04-28", - "last_updated": "2026-04-28", + "release_date": "2026-07-02", + "last_updated": "2026-07-02", "modalities": { "input": [ "text" @@ -110858,35 +114210,6 @@ "output": 32768 } }, - { - "id": "openrouter/prime-intellect/intellect-3", - "name": "INTELLECT-3", - "family": "glm", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-10", - "release_date": "2025-11-27", - "last_updated": "2025-11-27", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.2, - "output": 1.1 - }, - "limit": { - "context": 131072, - "output": 131072 - } - }, { "id": "openrouter/qwen/qwen-2.5-72b-instruct", "name": "Qwen2.5 72B Instruct", @@ -111145,12 +114468,11 @@ }, "open_weights": true, "cost": { - "input": 0.1, - "output": 0.1, - "cache_read": 0.1 + "input": 0.1495, + "output": 1.495 }, "limit": { - "context": 262144, + "context": 131072, "output": 262144 } }, @@ -111233,13 +114555,12 @@ }, "open_weights": true, "cost": { - "input": 0.08, - "output": 0.4, - "cache_read": 0.08 + "input": 0.13, + "output": 1.56 }, "limit": { - "context": 131072, - "output": 131072 + "context": 81920, + "output": 32768 } }, { @@ -111292,12 +114613,11 @@ }, "open_weights": true, "cost": { - "input": 0.05, - "output": 0.4, - "cache_read": 0.05 + "input": 0.117, + "output": 0.455 }, "limit": { - "context": 40960, + "context": 131072, "output": 8192 } }, @@ -111796,8 +115116,8 @@ }, "open_weights": true, "cost": { - "input": 0.08, - "output": 0.5 + "input": 0.117, + "output": 0.455 }, "limit": { "context": 131072, @@ -111916,11 +115236,12 @@ "open_weights": true, "cost": { "input": 0.14, - "output": 1.0 + "output": 1.0, + "cache_read": 0.05 }, "limit": { "context": 262144, - "output": 262144 + "output": 81920 } }, { @@ -111946,11 +115267,12 @@ "open_weights": true, "cost": { "input": 0.385, - "output": 2.45 + "output": 2.45, + "cache_read": 0.111 }, "limit": { "context": 131072, - "output": 65536 + "output": 64000 } }, { @@ -112097,8 +115419,9 @@ }, "open_weights": true, "cost": { - "input": 0.2885, - "output": 3.17 + "input": 0.285, + "output": 2.4, + "cache_read": 0.15 }, "limit": { "context": 262140, @@ -112403,6 +115726,36 @@ "output": 128000 } }, + { + "id": "openrouter/sakana/fugu-ultra", + "name": "Fugu Ultra", + "family": "fugu", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "release_date": "2026-06-24", + "last_updated": "2026-06-24", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 30.0, + "cache_read": 0.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "openrouter/sao10k/l3-lunaris-8b", "name": "Llama 3 8B Lunaris", @@ -112539,13 +115892,12 @@ }, "open_weights": true, "cost": { - "input": 0.09, - "output": 0.3, - "cache_read": 0.02 + "input": 0.1, + "output": 0.3 }, "limit": { "context": 262144, - "output": 16384 + "output": 65536 } }, { @@ -112579,33 +115931,6 @@ "output": 256000 } }, - { - "id": "openrouter/switchpoint/router", - "name": "Switchpoint Router", - "attachment": false, - "reasoning": true, - "tool_call": false, - "temperature": true, - "release_date": "2025-07-11", - "last_updated": "2025-07-11", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.85, - "output": 3.4 - }, - "limit": { - "context": 131072, - "output": 131072 - } - }, { "id": "openrouter/tencent/hunyuan-a13b-instruct", "name": "Hunyuan A13B Instruct", @@ -112635,6 +115960,35 @@ "output": 131072 } }, + { + "id": "openrouter/tencent/hy3", + "name": "Hy3", + "family": "hy3", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-07-06", + "last_updated": "2026-07-06", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.14, + "output": 0.58, + "cache_read": 0.035 + }, + "limit": { + "context": 262144, + "output": 131072 + } + }, { "id": "openrouter/tencent/hy3-preview", "name": "Hy3 preview", @@ -112664,6 +116018,34 @@ "output": 262144 } }, + { + "id": "openrouter/tencent/hy3:free", + "name": "Hy3 (free)", + "family": "hy3", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-07-06", + "last_updated": "2026-07-06", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "openrouter/thedrummer/cydonia-24b-v4.1", "name": "Cydonia 24B V4.1", @@ -112698,7 +116080,7 @@ "name": "Rocinante 12B", "attachment": false, "reasoning": false, - "tool_call": true, + "tool_call": false, "temperature": true, "knowledge": "2024-04-30", "release_date": "2024-09-30", @@ -112713,12 +116095,12 @@ }, "open_weights": true, "cost": { - "input": 0.17, - "output": 0.43 + "input": 0.25, + "output": 0.5 }, "limit": { - "context": 32768, - "output": 32768 + "context": 65536, + "output": 65536 } }, { @@ -112940,7 +116322,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -112957,6 +116340,37 @@ "output": 1000000 } }, + { + "id": "openrouter/x-ai/grok-4.5", + "name": "Grok 4.5", + "family": "grok", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-07-08", + "last_updated": "2026-07-08", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 6.0, + "cache_read": 0.5 + }, + "limit": { + "context": 500000, + "output": 500000 + } + }, { "id": "openrouter/x-ai/grok-build-0.1", "name": "Grok Build 0.1", @@ -112970,7 +116384,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -113011,12 +116426,12 @@ }, "open_weights": true, "cost": { - "input": 0.14, + "input": 0.105, "output": 0.28, - "cache_read": 0.0028 + "cache_read": 0.028 }, "limit": { - "context": 1048576, + "context": 32000, "output": 131072 } }, @@ -113289,7 +116704,7 @@ }, "limit": { "context": 202752, - "output": 16384 + "output": 128000 } }, { @@ -113341,13 +116756,13 @@ }, "open_weights": true, "cost": { - "input": 0.98, - "output": 3.08, - "cache_read": 0.49 + "input": 0.966, + "output": 3.036, + "cache_read": 0.1794 }, "limit": { - "context": 202752, - "output": 65535 + "context": 200000, + "output": 128000 } }, { @@ -113370,13 +116785,44 @@ }, "open_weights": true, "cost": { - "input": 1.0, - "output": 4.0, - "cache_read": 0.18 + "input": 0.54, + "output": 1.76, + "cache_read": 0.1 }, "limit": { - "context": 1048576, - "output": 32768 + "context": 101376, + "output": 101376 + } + }, + { + "id": "openrouter/z-ai/glm-5v-turbo", + "name": "GLM-5V-Turbo", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-01", + "last_updated": "2026-04-01", + "modalities": { + "input": [ + "image", + "text", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.2, + "output": 4.0, + "cache_read": 0.24 + }, + "limit": { + "context": 202752, + "output": 131072 } }, { @@ -113450,7 +116896,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "release_date": "2026-04-21", "last_updated": "2026-04-21", "modalities": { @@ -113482,7 +116928,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, "release_date": "2026-04-27", "last_updated": "2026-04-27", "modalities": { @@ -113497,10 +116943,10 @@ }, "open_weights": false, "cost": { - "input": 3.0, - "output": 15.0, - "cache_read": 0.3, - "cache_write": 3.75 + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write": 2.5 }, "limit": { "context": 1000000, @@ -113597,9 +117043,9 @@ }, "open_weights": false, "cost": { - "input": 0.66, + "input": 0.65, "output": 3.41, - "cache_read": 0.144 + "cache_read": 0.14 }, "limit": { "context": 262144, @@ -113614,7 +117060,7 @@ "reasoning": true, "tool_call": true, "temperature": false, - "knowledge": "2025-12-01", + "knowledge": "2026-02-16", "release_date": "2026-04-27", "last_updated": "2026-04-27", "modalities": { @@ -113631,7 +117077,8 @@ "cost": { "input": 5.0, "output": 30.0, - "cache_read": 0.5 + "cache_read": 0.5, + "cache_write": 6.25 }, "limit": { "context": 1050000, @@ -113670,6 +117117,37 @@ "output": 128000 } }, + { + "id": "openrouter/~x-ai/grok", + "name": "Grok Latest", + "family": "grok", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-07-08", + "last_updated": "2026-07-08", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 6.0, + "cache_read": 0.5 + }, + "limit": { + "context": 500000, + "output": 1000000 + } + }, { "id": "orcarouter/anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5 (latest)", @@ -113777,7 +117255,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", + "knowledge": "2025-05", "release_date": "2025-11-24", "last_updated": "2025-11-24", "modalities": { @@ -116175,33 +119653,6 @@ "output": 131072 } }, - { - "id": "ovhcloud/llama-3.1-8b-instruct", - "name": "Llama-3.1-8B-Instruct", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-06-11", - "last_updated": "2025-06-11", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.11, - "output": 0.11 - }, - "limit": { - "context": 131072, - "output": 131072 - } - }, { "id": "ovhcloud/meta-llama-3_3-70b-instruct", "name": "Meta-Llama-3_3-70B-Instruct", @@ -117478,6 +120929,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -123501,7 +126953,7 @@ "id": "qiniu-ai/x-ai/grok-4-fast-non-reasoning", "name": "X-Ai/Grok-4-Fast-Non-Reasoning", "attachment": true, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "release_date": "2025-12-18", @@ -123579,7 +127031,7 @@ "id": "qiniu-ai/x-ai/grok-4.1-fast-non-reasoning", "name": "X-Ai/Grok 4.1 Fast Non Reasoning", "attachment": true, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "release_date": "2025-12-19", @@ -125364,38 +128816,69 @@ } }, { - "id": "routing-run/route/deepseek-v3.2", - "name": "DeepSeek V3.2", - "family": "deepseek", + "id": "routing-run/claude-opus-4.8", + "name": "Claude Opus 4.8", + "family": "claude-opus", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, - "knowledge": "2024-07", - "release_date": "2025-12-01", - "last_updated": "2025-12-01", + "temperature": false, + "knowledge": "2026-01", + "release_date": "2026-05-28", + "last_updated": "2026-05-28", "modalities": { "input": [ "text", "image", - "video" + "pdf" ], "output": [ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { - "input": 0.4928, - "output": 0.7392 + "input": 5.0, + "output": 25.0 }, "limit": { - "context": 163840, - "output": 163840 + "context": 1000000, + "output": 32000 } }, { - "id": "routing-run/route/deepseek-v4-flash", + "id": "routing-run/claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-08-31", + "release_date": "2026-02-17", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 15.0 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "routing-run/deepseek-v4-flash", "name": "DeepSeek V4 Flash", "family": "deepseek-flash", "attachment": false, @@ -125415,47 +128898,16 @@ }, "open_weights": true, "cost": { - "input": 0.4928, - "output": 0.7392, - "cache_read": 0.0028 + "input": 0.112, + "output": 0.224 }, "limit": { "context": 1000000, - "output": 131072 + "output": 64000 } }, { - "id": "routing-run/route/deepseek-v4-flash-6bit", - "name": "DeepSeek V4 Flash 6bit", - "family": "deepseek-flash", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-05", - "release_date": "2026-04-24", - "last_updated": "2026-04-24", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.4928, - "output": 0.7392, - "cache_read": 0.0028 - }, - "limit": { - "context": 1000000, - "output": 131072 - } - }, - { - "id": "routing-run/route/deepseek-v4-pro", + "id": "routing-run/deepseek-v4-pro", "name": "DeepSeek V4 Pro", "family": "deepseek-thinking", "attachment": false, @@ -125475,85 +128927,24 @@ }, "open_weights": true, "cost": { - "input": 0.4928, - "output": 0.7392, - "cache_read": 0.003625 + "input": 0.348, + "output": 0.696 }, "limit": { "context": 1000000, - "output": 131072 + "output": 64000 } }, { - "id": "routing-run/route/deepseek-v4-pro-6bit", - "name": "DeepSeek V4 Pro 6bit", - "family": "deepseek-thinking", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-05", - "release_date": "2026-04-24", - "last_updated": "2026-04-24", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.4928, - "output": 0.7392, - "cache_read": 0.003625 - }, - "limit": { - "context": 1000000, - "output": 131072 - } - }, - { - "id": "routing-run/route/gemma-4-31b-it", - "name": "Gemma 4 31B IT", - "family": "gemma", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2026-04-02", - "last_updated": "2026-04-02", - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.1, - "output": 0.3 - }, - "limit": { - "context": 131072, - "output": 65536 - } - }, - { - "id": "routing-run/route/glm-5.1", - "name": "GLM 5.1", + "id": "routing-run/glm-5.2", + "name": "GLM-5.2", "family": "glm", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-04-07", - "last_updated": "2026-04-07", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "modalities": { "input": [ "text" @@ -125564,26 +128955,24 @@ }, "open_weights": true, "cost": { - "input": 1.0, - "output": 3.0, - "cache_read": 0.26, - "cache_write": 0.0 + "input": 0.8, + "output": 2.4 }, "limit": { - "context": 202752, - "output": 65536 + "context": 200000, + "output": 32000 } }, { - "id": "routing-run/route/glm-5.1-6bit", - "name": "GLM 5.1 6bit", + "id": "routing-run/glm-5.2-nitro", + "name": "GLM 5.2 Nitro", "family": "glm", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-04-07", - "last_updated": "2026-04-07", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "modalities": { "input": [ "text" @@ -125594,50 +128983,16 @@ }, "open_weights": true, "cost": { - "input": 1.0, - "output": 3.0, - "cache_read": 0.26, - "cache_write": 0.0 + "input": 0.8, + "output": 2.4 }, "limit": { - "context": 202752, - "output": 65536 + "context": 200000, + "output": 32000 } }, { - "id": "routing-run/route/kimi-k2.5", - "name": "Kimi K2.5", - "family": "kimi-k2", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": false, - "knowledge": "2025-01", - "release_date": "2026-01", - "last_updated": "2026-01", - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.462, - "output": 2.42, - "cache_read": 0.1 - }, - "limit": { - "context": 131072, - "output": 32768 - } - }, - { - "id": "routing-run/route/kimi-k2.6", + "id": "routing-run/kimi-k2.6", "name": "Kimi K2.6", "family": "kimi-k2", "attachment": true, @@ -125659,18 +129014,17 @@ }, "open_weights": true, "cost": { - "input": 0.462, - "output": 2.42, - "cache_read": 0.16 + "input": 0.275, + "output": 1.1 }, "limit": { - "context": 262144, - "output": 262144 + "context": 200000, + "output": 32000 } }, { - "id": "routing-run/route/kimi-k2.6-6bit", - "name": "Kimi K2.6 6bit", + "id": "routing-run/kimi-k2.6-nitro", + "name": "Kimi K2.6 Nitro", "family": "kimi-k2", "attachment": true, "reasoning": true, @@ -125691,276 +129045,172 @@ }, "open_weights": true, "cost": { - "input": 0.462, - "output": 2.42, - "cache_read": 0.16 + "input": 0.275, + "output": 1.1 + }, + "limit": { + "context": 200000, + "output": 32000 + } + }, + { + "id": "routing-run/kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.275, + "output": 1.1 + }, + "limit": { + "context": 200000, + "output": 32000 + } + }, + { + "id": "routing-run/kimi-k2.7-code-nitro", + "name": "Kimi K2.7 Code Nitro", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.275, + "output": 1.1 + }, + "limit": { + "context": 200000, + "output": 32000 + } + }, + { + "id": "routing-run/nemotron-3-ultra", + "name": "Nemotron 3 Ultra 550B A55B", + "family": "nemotron", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-04", + "last_updated": "2026-06-04", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.1, + "output": 0.1 + }, + "limit": { + "context": 131072, + "output": 32000 + } + }, + { + "id": "routing-run/qwen3.5-9b", + "name": "Qwen3.5 9B", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-23", + "last_updated": "2026-02-23", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.16, + "output": 0.48 }, "limit": { "context": 262144, - "output": 262144 + "output": 32000 } }, { - "id": "routing-run/route/mimo-v2.5", - "name": "MiMo V2.5", - "family": "mimo", + "id": "sakana/fugu", + "name": "Fugu", + "family": "fugu", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, - "knowledge": "2024-12", - "release_date": "2026-04-22", - "last_updated": "2026-04-22", + "temperature": false, + "release_date": "2026-06-15", + "last_updated": "2026-06-15", "modalities": { "input": [ "text", - "image", - "video" + "image" ], "output": [ "text" ] }, - "open_weights": true, - "cost": { - "input": 0.45, - "output": 1.35, - "cache_read": 0.2 - }, + "open_weights": false, + "cost": {}, "limit": { "context": 1000000, - "output": 262144 + "output": 1000000 } }, { - "id": "routing-run/route/mimo-v2.5-pro", - "name": "MiMo V2.5 Pro", - "family": "mimo", + "id": "sakana/fugu-ultra", + "name": "Fugu Ultra", + "family": "fugu", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, - "knowledge": "2024-12", - "release_date": "2026-04-22", - "last_updated": "2026-04-22", + "temperature": false, + "release_date": "2026-06-15", + "last_updated": "2026-06-15", "modalities": { "input": [ "text", - "image", - "video" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.45, - "output": 1.35, - "cache_read": 0.2 - }, - "limit": { - "context": 1000000, - "output": 262144 - } - }, - { - "id": "routing-run/route/mimo-v2.5-pro-6bit", - "name": "MiMo V2.5 Pro 6bit", - "family": "mimo", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-12", - "release_date": "2026-04-22", - "last_updated": "2026-04-22", - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.45, - "output": 1.35, - "cache_read": 0.2 - }, - "limit": { - "context": 1000000, - "output": 262144 - } - }, - { - "id": "routing-run/route/minimax-m2.5", - "name": "MiniMax M2.5", - "family": "minimax", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2026-02-12", - "last_updated": "2026-02-12", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.193, - "output": 1.238, - "cache_read": 0.03, - "cache_write": 0.375 - }, - "limit": { - "context": 100000, - "output": 131072 - } - }, - { - "id": "routing-run/route/minimax-m2.5-highspeed", - "name": "MiniMax M2.5 Highspeed", - "family": "minimax", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2026-02-13", - "last_updated": "2026-02-13", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.193, - "output": 1.238, - "cache_read": 0.06, - "cache_write": 0.375 - }, - "limit": { - "context": 100000, - "output": 131072 - } - }, - { - "id": "routing-run/route/minimax-m2.7", - "name": "MiniMax M2.7", - "family": "minimax", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-03-18", - "last_updated": "2026-03-18", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.33, - "output": 1.32, - "cache_read": 0.06, - "cache_write": 0.375 - }, - "limit": { - "context": 100000, - "output": 131072 - } - }, - { - "id": "routing-run/route/minimax-m2.7-highspeed", - "name": "MiniMax M2.7 Highspeed", - "family": "minimax", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-03-18", - "last_updated": "2026-03-18", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.33, - "output": 1.32, - "cache_read": 0.06, - "cache_write": 0.375 - }, - "limit": { - "context": 100000, - "output": 131072 - } - }, - { - "id": "routing-run/route/mistral-large-3", - "name": "Mistral Large 3", - "family": "mistral-large", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-11", - "release_date": "2024-11-01", - "last_updated": "2025-12-02", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.5, - "output": 1.5 - }, - "limit": { - "context": 128000, - "output": 32768 - } - }, - { - "id": "routing-run/route/mistral-medium", - "name": "Mistral Medium 2505", - "family": "mistral-medium", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2025-05", - "release_date": "2025-05-07", - "last_updated": "2025-05-07", - "modalities": { - "input": [ - "text", - "image", - "video" + "image" ], "output": [ "text" @@ -125968,157 +129218,13 @@ }, "open_weights": false, "cost": { - "input": 0.4, - "output": 2.0 + "input": 5.0, + "output": 30.0, + "cache_read": 0.5 }, "limit": { - "context": 128000, - "output": 32768 - } - }, - { - "id": "routing-run/route/mistral-small", - "name": "Mistral Small 2503", - "family": "mistral-small", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2025-06", - "release_date": "2026-03-16", - "last_updated": "2026-03-16", - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.15, - "output": 0.6 - }, - "limit": { - "context": 128000, - "output": 32768 - } - }, - { - "id": "routing-run/route/qwen3.6-27b", - "name": "Qwen3.6 27B", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2026-04-22", - "last_updated": "2026-04-22", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 1.1, - "output": 3.3 - }, - "limit": { - "context": 202000, - "output": 32768 - } - }, - { - "id": "routing-run/route/qwen3.6-27b-202k", - "name": "Qwen3.6 27B 202K", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2026-04-22", - "last_updated": "2026-04-22", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 1.1, - "output": 3.3 - }, - "limit": { - "context": 202000, - "output": 32768 - } - }, - { - "id": "routing-run/route/step-3.5-flash", - "name": "Step 3.5 Flash", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-01", - "release_date": "2026-01-29", - "last_updated": "2026-02-13", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.096, - "output": 0.288, - "cache_read": 0.019 - }, - "limit": { - "context": 262144, - "output": 65536 - } - }, - { - "id": "routing-run/route/stepfun-3.5-flash", - "name": "StepFun 3.5 Flash", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-01", - "release_date": "2026-01-29", - "last_updated": "2026-02-13", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.096, - "output": 0.288, - "cache_read": 0.019 - }, - "limit": { - "context": 262144, - "output": 65536 + "context": 1000000, + "output": 1000000 } }, { @@ -126550,6 +129656,39 @@ "output": 128000 } }, + { + "id": "sap-ai-core/anthropic--claude-4.8-opus", + "name": "anthropic--claude-4.8-opus", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01", + "release_date": "2026-05-28", + "last_updated": "2026-05-28", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "sap-ai-core/gemini-2.5-flash", "name": "gemini-2.5-flash", @@ -126652,6 +129791,74 @@ "output": 65536 } }, + { + "id": "sap-ai-core/gemini-3.1-flash-lite", + "name": "gemini-3.1-flash-lite", + "family": "gemini-flash-lite", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-05-07", + "last_updated": "2026-05-07", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.25, + "output": 1.5, + "cache_read": 0.025 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "sap-ai-core/gemini-3.5-flash", + "name": "gemini-3.5-flash", + "family": "gemini-flash", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-05-19", + "last_updated": "2026-05-19", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.5, + "output": 9.0, + "cache_read": 0.15 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, { "id": "sap-ai-core/gpt-4.1", "name": "gpt-4.1", @@ -127128,12 +130335,40 @@ "output": 16384 } }, + { + "id": "scaleway/glm-5.2", + "name": "GLM-5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.8, + "output": 5.5 + }, + "limit": { + "context": 256000, + "output": 16384 + } + }, { "id": "scaleway/gpt-oss-120b", "name": "GPT-OSS 120B", "family": "gpt-oss", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2024-01-01", @@ -127507,34 +130742,6 @@ "output": 262000 } }, - { - "id": "siliconflow-cn/Kwaipilot/KAT-Dev", - "name": "Kwaipilot/KAT-Dev", - "family": "kat-coder", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-09-27", - "last_updated": "2026-01-16", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.2, - "output": 0.6 - }, - "limit": { - "context": 128000, - "output": 128000 - } - }, { "id": "siliconflow-cn/PaddlePaddle/PaddleOCR-VL-1.5", "name": "PaddlePaddle/PaddleOCR-VL-1.5", @@ -127563,34 +130770,6 @@ "output": 16384 } }, - { - "id": "siliconflow-cn/Pro/MiniMaxAI/MiniMax-M2.1", - "name": "Pro/MiniMaxAI/MiniMax-M2.1", - "family": "minimax", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-23", - "last_updated": "2025-12-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.3, - "output": 1.2 - }, - "limit": { - "context": 197000, - "output": 131000 - } - }, { "id": "siliconflow-cn/Pro/MiniMaxAI/MiniMax-M2.5", "name": "Pro/MiniMaxAI/MiniMax-M2.5", @@ -127731,66 +130910,10 @@ "output": 164000 } }, - { - "id": "siliconflow-cn/Pro/moonshotai/Kimi-K2-Instruct", - "name": "Pro/moonshotai/Kimi-K2-Instruct-0905", - "family": "kimi-k2", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-09-08", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.4, - "output": 2.0 - }, - "limit": { - "context": 262000, - "output": 262000 - } - }, - { - "id": "siliconflow-cn/Pro/moonshotai/Kimi-K2-Thinking", - "name": "Pro/moonshotai/Kimi-K2-Thinking", - "family": "kimi-thinking", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-11-07", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.55, - "output": 2.5 - }, - "limit": { - "context": 262000, - "output": 262000 - } - }, { "id": "siliconflow-cn/Pro/moonshotai/Kimi-K2.5", "name": "Pro/moonshotai/Kimi-K2.5", - "family": "kimi-k2", + "family": "kimi", "attachment": false, "reasoning": true, "tool_call": true, @@ -127819,7 +130942,7 @@ { "id": "siliconflow-cn/Pro/moonshotai/Kimi-K2.6", "name": "Pro/moonshotai/Kimi-K2.6", - "family": "kimi-k2", + "family": "kimi", "attachment": false, "reasoning": true, "tool_call": true, @@ -127846,34 +130969,6 @@ "output": 262000 } }, - { - "id": "siliconflow-cn/Pro/zai-org/GLM-4.7", - "name": "Pro/zai-org/GLM-4.7", - "family": "glm", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-22", - "last_updated": "2025-12-22", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.6, - "output": 2.2 - }, - "limit": { - "context": 205000, - "output": 205000 - } - }, { "id": "siliconflow-cn/Pro/zai-org/GLM-5", "name": "Pro/zai-org/GLM-5", @@ -127931,90 +131026,6 @@ "output": 205000 } }, - { - "id": "siliconflow-cn/Qwen/QwQ-32B", - "name": "Qwen/QwQ-32B", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-03-06", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.15, - "output": 0.58 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, - { - "id": "siliconflow-cn/Qwen/Qwen2.5-14B-Instruct", - "name": "Qwen/Qwen2.5-14B-Instruct", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2024-09-18", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.1, - "output": 0.1 - }, - "limit": { - "context": 33000, - "output": 4000 - } - }, - { - "id": "siliconflow-cn/Qwen/Qwen2.5-32B-Instruct", - "name": "Qwen/Qwen2.5-32B-Instruct", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2024-09-19", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.18, - "output": 0.18 - }, - "limit": { - "context": 33000, - "output": 4000 - } - }, { "id": "siliconflow-cn/Qwen/Qwen2.5-72B-Instruct", "name": "Qwen/Qwen2.5-72B-Instruct", @@ -128043,34 +131054,6 @@ "output": 4000 } }, - { - "id": "siliconflow-cn/Qwen/Qwen2.5-72B-Instruct-128K", - "name": "Qwen/Qwen2.5-72B-Instruct-128K", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2024-09-18", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.59, - "output": 0.59 - }, - "limit": { - "context": 131000, - "output": 4000 - } - }, { "id": "siliconflow-cn/Qwen/Qwen2.5-7B-Instruct", "name": "Qwen/Qwen2.5-7B-Instruct", @@ -128099,98 +131082,12 @@ "output": 4000 } }, - { - "id": "siliconflow-cn/Qwen/Qwen2.5-Coder-32B-Instruct", - "name": "Qwen/Qwen2.5-Coder-32B-Instruct", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2024-11-11", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.18, - "output": 0.18 - }, - "limit": { - "context": 33000, - "output": 4000 - } - }, - { - "id": "siliconflow-cn/Qwen/Qwen2.5-VL-32B-Instruct", - "name": "Qwen/Qwen2.5-VL-32B-Instruct", - "family": "qwen", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-03-24", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.27, - "output": 0.27 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, - { - "id": "siliconflow-cn/Qwen/Qwen2.5-VL-72B-Instruct", - "name": "Qwen/Qwen2.5-VL-72B-Instruct", - "family": "qwen", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-01-28", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.59, - "output": 0.59 - }, - "limit": { - "context": 131000, - "output": 4000 - } - }, { "id": "siliconflow-cn/Qwen/Qwen3-14B", "name": "Qwen/Qwen3-14B", "family": "qwen", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2025-04-30", @@ -128213,34 +131110,6 @@ "output": 131000 } }, - { - "id": "siliconflow-cn/Qwen/Qwen3-235B-A22B-Instruct", - "name": "Qwen/Qwen3-235B-A22B-Instruct-2507", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-07-23", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.09, - "output": 0.6 - }, - "limit": { - "context": 262000, - "output": 262000 - } - }, { "id": "siliconflow-cn/Qwen/Qwen3-235B-A22B-Thinking", "name": "Qwen/Qwen3-235B-A22B-Thinking-2507", @@ -128297,40 +131166,12 @@ "output": 262000 } }, - { - "id": "siliconflow-cn/Qwen/Qwen3-30B-A3B-Thinking", - "name": "Qwen/Qwen3-30B-A3B-Thinking-2507", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-07-31", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.09, - "output": 0.3 - }, - "limit": { - "context": 262000, - "output": 131000 - } - }, { "id": "siliconflow-cn/Qwen/Qwen3-32B", "name": "Qwen/Qwen3-32B", "family": "qwen", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2025-04-30", @@ -128358,7 +131199,7 @@ "name": "Qwen/Qwen3-8B", "family": "qwen", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2025-04-30", @@ -128437,150 +131278,6 @@ "output": 262000 } }, - { - "id": "siliconflow-cn/Qwen/Qwen3-Next-80B-A3B-Instruct", - "name": "Qwen/Qwen3-Next-80B-A3B-Instruct", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-09-18", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.14, - "output": 1.4 - }, - "limit": { - "context": 262000, - "output": 262000 - } - }, - { - "id": "siliconflow-cn/Qwen/Qwen3-Next-80B-A3B-Thinking", - "name": "Qwen/Qwen3-Next-80B-A3B-Thinking", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-09-25", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.14, - "output": 0.57 - }, - "limit": { - "context": 262000, - "output": 262000 - } - }, - { - "id": "siliconflow-cn/Qwen/Qwen3-Omni-30B-A3B-Captioner", - "name": "Qwen/Qwen3-Omni-30B-A3B-Captioner", - "family": "qwen", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-10-04", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "audio" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.1, - "output": 0.4 - }, - "limit": { - "context": 66000, - "output": 66000 - } - }, - { - "id": "siliconflow-cn/Qwen/Qwen3-Omni-30B-A3B-Instruct", - "name": "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "family": "qwen", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-10-04", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text", - "image", - "audio" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.1, - "output": 0.4 - }, - "limit": { - "context": 66000, - "output": 66000 - } - }, - { - "id": "siliconflow-cn/Qwen/Qwen3-Omni-30B-A3B-Thinking", - "name": "Qwen/Qwen3-Omni-30B-A3B-Thinking", - "family": "qwen", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-10-04", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text", - "image", - "audio" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.1, - "output": 0.4 - }, - "limit": { - "context": 66000, - "output": 66000 - } - }, { "id": "siliconflow-cn/Qwen/Qwen3-VL-235B-A22B-Instruct", "name": "Qwen/Qwen3-VL-235B-A22B-Instruct", @@ -128784,35 +131481,6 @@ "output": 262000 } }, - { - "id": "siliconflow-cn/Qwen/Qwen3-VL-8B-Thinking", - "name": "Qwen/Qwen3-VL-8B-Thinking", - "family": "qwen", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-10-15", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.18, - "output": 2.0 - }, - "limit": { - "context": 262000, - "output": 262000 - } - }, { "id": "siliconflow-cn/Qwen/Qwen3.5-122B-A10B", "name": "Qwen/Qwen3.5-122B-A10B", @@ -129004,7 +131672,7 @@ "name": "Qwen/Qwen3.6-35B-A3B", "family": "qwen", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2025-04", @@ -129114,62 +131782,6 @@ "output": 164000 } }, - { - "id": "siliconflow-cn/deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", - "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-01-20", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.1, - "output": 0.1 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, - { - "id": "siliconflow-cn/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", - "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-01-20", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.18, - "output": 0.18 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, { "id": "siliconflow-cn/deepseek-ai/DeepSeek-V3", "name": "deepseek-ai/DeepSeek-V3", @@ -129254,6 +131866,36 @@ "output": 164000 } }, + { + "id": "siliconflow-cn/deepseek-ai/DeepSeek-V4-Flash", + "name": "DeepSeek V4 Flash", + "family": "deepseek-flash", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.14, + "output": 0.28, + "cache_read": 0.003 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, { "id": "siliconflow-cn/deepseek-ai/DeepSeek-V4-Pro", "name": "deepseek-ai/DeepSeek-V4-Pro", @@ -129283,35 +131925,6 @@ "output": 393000 } }, - { - "id": "siliconflow-cn/deepseek-ai/deepseek-vl2", - "name": "deepseek-ai/deepseek-vl2", - "family": "deepseek", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2024-12-13", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.15, - "output": 0.15 - }, - "limit": { - "context": 4000, - "output": 4000 - } - }, { "id": "siliconflow-cn/inclusionAI/Ling-flash-2.0", "name": "inclusionAI/Ling-flash-2.0", @@ -129340,62 +131953,6 @@ "output": 131000 } }, - { - "id": "siliconflow-cn/moonshotai/Kimi-K2-Instruct", - "name": "moonshotai/Kimi-K2-Instruct-0905", - "family": "kimi-k2", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-09-08", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.4, - "output": 2.0 - }, - "limit": { - "context": 262000, - "output": 262000 - } - }, - { - "id": "siliconflow-cn/moonshotai/Kimi-K2-Thinking", - "name": "moonshotai/Kimi-K2-Thinking", - "family": "kimi-thinking", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-11-07", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.55, - "output": 2.5 - }, - "limit": { - "context": 262000, - "output": 262000 - } - }, { "id": "siliconflow-cn/stepfun-ai/Step-3.5-Flash", "name": "stepfun-ai/Step-3.5-Flash", @@ -129480,92 +132037,6 @@ "output": 131000 } }, - { - "id": "siliconflow-cn/zai-org/GLM-4.5V", - "name": "zai-org/GLM-4.5V", - "family": "glm", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-08-13", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.14, - "output": 0.86 - }, - "limit": { - "context": 66000, - "output": 66000 - } - }, - { - "id": "siliconflow-cn/zai-org/GLM-4.6", - "name": "zai-org/GLM-4.6", - "family": "glm", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-10-04", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.5, - "output": 1.9 - }, - "limit": { - "context": 205000, - "output": 205000 - } - }, - { - "id": "siliconflow-cn/zai-org/GLM-4.6V", - "name": "zai-org/GLM-4.6V", - "family": "glm", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-07", - "last_updated": "2025-12-07", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.3, - "output": 0.9 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, { "id": "siliconflow-cn/zai-org/GLM-5.2", "name": "GLM-5.2", @@ -129591,8 +132062,8 @@ "cache_write": 0.0 }, "limit": { - "context": 205000, - "output": 205000 + "context": 1049000, + "output": 262000 } }, { @@ -129651,90 +132122,6 @@ "output": 131000 } }, - { - "id": "siliconflow/Qwen/QwQ-32B", - "name": "Qwen/QwQ-32B", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-03-06", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.15, - "output": 0.58 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, - { - "id": "siliconflow/Qwen/Qwen2.5-14B-Instruct", - "name": "Qwen/Qwen2.5-14B-Instruct", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2024-09-18", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.1, - "output": 0.1 - }, - "limit": { - "context": 33000, - "output": 4000 - } - }, - { - "id": "siliconflow/Qwen/Qwen2.5-32B-Instruct", - "name": "Qwen/Qwen2.5-32B-Instruct", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2024-09-19", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.18, - "output": 0.18 - }, - "limit": { - "context": 33000, - "output": 4000 - } - }, { "id": "siliconflow/Qwen/Qwen2.5-72B-Instruct", "name": "Qwen/Qwen2.5-72B-Instruct", @@ -129763,34 +132150,6 @@ "output": 4000 } }, - { - "id": "siliconflow/Qwen/Qwen2.5-72B-Instruct-128K", - "name": "Qwen/Qwen2.5-72B-Instruct-128K", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2024-09-18", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.59, - "output": 0.59 - }, - "limit": { - "context": 131000, - "output": 4000 - } - }, { "id": "siliconflow/Qwen/Qwen2.5-7B-Instruct", "name": "Qwen/Qwen2.5-7B-Instruct", @@ -129819,127 +132178,12 @@ "output": 4000 } }, - { - "id": "siliconflow/Qwen/Qwen2.5-Coder-32B-Instruct", - "name": "Qwen/Qwen2.5-Coder-32B-Instruct", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2024-11-11", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.18, - "output": 0.18 - }, - "limit": { - "context": 33000, - "output": 4000 - } - }, - { - "id": "siliconflow/Qwen/Qwen2.5-VL-32B-Instruct", - "name": "Qwen/Qwen2.5-VL-32B-Instruct", - "family": "qwen", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-03-24", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.27, - "output": 0.27 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, - { - "id": "siliconflow/Qwen/Qwen2.5-VL-72B-Instruct", - "name": "Qwen/Qwen2.5-VL-72B-Instruct", - "family": "qwen", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-01-28", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.59, - "output": 0.59 - }, - "limit": { - "context": 131000, - "output": 4000 - } - }, - { - "id": "siliconflow/Qwen/Qwen2.5-VL-7B-Instruct", - "name": "Qwen/Qwen2.5-VL-7B-Instruct", - "family": "qwen", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-01-28", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.05, - "output": 0.05 - }, - "limit": { - "context": 33000, - "output": 4000 - } - }, { "id": "siliconflow/Qwen/Qwen3-14B", "name": "Qwen/Qwen3-14B", "family": "qwen", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2025-04-30", @@ -129962,62 +132206,6 @@ "output": 131000 } }, - { - "id": "siliconflow/Qwen/Qwen3-235B-A22B", - "name": "Qwen/Qwen3-235B-A22B", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-04-30", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.35, - "output": 1.42 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, - { - "id": "siliconflow/Qwen/Qwen3-235B-A22B-Instruct", - "name": "Qwen/Qwen3-235B-A22B-Instruct-2507", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-07-23", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.09, - "output": 0.6 - }, - "limit": { - "context": 262000, - "output": 262000 - } - }, { "id": "siliconflow/Qwen/Qwen3-235B-A22B-Thinking", "name": "Qwen/Qwen3-235B-A22B-Thinking-2507", @@ -130074,40 +132262,12 @@ "output": 262000 } }, - { - "id": "siliconflow/Qwen/Qwen3-30B-A3B-Thinking", - "name": "Qwen/Qwen3-30B-A3B-Thinking-2507", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-07-31", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.09, - "output": 0.3 - }, - "limit": { - "context": 262000, - "output": 131000 - } - }, { "id": "siliconflow/Qwen/Qwen3-32B", "name": "Qwen/Qwen3-32B", "family": "qwen", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2025-04-30", @@ -130135,7 +132295,7 @@ "name": "Qwen/Qwen3-8B", "family": "qwen", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2025-04-30", @@ -130214,150 +132374,6 @@ "output": 262000 } }, - { - "id": "siliconflow/Qwen/Qwen3-Next-80B-A3B-Instruct", - "name": "Qwen/Qwen3-Next-80B-A3B-Instruct", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-09-18", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.14, - "output": 1.4 - }, - "limit": { - "context": 262000, - "output": 262000 - } - }, - { - "id": "siliconflow/Qwen/Qwen3-Next-80B-A3B-Thinking", - "name": "Qwen/Qwen3-Next-80B-A3B-Thinking", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-09-25", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.14, - "output": 0.57 - }, - "limit": { - "context": 262000, - "output": 262000 - } - }, - { - "id": "siliconflow/Qwen/Qwen3-Omni-30B-A3B-Captioner", - "name": "Qwen/Qwen3-Omni-30B-A3B-Captioner", - "family": "qwen", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-10-04", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "audio" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.1, - "output": 0.4 - }, - "limit": { - "context": 66000, - "output": 66000 - } - }, - { - "id": "siliconflow/Qwen/Qwen3-Omni-30B-A3B-Instruct", - "name": "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "family": "qwen", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-10-04", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text", - "image", - "audio" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.1, - "output": 0.4 - }, - "limit": { - "context": 66000, - "output": 66000 - } - }, - { - "id": "siliconflow/Qwen/Qwen3-Omni-30B-A3B-Thinking", - "name": "Qwen/Qwen3-Omni-30B-A3B-Thinking", - "family": "qwen", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-10-04", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text", - "image", - "audio" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.1, - "output": 0.4 - }, - "limit": { - "context": 66000, - "output": 66000 - } - }, { "id": "siliconflow/Qwen/Qwen3-VL-235B-A22B-Instruct", "name": "Qwen/Qwen3-VL-235B-A22B-Instruct", @@ -130562,19 +132578,18 @@ } }, { - "id": "siliconflow/Qwen/Qwen3-VL-8B-Thinking", - "name": "Qwen/Qwen3-VL-8B-Thinking", + "id": "siliconflow/Qwen/Qwen3.5-122B-A10B", + "name": "Qwen3.5 122B-A10B", "family": "qwen", - "attachment": true, - "reasoning": true, + "attachment": false, + "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-10-15", - "last_updated": "2025-11-25", + "release_date": "2026-02-23", + "last_updated": "2026-02-23", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" @@ -130582,12 +132597,180 @@ }, "open_weights": false, "cost": { - "input": 0.18, + "input": 0.26, + "output": 2.08 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "siliconflow/Qwen/Qwen3.5-27B", + "name": "Qwen3.5 27B", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-23", + "last_updated": "2026-02-23", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.25, "output": 2.0 }, "limit": { - "context": 262000, - "output": 262000 + "context": 262144, + "output": 262144 + } + }, + { + "id": "siliconflow/Qwen/Qwen3.5-35B-A3B", + "name": "Qwen3.5 35B-A3B", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-23", + "last_updated": "2026-02-23", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.24, + "output": 1.8 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "siliconflow/Qwen/Qwen3.5-397B-A17B", + "name": "Qwen3.5 397B-A17B", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-15", + "last_updated": "2026-02-15", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.39, + "output": 2.34 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "siliconflow/Qwen/Qwen3.5-9B", + "name": "Qwen/Qwen3.5-9B", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-03", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.1, + "output": 0.15 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "siliconflow/Qwen/Qwen3.6-27B", + "name": "Qwen3.6 27B", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 3.2 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "siliconflow/Qwen/Qwen3.6-35B-A3B", + "name": "Qwen3.6 35B-A3B", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-17", + "last_updated": "2026-04-17", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.2, + "output": 1.6 + }, + "limit": { + "context": 262144, + "output": 262144 } }, { @@ -130646,62 +132829,6 @@ "output": 164000 } }, - { - "id": "siliconflow/deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", - "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-01-20", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.1, - "output": 0.1 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, - { - "id": "siliconflow/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", - "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-01-20", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.18, - "output": 0.18 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, { "id": "siliconflow/deepseek-ai/DeepSeek-V3", "name": "deepseek-ai/DeepSeek-V3", @@ -130843,7 +132970,7 @@ } }, { - "id": "siliconflow/deepseek-ai/deepseek-v4-flash", + "id": "siliconflow/deepseek-ai/DeepSeek-V4-Flash", "name": "DeepSeek V4 Flash", "family": "deepseek-flash", "attachment": false, @@ -130873,7 +133000,7 @@ } }, { - "id": "siliconflow/deepseek-ai/deepseek-v4-pro", + "id": "siliconflow/deepseek-ai/DeepSeek-V4-Pro", "name": "DeepSeek V4 Pro", "family": "deepseek-thinking", "attachment": false, @@ -130902,35 +133029,6 @@ "output": 384000 } }, - { - "id": "siliconflow/deepseek-ai/deepseek-vl2", - "name": "deepseek-ai/deepseek-vl2", - "family": "deepseek", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2024-12-13", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.15, - "output": 0.15 - }, - "limit": { - "context": 4000, - "output": 4000 - } - }, { "id": "siliconflow/google/gemma-4-26B-A4B-it", "name": "Gemma 4 26B A4B IT", @@ -131015,66 +133113,10 @@ "output": 131000 } }, - { - "id": "siliconflow/moonshotai/Kimi-K2-Instruct", - "name": "moonshotai/Kimi-K2-Instruct", - "family": "kimi-k2", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-07-13", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.58, - "output": 2.29 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, - { - "id": "siliconflow/moonshotai/Kimi-K2-Thinking", - "name": "moonshotai/Kimi-K2-Thinking", - "family": "kimi-thinking", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-11-07", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.55, - "output": 2.5 - }, - "limit": { - "context": 262000, - "output": 262000 - } - }, { "id": "siliconflow/moonshotai/Kimi-K2.5", "name": "moonshotai/Kimi-K2.5", - "family": "kimi-k2", + "family": "kimi", "attachment": false, "reasoning": true, "tool_call": true, @@ -131103,7 +133145,7 @@ { "id": "siliconflow/moonshotai/Kimi-K2.6", "name": "moonshotai/Kimi-K2.6", - "family": "kimi-k2", + "family": "kimi", "attachment": false, "reasoning": true, "tool_call": true, @@ -131271,34 +133313,6 @@ "output": 262144 } }, - { - "id": "siliconflow/zai-org/GLM-4.5", - "name": "zai-org/GLM-4.5", - "family": "glm", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-07-28", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.4, - "output": 2.0 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, { "id": "siliconflow/zai-org/GLM-4.5-Air", "name": "zai-org/GLM-4.5-Air", @@ -131327,120 +133341,6 @@ "output": 131000 } }, - { - "id": "siliconflow/zai-org/GLM-4.5V", - "name": "zai-org/GLM-4.5V", - "family": "glm", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-08-13", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.14, - "output": 0.86 - }, - "limit": { - "context": 66000, - "output": 66000 - } - }, - { - "id": "siliconflow/zai-org/GLM-4.6", - "name": "zai-org/GLM-4.6", - "family": "glm", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-10-04", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.5, - "output": 1.9 - }, - "limit": { - "context": 205000, - "output": 205000 - } - }, - { - "id": "siliconflow/zai-org/GLM-4.6V", - "name": "zai-org/GLM-4.6V", - "family": "glm", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-07", - "last_updated": "2025-12-07", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.3, - "output": 0.9 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, - { - "id": "siliconflow/zai-org/GLM-4.7", - "name": "zai-org/GLM-4.7", - "family": "glm", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-22", - "last_updated": "2025-12-22", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.6, - "output": 2.2 - }, - "limit": { - "context": 205000, - "output": 205000 - } - }, { "id": "siliconflow/zai-org/GLM-5", "name": "zai-org/GLM-5", @@ -131523,8 +133423,8 @@ "cache_write": 0.0 }, "limit": { - "context": 205000, - "output": 205000 + "context": 1049000, + "output": 262000 } }, { @@ -131649,6 +133549,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -132073,12 +133974,12 @@ }, "open_weights": true, "cost": { - "input": 1.64, - "output": 1.91 + "input": 1.76, + "output": 2.05 }, "limit": { "context": 218000, - "output": 8192 + "output": 16384 } }, { @@ -132110,6 +134011,34 @@ "output": 4096 } }, + { + "id": "stackit/Qwen/Qwen3.6-27B", + "name": "Qwen3.6 27B", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.53, + "output": 0.76 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, { "id": "stackit/cortecs/Llama-3.3-70B-Instruct-FP8-Dynamic", "name": "Llama 3.3 70B", @@ -132130,12 +134059,12 @@ }, "open_weights": true, "cost": { - "input": 0.49, - "output": 0.71 + "input": 0.53, + "output": 0.76 }, "limit": { "context": 128000, - "output": 8192 + "output": 4096 } }, { @@ -132159,12 +134088,12 @@ }, "open_weights": true, "cost": { - "input": 0.49, - "output": 0.71 + "input": 0.53, + "output": 0.76 }, "limit": { "context": 37000, - "output": 8192 + "output": 4096 } }, { @@ -132195,66 +134124,10 @@ "output": 4096 } }, - { - "id": "stackit/neuralmagic/Meta-Llama-3.1-8B-Instruct-FP8", - "name": "Llama 3.1 8B", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2024-07-23", - "last_updated": "2024-07-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.16, - "output": 0.27 - }, - "limit": { - "context": 128000, - "output": 8192 - } - }, - { - "id": "stackit/neuralmagic/Mistral-Nemo-Instruct-2407-FP8", - "name": "Mistral Nemo", - "family": "mistral", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2024-07-01", - "last_updated": "2024-07-01", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.49, - "output": 0.71 - }, - "limit": { - "context": 128000, - "output": 8192 - } - }, { "id": "stackit/openai/gpt-oss-120b", - "name": "GPT-OSS 120B", - "family": "gpt", + "name": "GPT OSS 120B", + "family": "gpt-oss", "attachment": false, "reasoning": true, "tool_call": true, @@ -132271,14 +134144,100 @@ }, "open_weights": true, "cost": { - "input": 0.49, - "output": 0.71 + "input": 0.53, + "output": 0.76 }, "limit": { "context": 131000, "output": 8192 } }, + { + "id": "stackit/openai/gpt-oss-20b", + "name": "GPT OSS 20B", + "family": "gpt-oss", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.18, + "output": 0.29 + }, + "limit": { + "context": 131072, + "output": 8192 + } + }, + { + "id": "stepfun-ai/step-1-32k", + "name": "Step 1 (32K)", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-06", + "release_date": "2025-01-01", + "last_updated": "2026-02-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.05, + "output": 9.59, + "cache_read": 0.41 + }, + "limit": { + "context": 32768, + "output": 32768 + } + }, + { + "id": "stepfun-ai/step-2-16k", + "name": "Step 2 (16K)", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-06", + "release_date": "2025-01-01", + "last_updated": "2026-02-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.21, + "output": 16.44, + "cache_read": 1.04 + }, + "limit": { + "context": 16384, + "output": 8192 + } + }, { "id": "stepfun-ai/step-3.5-flash", "name": "Step 3.5 Flash", @@ -132308,6 +134267,111 @@ "output": 256000 } }, + { + "id": "stepfun-ai/step-3.7-flash", + "name": "Step 3.7 Flash", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2026-01-01", + "release_date": "2026-05-29", + "last_updated": "2026-06-29", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.2, + "output": 1.15, + "cache_read": 0.04 + }, + "limit": { + "context": 256000, + "output": 256000 + } + }, + { + "id": "stepfun-ai/step-tts-2", + "name": "Step TTS 2", + "family": "step", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2026-03-01", + "last_updated": "2026-07-02", + "modalities": { + "input": [ + "text" + ], + "output": [ + "audio" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "stepfun-ai/stepaudio-2.5-asr", + "name": "StepAudio 2.5 ASR", + "family": "step", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2026-04-24", + "last_updated": "2026-07-02", + "modalities": { + "input": [ + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "stepfun-ai/stepaudio-2.5-tts", + "name": "StepAudio 2.5 TTS", + "family": "step", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2026-04-16", + "last_updated": "2026-07-02", + "modalities": { + "input": [ + "text" + ], + "output": [ + "audio" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, { "id": "stepfun/step-1-32k", "name": "Step 1 (32K)", @@ -132395,6 +134459,168 @@ "output": 256000 } }, + { + "id": "stepfun/step-3.7-flash", + "name": "Step 3.7 Flash", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2026-01-01", + "release_date": "2026-05-29", + "last_updated": "2026-06-29", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.2, + "output": 1.15, + "cache_read": 0.04 + }, + "limit": { + "context": 256000, + "output": 256000 + } + }, + { + "id": "stepfun/step-tts-2", + "name": "Step TTS 2", + "family": "step", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2026-03-01", + "last_updated": "2026-07-02", + "modalities": { + "input": [ + "text" + ], + "output": [ + "audio" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "stepfun/stepaudio-2.5-asr", + "name": "StepAudio 2.5 ASR", + "family": "step", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2026-04-24", + "last_updated": "2026-07-02", + "modalities": { + "input": [ + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "stepfun/stepaudio-2.5-tts", + "name": "StepAudio 2.5 TTS", + "family": "step", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2026-04-16", + "last_updated": "2026-07-02", + "modalities": { + "input": [ + "text" + ], + "output": [ + "audio" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "subconscious/subconscious/glm-5.2", + "name": "GLM-5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.26 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "subconscious/subconscious/tim-qwen3.6-27b", + "name": "TIM-Qwen3.6 27B", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-05-11", + "last_updated": "2026-05-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 3.0, + "cache_read": 0.15 + }, + "limit": { + "context": 8192, + "output": 5000 + } + }, { "id": "submodel/Qwen/Qwen3-235B-A22B-Instruct", "name": "Qwen3 235B A22B Instruct 2507", @@ -132647,91 +134873,6 @@ "output": 131072 } }, - { - "id": "synthetic/hf:MiniMaxAI/MiniMax-M2", - "name": "MiniMax-M2", - "family": "minimax", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-10-27", - "last_updated": "2025-10-27", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.55, - "output": 2.19 - }, - "limit": { - "context": 196608, - "output": 131000 - } - }, - { - "id": "synthetic/hf:MiniMaxAI/MiniMax-M2.1", - "name": "MiniMax-M2.1", - "family": "minimax", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-23", - "last_updated": "2025-12-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.55, - "output": 2.19 - }, - "limit": { - "context": 204800, - "output": 131072 - } - }, - { - "id": "synthetic/hf:MiniMaxAI/MiniMax-M2.5", - "name": "MiniMax-M2.5", - "family": "minimax", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-02-07", - "last_updated": "2026-02-07", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.6, - "output": 3.0, - "cache_read": 0.6 - }, - "limit": { - "context": 191488, - "output": 65536 - } - }, { "id": "synthetic/hf:MiniMaxAI/MiniMax-M3", "name": "MiniMax-M3", @@ -132763,131 +134904,15 @@ } }, { - "id": "synthetic/hf:Qwen/Qwen2.5-Coder-32B-Instruct", - "name": "Qwen2.5-Coder-32B-Instruct", + "id": "synthetic/hf:Qwen/Qwen3.6-27B", + "name": "Qwen3.6 27B", "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "knowledge": "2024-10", - "release_date": "2024-11-11", - "last_updated": "2024-11-11", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.8, - "output": 0.8 - }, - "limit": { - "context": 32768, - "output": 32768 - } - }, - { - "id": "synthetic/hf:Qwen/Qwen3-235B-A22B-Instruct", - "name": "Qwen 3 235B Instruct", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2025-04", - "release_date": "2025-04-28", - "last_updated": "2025-07-21", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.2, - "output": 0.6 - }, - "limit": { - "context": 256000, - "output": 32000 - } - }, - { - "id": "synthetic/hf:Qwen/Qwen3-235B-A22B-Thinking", - "name": "Qwen3 235B A22B Thinking 2507", - "family": "qwen", - "attachment": false, + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-04", - "release_date": "2025-07-25", - "last_updated": "2025-07-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.65, - "output": 3.0 - }, - "limit": { - "context": 256000, - "output": 32000 - } - }, - { - "id": "synthetic/hf:Qwen/Qwen3-Coder-480B-A35B-Instruct", - "name": "Qwen 3 Coder 480B", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2025-04", - "release_date": "2025-07-23", - "last_updated": "2025-07-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 2.0, - "output": 2.0 - }, - "limit": { - "context": 256000, - "output": 32000 - } - }, - { - "id": "synthetic/hf:Qwen/Qwen3.5-397B-A17B", - "name": "Qwen3.5-97B-A17B", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-02-11", - "last_updated": "2026-02-11", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", "modalities": { "input": [ "text", @@ -132899,9 +134924,9 @@ }, "open_weights": true, "cost": { - "input": 0.6, - "output": 3.0, - "cache_read": 0.6 + "input": 0.45, + "output": 3.6, + "cache_read": 0.45 }, "limit": { "context": 262144, @@ -132909,424 +134934,16 @@ } }, { - "id": "synthetic/hf:deepseek-ai/DeepSeek-R1", - "name": "DeepSeek R1", - "family": "deepseek-thinking", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-01", - "release_date": "2025-01-20", - "last_updated": "2025-01-20", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.55, - "output": 2.19 - }, - "limit": { - "context": 128000, - "output": 128000 - } - }, - { - "id": "synthetic/hf:deepseek-ai/DeepSeek-V3", - "name": "DeepSeek V3", - "family": "deepseek", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-07", - "release_date": "2025-01-20", - "last_updated": "2025-05-29", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 1.25, - "output": 1.25 - }, - "limit": { - "context": 128000, - "output": 128000 - } - }, - { - "id": "synthetic/hf:deepseek-ai/DeepSeek-V3.1", - "name": "DeepSeek V3.1", - "family": "deepseek", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-08-21", - "last_updated": "2025-08-21", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.56, - "output": 1.68 - }, - "limit": { - "context": 128000, - "output": 128000 - } - }, - { - "id": "synthetic/hf:deepseek-ai/DeepSeek-V3.1-Terminus", - "name": "DeepSeek V3.1 Terminus", - "family": "deepseek", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-09-22", - "last_updated": "2025-09-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.2, - "output": 1.2 - }, - "limit": { - "context": 128000, - "output": 128000 - } - }, - { - "id": "synthetic/hf:deepseek-ai/DeepSeek-V3.2", - "name": "DeepSeek V3.2", - "family": "deepseek", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-01", - "last_updated": "2025-12-01", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.27, - "output": 0.4, - "cache_read": 0.27, - "cache_write": 0.0 - }, - "limit": { - "context": 162816, - "output": 8000 - } - }, - { - "id": "synthetic/hf:meta-llama/Llama-3.1-405B-Instruct", - "name": "Llama-3.1-405B-Instruct", - "family": "llama", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2023-12", - "release_date": "2024-07-23", - "last_updated": "2024-07-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 3.0, - "output": 3.0 - }, - "limit": { - "context": 128000, - "output": 32768 - } - }, - { - "id": "synthetic/hf:meta-llama/Llama-3.1-70B-Instruct", - "name": "Llama-3.1-70B-Instruct", - "family": "llama", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2023-12", - "release_date": "2024-07-23", - "last_updated": "2024-07-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.9, - "output": 0.9 - }, - "limit": { - "context": 128000, - "output": 32768 - } - }, - { - "id": "synthetic/hf:meta-llama/Llama-3.1-8B-Instruct", - "name": "Llama-3.1-8B-Instruct", - "family": "llama", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2023-12", - "release_date": "2024-07-23", - "last_updated": "2024-07-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.2, - "output": 0.2 - }, - "limit": { - "context": 128000, - "output": 32768 - } - }, - { - "id": "synthetic/hf:meta-llama/Llama-3.3-70B-Instruct", - "name": "Llama-3.3-70B-Instruct", - "family": "llama", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2023-12", - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.9, - "output": 0.9 - }, - "limit": { - "context": 128000, - "output": 32768 - } - }, - { - "id": "synthetic/hf:meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", - "name": "Llama-4-Maverick-17B-128E-Instruct-FP8", - "family": "llama", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-08", - "release_date": "2025-04-05", - "last_updated": "2025-04-05", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.22, - "output": 0.88 - }, - "limit": { - "context": 524000, - "output": 4096 - } - }, - { - "id": "synthetic/hf:meta-llama/Llama-4-Scout-17B-16E-Instruct", - "name": "Llama-4-Scout-17B-16E-Instruct", - "family": "llama", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-08", - "release_date": "2025-04-05", - "last_updated": "2025-04-05", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.15, - "output": 0.6 - }, - "limit": { - "context": 328000, - "output": 4096 - } - }, - { - "id": "synthetic/hf:moonshotai/Kimi-K2-Instruct", - "name": "Kimi K2 0905", - "family": "kimi-k2", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-10", - "release_date": "2025-09-05", - "last_updated": "2025-09-05", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 1.2, - "output": 1.2 - }, - "limit": { - "context": 262144, - "output": 32768 - } - }, - { - "id": "synthetic/hf:moonshotai/Kimi-K2-Thinking", - "name": "Kimi K2 Thinking", - "family": "kimi-thinking", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-11", - "release_date": "2025-11-07", - "last_updated": "2025-11-07", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.55, - "output": 2.19 - }, - "limit": { - "context": 262144, - "output": 262144 - } - }, - { - "id": "synthetic/hf:moonshotai/Kimi-K2.5", - "name": "Kimi K2.5", - "family": "kimi-k2", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-01", - "release_date": "2026-01", - "last_updated": "2026-01", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.55, - "output": 2.19 - }, - "limit": { - "context": 262144, - "output": 65536 - } - }, - { - "id": "synthetic/hf:moonshotai/Kimi-K2.6", - "name": "Kimi K2.6", + "id": "synthetic/hf:moonshotai/Kimi-K2.7-Code", + "name": "Kimi K2.7 Code", "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, "knowledge": "2025-01", - "release_date": "2026-04-21", - "last_updated": "2026-04-21", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", "modalities": { "input": [ "text", @@ -133347,47 +134964,16 @@ "output": 65536 } }, - { - "id": "synthetic/hf:nvidia/Kimi-K2.5-NVFP4", - "name": "Kimi K2.5 (NVFP4)", - "family": "kimi-k2", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-01", - "release_date": "2026-01", - "last_updated": "2026-01", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.55, - "output": 2.19 - }, - "limit": { - "context": 262144, - "output": 65536 - } - }, { "id": "synthetic/hf:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4", - "name": "Nemotron 3 Super 120B", + "name": "Nemotron 3 Super 120B A12B", "family": "nemotron", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2024-04", "release_date": "2026-03-11", - "last_updated": "2026-04-03", + "last_updated": "2026-03-11", "modalities": { "input": [ "text" @@ -133428,81 +135014,25 @@ "open_weights": true, "cost": { "input": 0.1, - "output": 0.1 + "output": 0.1, + "cache_read": 0.1 }, "limit": { - "context": 128000, + "context": 131072, "output": 32768 } }, - { - "id": "synthetic/hf:zai-org/GLM-4.6", - "name": "GLM 4.6", - "family": "glm", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-04", - "release_date": "2025-09-30", - "last_updated": "2025-09-30", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.55, - "output": 2.19 - }, - "limit": { - "context": 200000, - "output": 64000 - } - }, - { - "id": "synthetic/hf:zai-org/GLM-4.7", - "name": "GLM 4.7", - "family": "glm", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-04", - "release_date": "2025-12-22", - "last_updated": "2025-12-22", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.55, - "output": 2.19 - }, - "limit": { - "context": 200000, - "output": 64000 - } - }, { "id": "synthetic/hf:zai-org/GLM-4.7-Flash", "name": "GLM-4.7-Flash", - "family": "glm", + "family": "glm-flash", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-01-18", - "last_updated": "2026-01-18", + "knowledge": "2025-04", + "release_date": "2026-01-19", + "last_updated": "2026-01-19", "modalities": { "input": [ "text" @@ -133513,9 +135043,9 @@ }, "open_weights": true, "cost": { - "input": 0.06, - "output": 0.4, - "cache_read": 0.06 + "input": 0.1, + "output": 0.5, + "cache_read": 0.1 }, "limit": { "context": 196608, @@ -133523,15 +135053,15 @@ } }, { - "id": "synthetic/hf:zai-org/GLM-5", - "name": "GLM-5", + "id": "synthetic/hf:zai-org/GLM-5.2", + "name": "GLM-5.2", "family": "glm", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-12", - "last_updated": "2026-04-08", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "modalities": { "input": [ "text" @@ -133542,41 +135072,12 @@ }, "open_weights": true, "cost": { - "input": 1.0, - "output": 3.0, - "cache_read": 1.0 + "input": 1.4, + "output": 4.4, + "cache_read": 1.4 }, "limit": { - "context": 196608, - "output": 65536 - } - }, - { - "id": "synthetic/hf:zai-org/GLM-5.1", - "name": "GLM 5.1", - "family": "glm", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-03-27", - "last_updated": "2026-04-12", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 1.0, - "output": 3.0, - "cache_read": 1.0 - }, - "limit": { - "context": 196608, + "context": 524288, "output": 65536 } }, @@ -133823,6 +135324,66 @@ "output": 16384 } }, + { + "id": "tencent-token-plan/hy3", + "name": "Hy3", + "family": "Hy", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-07-06", + "last_updated": "2026-07-06", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 256000, + "output": 64000 + } + }, + { + "id": "tencent-tokenhub/hy3", + "name": "Hy3", + "family": "Hy", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-07-06", + "last_updated": "2026-07-06", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 256000, + "output": 64000 + } + }, { "id": "tencent-tokenhub/hy3-preview", "name": "Hy3 preview", @@ -133861,7 +135422,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-05-04", - "last_updated": "2026-05-19", + "last_updated": "2026-07-06", "modalities": { "input": [ "text", @@ -133886,7 +135447,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-05-04", - "last_updated": "2026-05-19", + "last_updated": "2026-07-06", "modalities": { "input": [ "text" @@ -133898,8 +135459,8 @@ "open_weights": false, "cost": {}, "limit": { - "context": 128000, - "output": 64000 + "context": 196608, + "output": 30000 } }, { @@ -133910,7 +135471,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-05-04", - "last_updated": "2026-05-19", + "last_updated": "2026-07-06", "modalities": { "input": [ "text" @@ -133934,7 +135495,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-05-04", - "last_updated": "2026-05-19", + "last_updated": "2026-07-06", "modalities": { "input": [ "text", @@ -133959,7 +135520,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-05-04", - "last_updated": "2026-05-19", + "last_updated": "2026-07-06", "modalities": { "input": [ "text" @@ -133971,8 +135532,8 @@ "open_weights": false, "cost": {}, "limit": { - "context": 128000, - "output": 64000 + "context": 196608, + "output": 30000 } }, { @@ -133983,7 +135544,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-05-04", - "last_updated": "2026-05-19", + "last_updated": "2026-07-06", "modalities": { "input": [ "text" @@ -134007,7 +135568,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-03-24", - "last_updated": "2026-05-19", + "last_updated": "2026-07-06", "modalities": { "input": [ "text", @@ -134032,7 +135593,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-02-26", - "last_updated": "2026-05-19", + "last_updated": "2026-07-06", "modalities": { "input": [ "text" @@ -134044,7 +135605,7 @@ "open_weights": false, "cost": {}, "limit": { - "context": 128000, + "context": 196608, "output": 30000 } }, @@ -134056,7 +135617,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-02-26", - "last_updated": "2026-05-19", + "last_updated": "2026-07-06", "modalities": { "input": [ "text" @@ -134072,6 +135633,208 @@ "output": 16000 } }, + { + "id": "tinfoil/gemma4-31b", + "name": "Gemma 4 31B IT", + "family": "gemma", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.4, + "output": 1.0 + }, + "limit": { + "context": 256000, + "output": 32768 + } + }, + { + "id": "tinfoil/glm-5.2", + "name": "GLM-5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.5, + "output": 5.25 + }, + "limit": { + "context": 384000, + "output": 131072 + } + }, + { + "id": "tinfoil/gpt-oss-120b", + "name": "gpt-oss-120b", + "family": "gpt-oss", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-06", + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.15, + "output": 0.6 + }, + "limit": { + "context": 131000, + "output": 32768 + } + }, + { + "id": "tinfoil/gpt-oss-safeguard-120b", + "name": "gpt-oss-safeguard-120b", + "family": "gpt-oss", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-06", + "release_date": "2025-10-29", + "last_updated": "2025-10-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.15, + "output": 0.6 + }, + "limit": { + "context": 131000, + "output": 32768 + } + }, + { + "id": "tinfoil/kimi-k2-6", + "name": "Kimi K2.6", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.5, + "output": 5.25 + }, + "limit": { + "context": 256000, + "output": 262144 + } + }, + { + "id": "tinfoil/llama3-3-70b", + "name": "Llama-3.3-70B-Instruct", + "family": "llama", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2023-12", + "release_date": "2024-12-06", + "last_updated": "2024-12-06", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.75, + "output": 2.75 + }, + "limit": { + "context": 128000, + "output": 4096 + } + }, + { + "id": "tinfoil/nomic-embed-text", + "name": "Nomic Embed Text v1.5", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2024-02", + "last_updated": "2024-02", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.05, + "output": 0.0 + }, + "limit": { + "context": 8192, + "output": 768 + } + }, { "id": "togetherai/LiquidAI/LFM2-24B-A2B", "name": "LFM2-24B-A2B", @@ -134398,7 +136161,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-05-21", - "last_updated": "2026-06-15", + "last_updated": "2026-07-02", "modalities": { "input": [ "text" @@ -134658,7 +136421,7 @@ "temperature": true, "knowledge": "2023-12", "release_date": "2024-12-06", - "last_updated": "2024-12-06", + "last_updated": "2026-07-02", "modalities": { "input": [ "text" @@ -134669,8 +136432,8 @@ }, "open_weights": true, "cost": { - "input": 0.88, - "output": 0.88 + "input": 1.04, + "output": 1.04 }, "limit": { "context": 131072, @@ -134948,7 +136711,7 @@ "temperature": true, "knowledge": "2025-11", "release_date": "2026-04-07", - "last_updated": "2026-04-07", + "last_updated": "2026-07-02", "modalities": { "input": [ "text" @@ -134967,6 +136730,217 @@ "output": 131072 } }, + { + "id": "togetherai/zai-org/GLM-5.2", + "name": "GLM-5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-16", + "last_updated": "2026-06-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.26 + }, + "limit": { + "context": 262144, + "output": 164000 + } + }, + { + "id": "trustedrouter/auto", + "name": "Auto", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-05-01", + "last_updated": "2026-06-27", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "trustedrouter/cheap", + "name": "Cheap", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-05-01", + "last_updated": "2026-06-27", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "trustedrouter/e2e", + "name": "End-to-End Encrypted", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-01", + "last_updated": "2026-06-27", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "trustedrouter/fast", + "name": "Fast", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-01", + "last_updated": "2026-06-27", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "trustedrouter/synth", + "name": "Synth", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-20", + "last_updated": "2026-06-27", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "trustedrouter/synth-code", + "name": "Synth Code", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-20", + "last_updated": "2026-06-27", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "trustedrouter/zdr", + "name": "Zero Data Retention", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-01", + "last_updated": "2026-06-27", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "umans-ai-coding-plan/umans-coder", "name": "Umans Coder", @@ -135511,14 +137485,13 @@ } }, { - "id": "venice/arcee-trinity-large-thinking", - "name": "Trinity Large Thinking", - "family": "trinity", + "id": "venice/aion-labs-aion-3.0", + "name": "Aion 3.0", "attachment": false, "reasoning": true, "tool_call": true, - "release_date": "2026-04-02", - "last_updated": "2026-06-11", + "release_date": "2026-07-08", + "last_updated": "2026-07-08", "modalities": { "input": [ "text" @@ -135527,15 +137500,42 @@ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { - "input": 0.3125, - "output": 1.125, - "cache_read": 0.075 + "input": 3.75, + "output": 7.5, + "cache_read": 0.9375 }, "limit": { - "context": 256000, - "output": 65536 + "context": 128000, + "output": 32768 + } + }, + { + "id": "venice/aion-labs-aion-3.0-mini", + "name": "Aion 3.0 Mini", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-07-08", + "last_updated": "2026-07-08", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.875, + "output": 1.75, + "cache_read": 0.225 + }, + "limit": { + "context": 128000, + "output": 32768 } }, { @@ -135578,7 +137578,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", + "knowledge": "2025-05", "release_date": "2025-12-06", "last_updated": "2026-06-11", "modalities": { @@ -135634,38 +137634,6 @@ "output": 128000 } }, - { - "id": "venice/claude-opus-4.6-fast", - "name": "Claude Opus 4.6 Fast", - "family": "claude-opus", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-05-31", - "release_date": "2026-04-08", - "last_updated": "2026-06-11", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 36.0, - "output": 180.0, - "cache_read": 3.6, - "cache_write": 45.0 - }, - "limit": { - "context": 1000000, - "output": 128000 - } - }, { "id": "venice/claude-opus-4.7", "name": "Claude Opus 4.7", @@ -135738,6 +137706,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-06-11", "modalities": { @@ -135769,6 +137738,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-06-11", "modalities": { @@ -135856,6 +137826,38 @@ "output": 64000 } }, + { + "id": "venice/claude-sonnet-5", + "name": "Claude Sonnet 5", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-29", + "last_updated": "2026-07-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 15.0, + "cache_read": 0.3, + "cache_write": 3.75 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, { "id": "venice/deepseek-v3.2", "name": "DeepSeek V3.2", @@ -135905,8 +137907,8 @@ }, "open_weights": true, "cost": { - "input": 0.17, - "output": 0.35, + "input": 0.138, + "output": 0.275, "cache_read": 0.028 }, "limit": { @@ -135935,8 +137937,8 @@ }, "open_weights": true, "cost": { - "input": 1.73, - "output": 3.796, + "input": 1.65, + "output": 3.301, "cache_read": 0.33 }, "limit": { @@ -136124,8 +138126,9 @@ }, "open_weights": true, "cost": { - "input": 0.1625, - "output": 0.5 + "input": 0.13, + "output": 0.4, + "cache_read": 0.05 }, "limit": { "context": 256000, @@ -136251,6 +138254,36 @@ "output": 32000 } }, + { + "id": "venice/grok-4.5", + "name": "Grok 4.5", + "family": "grok", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-07-07", + "last_updated": "2026-07-08", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.27, + "output": 6.8, + "cache_read": 0.57 + }, + "limit": { + "context": 500000, + "output": 32000 + } + }, { "id": "venice/grok-build-0.1", "name": "Grok Build 0.1", @@ -136361,9 +138394,9 @@ }, "open_weights": true, "cost": { - "input": 0.85, - "output": 4.655, - "cache_read": 0.22 + "input": 0.75, + "output": 3.5, + "cache_read": 0.16 }, "limit": { "context": 256000, @@ -136392,9 +138425,9 @@ }, "open_weights": true, "cost": { - "input": 0.9, - "output": 4.3, - "cache_read": 0.2 + "input": 0.75, + "output": 3.5, + "cache_read": 0.16 }, "limit": { "context": 256000, @@ -136503,9 +138536,9 @@ }, "open_weights": true, "cost": { - "input": 0.34, - "output": 1.19, - "cache_read": 0.04 + "input": 0.27, + "output": 0.95, + "cache_read": 0.03 }, "limit": { "context": 198000, @@ -136541,49 +138574,20 @@ "output": 32768 } }, - { - "id": "venice/minimax-m3", - "name": "MiniMax M3", - "family": "minimax-m3", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-06-01", - "last_updated": "2026-06-11", - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.3, - "output": 1.2, - "cache_read": 0.06 - }, - "limit": { - "context": 500000, - "output": 131072 - } - }, { "id": "venice/minimax-m3-preview", "name": "MiniMax M3 Preview", "family": "minimax-m3", - "attachment": false, + "attachment": true, "reasoning": true, "tool_call": true, "release_date": "2026-06-12", "last_updated": "2026-06-13", "modalities": { "input": [ - "text" + "text", + "image", + "video" ], "output": [ "text" @@ -136761,8 +138765,9 @@ }, "open_weights": true, "cost": { - "input": 0.14, - "output": 0.8 + "input": 0.07, + "output": 0.4, + "cache_read": 0.035 }, "limit": { "context": 200000, @@ -137082,6 +139087,7 @@ "attachment": false, "reasoning": true, "tool_call": true, + "temperature": true, "release_date": "2025-11-06", "last_updated": "2026-06-11", "modalities": { @@ -137139,7 +139145,7 @@ "id": "venice/qwen-3.7-max", "name": "Qwen 3.7 Max", "family": "qwen", - "attachment": false, + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, @@ -137147,7 +139153,9 @@ "last_updated": "2026-06-11", "modalities": { "input": [ - "text" + "text", + "image", + "video" ], "output": [ "text" @@ -137446,11 +139454,12 @@ }, "open_weights": true, "cost": { - "input": 0.25, - "output": 1.5 + "input": 0.21, + "output": 1.9, + "cache_read": 0.1 }, "limit": { - "context": 256000, + "context": 128000, "output": 16384 } }, @@ -137534,9 +139543,9 @@ }, "open_weights": true, "cost": { - "input": 0.175, - "output": 0.35, - "cache_read": 0.0625 + "input": 0.14, + "output": 0.28, + "cache_read": 0.05 }, "limit": { "context": 1000000, @@ -137623,9 +139632,9 @@ }, "open_weights": true, "cost": { - "input": 0.85, - "output": 2.75, - "cache_read": 0.3 + "input": 0.43, + "output": 1.75, + "cache_read": 0.08 }, "limit": { "context": 198000, @@ -137740,9 +139749,9 @@ }, "open_weights": true, "cost": { - "input": 1.75, - "output": 5.5, - "cache_read": 0.325 + "input": 1.54, + "output": 4.84, + "cache_read": 0.286 }, "limit": { "context": 200000, @@ -137787,7 +139796,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2025-04-01", + "release_date": "2025-04-28", "last_updated": "2025-04", "modalities": { "input": [ @@ -137816,7 +139825,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2025-04-01", + "release_date": "2025-04-28", "last_updated": "2025-04", "modalities": { "input": [ @@ -137845,7 +139854,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2025-04-01", + "release_date": "2025-04-28", "last_updated": "2025-04", "modalities": { "input": [ @@ -137874,7 +139883,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2025-04-01", + "release_date": "2025-04-28", "last_updated": "2025-04", "modalities": { "input": [ @@ -137906,8 +139915,7 @@ "last_updated": "2026-04-24", "modalities": { "input": [ - "text", - "pdf" + "text" ], "output": [ "text" @@ -137934,7 +139942,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2025-09-24", + "release_date": "2025-09-23", "last_updated": "2025-04", "modalities": { "input": [ @@ -137965,7 +139973,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2025-04-01", + "release_date": "2025-07-22", "last_updated": "2025-04", "modalities": { "input": [ @@ -137995,7 +140003,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2025-04-01", + "release_date": "2025-07-31", "last_updated": "2025-04", "modalities": { "input": [ @@ -138187,7 +140195,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2025-09-23", + "release_date": "2025-09-05", "last_updated": "2025-09-23", "modalities": { "input": [ @@ -138217,7 +140225,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-01", + "release_date": "2026-01-23", "last_updated": "2025-01", "modalities": { "input": [ @@ -138247,7 +140255,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2025-09-12", + "release_date": "2025-09-11", "last_updated": "2025-09", "modalities": { "input": [ @@ -138276,7 +140284,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-09", - "release_date": "2025-09-12", + "release_date": "2025-09-11", "last_updated": "2025-09", "modalities": { "input": [ @@ -138304,13 +140312,12 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2025-09-24", + "release_date": "2025-09-23", "last_updated": "2026-05-01", "modalities": { "input": [ "text", - "image", - "pdf" + "image" ], "output": [ "text" @@ -138335,13 +140342,12 @@ "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2025-09-24", + "release_date": "2025-09-23", "last_updated": "2025-09-24", "modalities": { "input": [ "text", - "image", - "pdf" + "image" ], "output": [ "text" @@ -138366,7 +140372,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-09", - "release_date": "2025-09-24", + "release_date": "2025-09-23", "last_updated": "2025-09-24", "modalities": { "input": [ @@ -138528,8 +140534,7 @@ "last_updated": "2026-05-21", "modalities": { "input": [ - "text", - "pdf" + "text" ], "output": [ "text" @@ -138556,7 +140561,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2026-06-01", + "release_date": "2026-06-02", "last_updated": "2026-06-02", "modalities": { "input": [ @@ -138730,6 +140735,56 @@ "output": 0 } }, + { + "id": "vercel/alibaba/wan-v2.7-r2v", + "name": "Wan v2.7 Reference-to-Video", + "family": "o", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-04-07", + "last_updated": "2026-04-07", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/alibaba/wan-v2.7-t2v", + "name": "Wan v2.7 Text-to-Video", + "family": "o", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-04-07", + "last_updated": "2026-04-07", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, { "id": "vercel/amazon/nova-2-lite", "name": "Nova 2 Lite", @@ -138739,12 +140794,13 @@ "tool_call": false, "temperature": true, "knowledge": "2024-10", - "release_date": "2024-12-01", + "release_date": "2025-12-02", "last_updated": "2024-12-01", "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -138863,7 +140919,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2024-04-01", + "release_date": "2024-04-30", "last_updated": "2024-04", "modalities": { "input": [ @@ -138894,8 +140950,7 @@ "modalities": { "input": [ "text", - "image", - "pdf" + "image" ], "output": [ "text" @@ -138915,14 +140970,14 @@ }, { "id": "vercel/anthropic/claude-3.5-haiku", - "name": "Claude Haiku 3.5", + "name": "Claude 3.5 Haiku", "family": "claude-haiku", "attachment": true, "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2024-07-31", - "release_date": "2024-10-22", + "release_date": "2024-11-04", "last_updated": "2024-10-22", "modalities": { "input": [ @@ -138946,6 +141001,39 @@ "output": 8192 } }, + { + "id": "vercel/anthropic/claude-fable-5", + "name": "Claude Fable 5", + "family": "claude-fable", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2026-01-31", + "release_date": "2026-07-01", + "last_updated": "2026-06-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 10.0, + "output": 50.0, + "cache_read": 1.0, + "cache_write": 12.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "vercel/anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5", @@ -139053,8 +141141,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", - "release_date": "2024-11-24", + "knowledge": "2025-05", + "release_date": "2025-11-24", "last_updated": "2025-11-24", "modalities": { "input": [ @@ -139152,6 +141240,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -139205,7 +141294,7 @@ "cache_write": 3.75 }, "limit": { - "context": 200000, + "context": 1000000, "output": 64000 } }, @@ -139238,7 +141327,7 @@ "cache_write": 3.75 }, "limit": { - "context": 200000, + "context": 1000000, "output": 64000 } }, @@ -139275,6 +141364,39 @@ "output": 128000 } }, + { + "id": "vercel/anthropic/claude-sonnet-5", + "name": "Claude Sonnet 5", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2026-01-31", + "release_date": "2026-06-29", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "vercel/arcee-ai/trinity-large-preview", "name": "Trinity Large Preview", @@ -139284,7 +141406,7 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-01-01", + "release_date": "2026-01-27", "last_updated": "2025-01", "modalities": { "input": [ @@ -139369,7 +141491,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2026-06-08", + "release_date": "2025-11-25", "last_updated": "2026-06-08", "modalities": { "input": [ @@ -139394,7 +141516,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2026-06-08", + "release_date": "2026-01-15", "last_updated": "2026-06-08", "modalities": { "input": [ @@ -139419,7 +141541,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2026-06-08", + "release_date": "2026-01-15", "last_updated": "2026-06-08", "modalities": { "input": [ @@ -139444,7 +141566,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2026-06-08", + "release_date": "2025-12-16", "last_updated": "2026-06-08", "modalities": { "input": [ @@ -139469,7 +141591,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2026-06-08", + "release_date": "2025-11-25", "last_updated": "2026-06-08", "modalities": { "input": [ @@ -139494,7 +141616,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2025-06-01", + "release_date": "2025-05-29", "last_updated": "2025-06", "modalities": { "input": [ @@ -139519,7 +141641,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2025-06-01", + "release_date": "2025-05-29", "last_updated": "2025-06", "modalities": { "input": [ @@ -139569,7 +141691,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2024-10-01", + "release_date": "2024-10-02", "last_updated": "2024-10", "modalities": { "input": [ @@ -139624,7 +141746,8 @@ "last_updated": "2025-09", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -139757,7 +141880,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2025-10-31", + "release_date": "2025-10-24", "last_updated": "2025-10-31", "modalities": { "input": [ @@ -139807,7 +141930,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2025-08-28", + "release_date": "2025-09-09", "last_updated": "2025-08-28", "modalities": { "input": [ @@ -139832,7 +141955,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2025-11-28", + "release_date": "2025-12-03", "last_updated": "2025-11-28", "modalities": { "input": [ @@ -139857,7 +141980,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2026-01-28", + "release_date": "2026-02-13", "last_updated": "2026-01-28", "modalities": { "input": [ @@ -140083,13 +142206,12 @@ }, "open_weights": false, "cost": { - "input": 0.56, - "output": 1.68, - "cache_read": 0.28 + "input": 0.6, + "output": 1.7 }, "limit": { - "context": 163840, - "output": 8192 + "context": 128000, + "output": 128000 } }, { @@ -140127,7 +142249,7 @@ "name": "DeepSeek V3.2", "family": "deepseek", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": false, "temperature": true, "knowledge": "2024-07", @@ -140135,9 +142257,7 @@ "last_updated": "2025-12-01", "modalities": { "input": [ - "text", - "image", - "pdf" + "text" ], "output": [ "text" @@ -140167,9 +142287,7 @@ "last_updated": "2025-12-01", "modalities": { "input": [ - "text", - "image", - "pdf" + "text" ], "output": [ "text" @@ -140198,9 +142316,7 @@ "last_updated": "2026-04-24", "modalities": { "input": [ - "text", - "image", - "pdf" + "text" ], "output": [ "text" @@ -140230,8 +142346,7 @@ "last_updated": "2026-04-24", "modalities": { "input": [ - "text", - "pdf" + "text" ], "output": [ "text" @@ -140291,7 +142406,7 @@ "tool_call": false, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", + "release_date": "2025-08-26", "last_updated": "2025-08-26", "modalities": { "input": [ @@ -140569,6 +142684,37 @@ "output": 65000 } }, + { + "id": "vercel/google/gemini-3.1-flash-lite-image", + "name": "Gemini 3.1 Flash Lite Image (Nano Banana 2 Lite)", + "family": "gemini", + "attachment": true, + "reasoning": true, + "tool_call": false, + "temperature": true, + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text", + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.25, + "output": 1.5, + "cache_read": 0.03 + }, + "limit": { + "context": 65536, + "output": 4096 + } + }, { "id": "vercel/google/gemini-3.1-flash-lite-preview", "name": "Gemini 3.1 Flash Lite Preview", @@ -140716,6 +142862,36 @@ "output": 0 } }, + { + "id": "vercel/google/gemini-omni-flash-preview", + "name": "Gemini Omni Flash Preview", + "family": "gemini", + "attachment": true, + "reasoning": true, + "tool_call": false, + "temperature": true, + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.5, + "output": 9.0 + }, + "limit": { + "context": 1000000, + "output": 57920 + } + }, { "id": "vercel/google/gemma-4-26b-a4b-it", "name": "Gemma 4 26B A4B IT", @@ -140809,8 +142985,8 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, - "release_date": "2025-05-22", + "temperature": true, + "release_date": "2025-05-20", "last_updated": "2025-05-22", "modalities": { "input": [ @@ -140910,7 +143086,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2026-06-08", + "release_date": "2025-07-31", "last_updated": "2026-06-08", "modalities": { "input": [ @@ -140935,7 +143111,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2026-06-08", + "release_date": "2025-05-20", "last_updated": "2026-06-08", "modalities": { "input": [ @@ -140960,7 +143136,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2026-06-08", + "release_date": "2025-10-15", "last_updated": "2026-06-08", "modalities": { "input": [ @@ -140985,7 +143161,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2026-06-08", + "release_date": "2025-10-15", "last_updated": "2026-06-08", "modalities": { "input": [ @@ -141070,7 +143246,9 @@ "last_updated": "2026-04-29", "modalities": { "input": [ - "text" + "text", + "image", + "pdf" ], "output": [ "text" @@ -141144,7 +143322,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2025-12-21", + "release_date": "2025-12-03", "last_updated": "2025-12-21", "modalities": { "input": [ @@ -141169,7 +143347,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2025-12-21", + "release_date": "2025-12-18", "last_updated": "2025-12-21", "modalities": { "input": [ @@ -141194,7 +143372,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2025-12-21", + "release_date": "2025-12-03", "last_updated": "2025-12-21", "modalities": { "input": [ @@ -141295,7 +143473,7 @@ "tool_call": false, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-10-24", + "release_date": "2025-11-09", "last_updated": "2025-10-24", "modalities": { "input": [ @@ -141354,7 +143532,7 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-08-30", + "release_date": "2025-09-01", "last_updated": "2025-08-30", "modalities": { "input": [ @@ -141379,7 +143557,7 @@ "reasoning": true, "tool_call": false, "temperature": true, - "release_date": "2026-03-13", + "release_date": "2026-01-15", "last_updated": "2026-03-13", "modalities": { "input": [ @@ -141493,7 +143671,7 @@ "tool_call": false, "temperature": true, "knowledge": "2023-12", - "release_date": "2024-09-18", + "release_date": "2024-09-25", "last_updated": "2024-09-18", "modalities": { "input": [ @@ -141522,7 +143700,7 @@ "tool_call": false, "temperature": true, "knowledge": "2023-12", - "release_date": "2024-09-18", + "release_date": "2024-09-25", "last_updated": "2024-09-18", "modalities": { "input": [ @@ -141701,7 +143879,7 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-10-27", + "release_date": "2025-12-23", "last_updated": "2025-12-23", "modalities": { "input": [ @@ -141732,7 +143910,7 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-10-27", + "release_date": "2025-12-23", "last_updated": "2025-10-27", "modalities": { "input": [ @@ -141997,7 +144175,7 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-05-07", + "release_date": "2025-05-21", "last_updated": "2025-05-07", "modalities": { "input": [ @@ -142026,11 +144204,12 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-05-07", + "release_date": "2025-12-09", "last_updated": "2025-05-07", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -142113,7 +144292,7 @@ "tool_call": false, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-12-01", + "release_date": "2025-12-02", "last_updated": "2025-12-01", "modalities": { "input": [ @@ -142286,11 +144465,12 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-05-21", + "release_date": "2026-04-29", "last_updated": "2026-05-21", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -142315,7 +144495,7 @@ "tool_call": true, "temperature": true, "knowledge": "2024-04", - "release_date": "2024-07-01", + "release_date": "2024-07-18", "last_updated": "2024-07-01", "modalities": { "input": [ @@ -142344,7 +144524,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-06", - "release_date": "2024-09-01", + "release_date": "2024-09-17", "last_updated": "2026-03-16", "modalities": { "input": [ @@ -142433,7 +144613,7 @@ "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-09-05", + "release_date": "2025-07-11", "last_updated": "2025-09-05", "modalities": { "input": [ @@ -142474,13 +144654,13 @@ }, "open_weights": false, "cost": { - "input": 0.6, - "output": 2.5, - "cache_read": 0.15 + "input": 0.47, + "output": 2.0, + "cache_read": 0.141 }, "limit": { - "context": 262114, - "output": 262114 + "context": 216144, + "output": 216144 } }, { @@ -142528,8 +144708,7 @@ "modalities": { "input": [ "text", - "image", - "pdf" + "image" ], "output": [ "text" @@ -142675,7 +144854,7 @@ "tool_call": false, "temperature": true, "knowledge": "2024-10", - "release_date": "2024-12-01", + "release_date": "2025-12-15", "last_updated": "2025-12-15", "modalities": { "input": [ @@ -142703,7 +144882,7 @@ "reasoning": true, "tool_call": false, "temperature": true, - "release_date": "2026-03-18", + "release_date": "2026-03-11", "last_updated": "2026-03-11", "modalities": { "input": [ @@ -142761,7 +144940,7 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2024-12-01", + "release_date": "2025-10-28", "last_updated": "2025-10-28", "modalities": { "input": [ @@ -142820,7 +144999,7 @@ "tool_call": false, "temperature": true, "knowledge": "2021-09", - "release_date": "2023-05-28", + "release_date": "2023-03-01", "last_updated": "2023-11-06", "modalities": { "input": [ @@ -142849,7 +145028,7 @@ "tool_call": false, "temperature": true, "knowledge": "2021-09", - "release_date": "2023-09-28", + "release_date": "2023-09-18", "last_updated": "2023-03-01", "modalities": { "input": [ @@ -143087,6 +145266,62 @@ "output": 16384 } }, + { + "id": "vercel/openai/gpt-4o-mini-transcribe", + "name": "GPT-4o mini Transcribe", + "family": "o-mini", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2024-03-13", + "last_updated": "2024-03-13", + "modalities": { + "input": [ + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 5.0 + }, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/openai/gpt-4o-transcribe", + "name": "GPT-4o Transcribe", + "family": "gpt", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2024-03-13", + "last_updated": "2024-03-13", + "modalities": { + "input": [ + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.5, + "output": 10.0 + }, + "limit": { + "context": 0, + "output": 0 + } + }, { "id": "vercel/openai/gpt-5", "name": "GPT-5", @@ -143253,7 +145488,7 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-08-07", + "release_date": "2025-10-06", "last_updated": "2025-10-06", "modalities": { "input": [ @@ -143262,8 +145497,7 @@ "pdf" ], "output": [ - "text", - "image" + "text" ] }, "open_weights": false, @@ -143377,7 +145611,7 @@ "name": "GPT-5.1 Instant", "family": "gpt", "attachment": true, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2024-10", @@ -143474,7 +145708,7 @@ "name": "GPT-5.2 Chat", "family": "gpt", "attachment": true, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2024-10", @@ -143569,7 +145803,7 @@ "name": "GPT-5.3 Chat", "family": "gpt", "attachment": true, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "release_date": "2026-03-03", @@ -143604,7 +145838,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-08-31", - "release_date": "2026-02-24", + "release_date": "2026-02-05", "last_updated": "2026-02-05", "modalities": { "input": [ @@ -143954,13 +146188,12 @@ }, "open_weights": true, "cost": { - "input": 0.35, - "output": 0.75, - "cache_read": 0.25 + "input": 0.1, + "output": 0.5 }, "limit": { "context": 131072, - "output": 131000 + "output": 131072 } }, { @@ -144001,7 +146234,7 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2024-12-01", + "release_date": "2025-10-29", "last_updated": "2024-12-01", "modalities": { "input": [ @@ -144022,6 +146255,99 @@ "output": 65536 } }, + { + "id": "vercel/openai/gpt-realtime-1.5", + "name": "GPT-Realtime-1.5", + "family": "gpt", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-02-23", + "last_updated": "2026-02-23", + "modalities": { + "input": [ + "text", + "audio" + ], + "output": [ + "text", + "audio" + ] + }, + "open_weights": false, + "cost": { + "input": 4.0, + "output": 16.0, + "cache_read": 0.4 + }, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/openai/gpt-realtime-2", + "name": "gpt-realtime-2", + "family": "gpt", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-05-07", + "last_updated": "2026-05-07", + "modalities": { + "input": [ + "text", + "audio" + ], + "output": [ + "text", + "audio" + ] + }, + "open_weights": false, + "cost": { + "input": 4.0, + "output": 24.0, + "cache_read": 0.4 + }, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/openai/gpt-realtime-mini", + "name": "GPT-Realtime mini", + "family": "gpt", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-10-10", + "last_updated": "2025-10-10", + "modalities": { + "input": [ + "text", + "audio" + ], + "output": [ + "text", + "audio" + ] + }, + "open_weights": false, + "cost": { + "input": 0.6, + "output": 2.4, + "cache_read": 0.06 + }, + "limit": { + "context": 0, + "output": 0 + } + }, { "id": "vercel/openai/o1", "name": "o1", @@ -144095,7 +146421,7 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2024-06-26", + "release_date": "2025-06-26", "last_updated": "2024-06-26", "modalities": { "input": [ @@ -144157,7 +146483,7 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-04-16", + "release_date": "2025-06-10", "last_updated": "2025-06-10", "modalities": { "input": [ @@ -144285,6 +146611,81 @@ "output": 1536 } }, + { + "id": "vercel/openai/tts-1", + "name": "TTS-1", + "family": "o", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2023-11-06", + "last_updated": "2023-11-06", + "modalities": { + "input": [ + "text" + ], + "output": [ + "audio" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/openai/tts-1-hd", + "name": "TTS-1 HD", + "family": "o", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2023-11-06", + "last_updated": "2023-11-06", + "modalities": { + "input": [ + "text" + ], + "output": [ + "audio" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/openai/whisper-1", + "name": "Whisper", + "family": "whisper", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2022-09-21", + "last_updated": "2022-09-21", + "modalities": { + "input": [ + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, { "id": "vercel/perplexity/sonar", "name": "Sonar", @@ -144373,7 +146774,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2026-06-08", + "release_date": "2024-08-02", "last_updated": "2026-06-08", "modalities": { "input": [ @@ -144390,6 +146791,31 @@ "output": 0 } }, + { + "id": "vercel/quiverai/arrow-1.1", + "name": "Arrow 1.1", + "family": "o", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 131072, + "output": 131072 + } + }, { "id": "vercel/recraft/recraft-v2", "name": "Recraft V2", @@ -144398,7 +146824,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2024-03-01", + "release_date": "2024-03-13", "last_updated": "2024-03", "modalities": { "input": [ @@ -144423,7 +146849,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2024-10-01", + "release_date": "2024-10-30", "last_updated": "2024-10", "modalities": { "input": [ @@ -144739,7 +147165,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2024-09-01", + "release_date": "2025-01-07", "last_updated": "2024-09", "modalities": { "input": [ @@ -144814,7 +147240,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2026-03-06", + "release_date": "2026-01-15", "last_updated": "2026-03-06", "modalities": { "input": [ @@ -144839,7 +147265,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2026-03-06", + "release_date": "2026-01-15", "last_updated": "2026-03-06", "modalities": { "input": [ @@ -144864,7 +147290,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2026-03-06", + "release_date": "2026-01-15", "last_updated": "2026-03-06", "modalities": { "input": [ @@ -144914,7 +147340,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2024-09-01", + "release_date": "2024-12-04", "last_updated": "2024-09", "modalities": { "input": [ @@ -144939,7 +147365,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2024-03-01", + "release_date": "2024-06-03", "last_updated": "2024-03", "modalities": { "input": [ @@ -144964,7 +147390,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2024-03-01", + "release_date": "2024-04-15", "last_updated": "2024-03", "modalities": { "input": [ @@ -144990,7 +147416,7 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-07-09", + "release_date": "2025-11-19", "last_updated": "2025-07-09", "modalities": { "input": [ @@ -145022,7 +147448,7 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-07-09", + "release_date": "2025-11-19", "last_updated": "2025-07-09", "modalities": { "input": [ @@ -145053,7 +147479,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-03-09", + "release_date": "2026-03-10", "last_updated": "2026-03-23", "modalities": { "input": [ @@ -145115,7 +147541,7 @@ "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2026-03-09", + "release_date": "2026-03-10", "last_updated": "2026-03-23", "modalities": { "input": [ @@ -145177,7 +147603,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-03-09", + "release_date": "2026-03-10", "last_updated": "2026-03-23", "modalities": { "input": [ @@ -145262,6 +147688,37 @@ "output": 1000000 } }, + { + "id": "vercel/xai/grok-4.5", + "name": "Grok 4.5", + "family": "grok", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-07-08", + "last_updated": "2026-07-08", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 6.0, + "cache_read": 0.5 + }, + "limit": { + "context": 500000, + "output": 500000 + } + }, { "id": "vercel/xai/grok-build-0.1", "name": "Grok Build 0.1", @@ -145343,6 +147800,31 @@ "output": 0 } }, + { + "id": "vercel/xai/grok-imagine-video-1.5", + "name": "Grok Imagine Video 1.5", + "family": "grok", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-06-22", + "last_updated": "2026-06-22", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, { "id": "vercel/xai/grok-imagine-video-1.5-preview", "name": "Grok Imagine Video 1.5 Preview", @@ -145368,6 +147850,83 @@ "output": 0 } }, + { + "id": "vercel/xai/grok-stt", + "name": "Grok STT", + "family": "grok", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-03-16", + "last_updated": "2026-03-16", + "modalities": { + "input": [ + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/xai/grok-tts", + "name": "Grok TTS", + "family": "grok", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-03-16", + "last_updated": "2026-03-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "audio" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/xai/grok-voice-think-fast-1.0", + "name": "Grok Voice Think Fast 1.0", + "family": "grok", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-04-23", + "last_updated": "2026-04-23", + "modalities": { + "input": [ + "text", + "audio" + ], + "output": [ + "text", + "audio" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, { "id": "vercel/xiaomi/mimo-v2-flash", "name": "MiMo V2 Flash", @@ -145377,7 +147936,7 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-12-17", + "release_date": "2025-12-16", "last_updated": "2026-02-04", "modalities": { "input": [ @@ -145474,7 +148033,6 @@ "modalities": { "input": [ "text", - "image", "pdf" ], "output": [ @@ -145694,13 +148252,13 @@ }, "open_weights": false, "cost": { - "input": 2.25, - "output": 2.75, - "cache_read": 2.25 + "input": 0.6, + "output": 2.2, + "cache_read": 0.12 }, "limit": { - "context": 131000, - "output": 40000 + "context": 200000, + "output": 120000 } }, { @@ -145712,7 +148270,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2026-03-13", + "release_date": "2026-01-19", "last_updated": "2026-01-19", "modalities": { "input": [ @@ -145741,7 +148299,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-01-01", + "release_date": "2026-01-19", "last_updated": "2026-01-19", "modalities": { "input": [ @@ -145782,8 +148340,8 @@ }, "open_weights": true, "cost": { - "input": 1.0, - "output": 3.2, + "input": 0.95, + "output": 3.15, "cache_read": 0.2 }, "limit": { @@ -145832,9 +148390,7 @@ "last_updated": "2026-04-07", "modalities": { "input": [ - "text", - "image", - "pdf" + "text" ], "output": [ "text" @@ -145842,13 +148398,13 @@ }, "open_weights": false, "cost": { - "input": 1.4, - "output": 4.4, + "input": 1.3, + "output": 4.3, "cache_read": 0.26 }, "limit": { - "context": 202800, - "output": 64000 + "context": 202000, + "output": 202000 } }, { @@ -145871,9 +148427,38 @@ }, "open_weights": true, "cost": { - "input": 1.5, - "output": 4.5, - "cache_read": 0.3 + "input": 1.4, + "output": 4.4, + "cache_read": 0.26 + }, + "limit": { + "context": 1040000, + "output": 128000 + } + }, + { + "id": "vercel/zai/glm-5.2-fast", + "name": "GLM 5.2 Fast", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-16", + "last_updated": "2026-06-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 10.25, + "cache_read": 0.5 }, "limit": { "context": 1000000, @@ -146039,6 +148624,35 @@ "output": 65536 } }, + { + "id": "vivgrid/glm-5.2", + "name": "GLM-5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.2, + "output": 4.2, + "cache_read": 0.3 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "vivgrid/gpt-5-mini", "name": "GPT-5 Mini", @@ -146350,6 +148964,126 @@ "output": 131072 } }, + { + "id": "vultr/Qwen/Qwen3.5-397B-A17B", + "name": "Qwen3.5 397B-A17B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-15", + "last_updated": "2026-02-15", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 2.0 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, + { + "id": "vultr/Qwen/Qwen3.6-27B", + "name": "Qwen3.6 27B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 2.0 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, + { + "id": "vultr/XiaomiMiMo/MiMo-V2.5-Pro", + "name": "MiMo-V2.5-Pro", + "family": "mimo", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-12", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.55, + "output": 1.65 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, + { + "id": "vultr/deepseek-ai/DeepSeek-V4-Flash", + "name": "DeepSeek V4 Flash", + "family": "deepseek-flash", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 1.0 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, { "id": "vultr/moonshotai/Kimi-K2.6", "name": "Kimi K2.6", @@ -146371,8 +149105,8 @@ }, "open_weights": true, "cost": { - "input": 0.15, - "output": 0.6 + "input": 0.3, + "output": 1.2 }, "limit": { "context": 262144, @@ -146408,35 +149142,6 @@ "output": 131072 } }, - { - "id": "vultr/nvidia/Llama-3.1-Nemotron-Safety-Guard-8B-v3", - "name": "Llama 3.1 Nemotron Safety Guard", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "knowledge": "2023-12", - "release_date": "2025-10-28", - "last_updated": "2025-10-28", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.01, - "output": 0.01 - }, - "limit": { - "context": 8192, - "output": 4096 - } - }, { "id": "vultr/nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-BF16", "name": "NVIDIA Nemotron 3 Nano Omni", @@ -146496,15 +149201,15 @@ } }, { - "id": "vultr/zai-org/GLM-5.1-FP8", - "name": "GLM-5.1", + "id": "vultr/zai-org/GLM-5.2-FP8", + "name": "GLM-5.2", "family": "glm", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-04-07", - "last_updated": "2026-04-07", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "modalities": { "input": [ "text" @@ -146519,7 +149224,7 @@ "output": 3.1 }, "limit": { - "context": 200000, + "context": 393216, "output": 131072 } }, @@ -146554,6 +149259,36 @@ "output": 131072 } }, + { + "id": "wafer.ai/GLM-5.2", + "name": "GLM-5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-22", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.2, + "output": 4.1, + "cache_read": 0.2, + "cache_write": 0.0 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, { "id": "wafer.ai/Kimi-K2.6", "name": "Kimi-K2.6", @@ -146746,43 +149481,14 @@ } }, { - "id": "wandb/MiniMaxAI/MiniMax-M2.5", - "name": "MiniMax M2.5", - "family": "minimax", + "id": "wandb/JetBrains/Mellum2-12B-A2.5B-Instruct", + "name": "Mellum2 12B A2.5B", "attachment": false, "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2026-02-12", - "last_updated": "2026-03-12", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.3, - "output": 1.2 - }, - "limit": { - "context": 196608, - "output": 196608 - } - }, - { - "id": "wandb/OpenPipe/Qwen3-14B-Instruct", - "name": "OpenPipe Qwen3 14B Instruct", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-04-29", - "last_updated": "2026-03-12", + "release_date": "2026-06-01", + "last_updated": "2026-06-01", "modalities": { "input": [ "text" @@ -146794,7 +149500,66 @@ "open_weights": true, "cost": { "input": 0.05, - "output": 0.22 + "output": 0.1, + "cache_read": 0.05 + }, + "limit": { + "context": 131072, + "output": 131072 + } + }, + { + "id": "wandb/MiniMaxAI/MiniMax-M2.5", + "name": "MiniMax M2.5", + "family": "minimax-m2.5", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-12", + "last_updated": "2026-02-12", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.3 + }, + "limit": { + "context": 196608, + "output": 196608 + } + }, + { + "id": "wandb/OpenPipe/Qwen3-14B-Instruct", + "name": "Qwen3 14B Instruct", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2025-04-29", + "last_updated": "2025-04-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.05, + "output": 0.22, + "cache_read": 0.05 }, "limit": { "context": 32768, @@ -146803,15 +149568,15 @@ }, { "id": "wandb/Qwen/Qwen3-235B-A22B-Instruct", - "name": "Qwen3 235B A22B Instruct 2507", + "name": "Qwen3 235B A22B-2507", "family": "qwen", "attachment": false, "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2025-04-28", - "last_updated": "2026-03-12", + "release_date": "2025-07-22", + "last_updated": "2025-07-22", "modalities": { "input": [ "text" @@ -146823,7 +149588,8 @@ "open_weights": true, "cost": { "input": 0.1, - "output": 0.1 + "output": 0.1, + "cache_read": 0.1 }, "limit": { "context": 262144, @@ -146832,7 +149598,7 @@ }, { "id": "wandb/Qwen/Qwen3-235B-A22B-Thinking", - "name": "Qwen3-235B-A22B-Thinking-2507", + "name": "Qwen3 235B A22B Thinking-2507", "family": "qwen", "attachment": false, "reasoning": true, @@ -146840,7 +149606,7 @@ "temperature": true, "knowledge": "2025-04", "release_date": "2025-07-25", - "last_updated": "2026-03-12", + "last_updated": "2025-07-25", "modalities": { "input": [ "text" @@ -146852,7 +149618,8 @@ "open_weights": true, "cost": { "input": 0.1, - "output": 0.1 + "output": 0.1, + "cache_read": 0.1 }, "limit": { "context": 262144, @@ -146868,7 +149635,7 @@ "tool_call": true, "temperature": true, "release_date": "2025-07-29", - "last_updated": "2026-03-12", + "last_updated": "2025-07-29", "modalities": { "input": [ "text" @@ -146880,7 +149647,8 @@ "open_weights": true, "cost": { "input": 0.1, - "output": 0.3 + "output": 0.3, + "cache_read": 0.1 }, "limit": { "context": 262144, @@ -146889,15 +149657,15 @@ }, { "id": "wandb/Qwen/Qwen3-Coder-480B-A35B-Instruct", - "name": "Qwen3-Coder-480B-A35B-Instruct", + "name": "Qwen3 Coder 480B A35B", "family": "qwen", "attachment": false, "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2025-07-23", - "last_updated": "2026-03-12", + "release_date": "2025-07-22", + "last_updated": "2025-07-22", "modalities": { "input": [ "text" @@ -146909,7 +149677,128 @@ "open_weights": true, "cost": { "input": 1.0, - "output": 1.5 + "output": 1.5, + "cache_read": 1.0 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "wandb/Qwen/Qwen3.5-27B", + "name": "Qwen3.5-27B", + "family": "qwen3.5", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-24", + "last_updated": "2026-02-24", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.39, + "output": 3.12, + "cache_read": 0.08 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "wandb/Qwen/Qwen3.5-35B-A3B", + "name": "Qwen3.5-35B-A3B", + "family": "qwen3.5", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-24", + "last_updated": "2026-02-24", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.25, + "output": 1.25, + "cache_read": 0.25 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "wandb/Qwen/Qwen3.6-27B", + "name": "Qwen3.6 27B", + "family": "qwen3.6", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.6, + "output": 3.6, + "cache_read": 0.12 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "wandb/Qwen/Qwen3.6-35B-A3B", + "name": "Qwen3.6 35B A3B", + "family": "qwen3.6", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-15", + "last_updated": "2026-04-15", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.25, + "output": 1.25, + "cache_read": 0.25 }, "limit": { "context": 262144, @@ -146925,7 +149814,7 @@ "tool_call": true, "temperature": true, "release_date": "2025-08-21", - "last_updated": "2026-03-12", + "last_updated": "2025-08-21", "modalities": { "input": [ "text" @@ -146937,7 +149826,8 @@ "open_weights": true, "cost": { "input": 0.55, - "output": 1.65 + "output": 1.65, + "cache_read": 0.55 }, "limit": { "context": 161000, @@ -146945,15 +149835,16 @@ } }, { - "id": "wandb/meta-llama/Llama-3.1-70B-Instruct", - "name": "Llama 3.1 70B", - "family": "llama", + "id": "wandb/deepseek-ai/DeepSeek-V4-Flash", + "name": "DeepSeek V4 Flash", + "family": "deepseek", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2024-07-23", - "last_updated": "2026-03-12", + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "modalities": { "input": [ "text" @@ -146964,25 +149855,26 @@ }, "open_weights": true, "cost": { - "input": 0.8, - "output": 0.8 + "input": 0.14, + "output": 0.28, + "cache_read": 0.07 }, "limit": { - "context": 128000, - "output": 128000 + "context": 1048576, + "output": 1048576 } }, { - "id": "wandb/meta-llama/Llama-3.1-8B-Instruct", - "name": "Meta-Llama-3.1-8B-Instruct", - "family": "llama", + "id": "wandb/deepseek-ai/DeepSeek-V4-Pro", + "name": "DeepSeek V4 Pro", + "family": "deepseek", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2023-12", - "release_date": "2024-07-23", - "last_updated": "2026-03-12", + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "modalities": { "input": [ "text" @@ -146993,54 +149885,25 @@ }, "open_weights": true, "cost": { - "input": 0.22, - "output": 0.22 + "input": 1.74, + "output": 3.48, + "cache_read": 0.14 }, "limit": { - "context": 128000, - "output": 128000 + "context": 1048576, + "output": 1048576 } }, { - "id": "wandb/meta-llama/Llama-3.3-70B-Instruct", - "name": "Llama-3.3-70B-Instruct", - "family": "llama", - "attachment": false, + "id": "wandb/google/gemma-4-31B-it", + "name": "Gemma 4 31B", + "family": "gemma", + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2023-12", - "release_date": "2024-12-06", - "last_updated": "2026-03-12", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.71, - "output": 0.71 - }, - "limit": { - "context": 128000, - "output": 128000 - } - }, - { - "id": "wandb/meta-llama/Llama-4-Scout-17B-16E-Instruct", - "name": "Llama 4 Scout 17B 16E Instruct", - "family": "llama", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-12", - "release_date": "2025-01-31", - "last_updated": "2026-03-12", + "release_date": "2026-04-02", + "last_updated": "2026-04-02", "modalities": { "input": [ "text", @@ -147052,25 +149915,144 @@ }, "open_weights": true, "cost": { - "input": 0.17, - "output": 0.66 + "input": 0.12, + "output": 0.35, + "cache_read": 0.09 }, "limit": { - "context": 64000, - "output": 64000 + "context": 262144, + "output": 262144 + } + }, + { + "id": "wandb/ibm-granite/granite-4.1-8b", + "name": "Granite 4.1 8B", + "family": "granite", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-29", + "last_updated": "2026-04-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.05, + "output": 0.1, + "cache_read": 0.05 + }, + "limit": { + "context": 131072, + "output": 131072 + } + }, + { + "id": "wandb/meta-llama/Llama-3.1-70B-Instruct", + "name": "Llama 3.1 70B", + "family": "llama", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2024-07-23", + "last_updated": "2024-07-23", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.8, + "output": 0.8, + "cache_read": 0.8 + }, + "limit": { + "context": 128000, + "output": 128000 + } + }, + { + "id": "wandb/meta-llama/Llama-3.1-8B-Instruct", + "name": "Llama 3.1 8B", + "family": "llama", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2023-12", + "release_date": "2024-07-23", + "last_updated": "2024-07-23", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.22, + "output": 0.22, + "cache_read": 0.22 + }, + "limit": { + "context": 128000, + "output": 128000 + } + }, + { + "id": "wandb/meta-llama/Llama-3.3-70B-Instruct", + "name": "Llama 3.3 70B", + "family": "llama", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2023-12", + "release_date": "2024-12-01", + "last_updated": "2024-12-01", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.71, + "output": 0.71, + "cache_read": 0.71 + }, + "limit": { + "context": 128000, + "output": 128000 } }, { "id": "wandb/microsoft/Phi-4-mini-instruct", - "name": "Phi-4-mini-instruct", + "name": "Phi 4 Mini 3.8B", "family": "phi", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2023-10", - "release_date": "2024-12-11", - "last_updated": "2026-03-12", + "release_date": "2025-02-01", + "last_updated": "2025-02-01", "modalities": { "input": [ "text" @@ -147082,7 +150064,8 @@ "open_weights": true, "cost": { "input": 0.08, - "output": 0.35 + "output": 0.35, + "cache_read": 0.08 }, "limit": { "context": 128000, @@ -147097,8 +150080,9 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-01-27", - "last_updated": "2026-03-12", + "knowledge": "2025-01", + "release_date": "2026-02-02", + "last_updated": "2026-02-02", "modalities": { "input": [ "text", @@ -147110,8 +150094,71 @@ }, "open_weights": true, "cost": { - "input": 0.5, - "output": 2.85 + "input": 0.6, + "output": 3.0, + "cache_read": 0.1 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "wandb/moonshotai/Kimi-K2.6", + "name": "Kimi K2.6", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-04-20", + "last_updated": "2026-04-20", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.16 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "wandb/moonshotai/Kimi-K2.7-Code", + "name": "Kimi K2.7 Code", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.94, + "output": 4.0, + "cache_read": 0.19 }, "limit": { "context": 262144, @@ -147120,14 +150167,14 @@ }, { "id": "wandb/nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-FP8", - "name": "NVIDIA Nemotron 3 Super 120B", + "name": "Nemotron 3 Super", "family": "nemotron", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2026-03-11", - "last_updated": "2026-03-12", + "last_updated": "2026-03-11", "modalities": { "input": [ "text" @@ -147139,7 +150186,37 @@ "open_weights": true, "cost": { "input": 0.2, - "output": 0.8 + "output": 0.8, + "cache_read": 0.2 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "wandb/nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B", + "name": "Nemotron 3 Ultra", + "family": "nemotron", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-04", + "last_updated": "2026-06-04", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.75, + "output": 2.75, + "cache_read": 0.15 }, "limit": { "context": 262144, @@ -147151,11 +150228,11 @@ "name": "gpt-oss-120b", "family": "gpt-oss", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2025-08-05", - "last_updated": "2026-03-12", + "last_updated": "2025-08-05", "modalities": { "input": [ "text" @@ -147164,10 +150241,11 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { - "input": 0.15, - "output": 0.6 + "input": 0.04, + "output": 0.14, + "cache_read": 0.04 }, "limit": { "context": 131072, @@ -147179,39 +150257,11 @@ "name": "gpt-oss-20b", "family": "gpt-oss", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2025-08-05", - "last_updated": "2026-03-12", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.05, - "output": 0.2 - }, - "limit": { - "context": 131072, - "output": 131072 - } - }, - { - "id": "wandb/zai-org/GLM-5-FP8", - "name": "GLM 5", - "family": "glm", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2026-02-11", - "last_updated": "2026-03-12", + "last_updated": "2025-08-05", "modalities": { "input": [ "text" @@ -147222,17 +150272,18 @@ }, "open_weights": true, "cost": { - "input": 1.0, - "output": 3.2 + "input": 0.03, + "output": 0.13, + "cache_read": 0.03 }, "limit": { - "context": 200000, - "output": 200000 + "context": 131072, + "output": 131072 } }, { "id": "wandb/zai-org/GLM-5.1", - "name": "GLM-5.1", + "name": "GLM 5.1", "family": "glm", "attachment": false, "reasoning": true, @@ -147252,12 +150303,40 @@ "cost": { "input": 1.4, "output": 4.4, - "cache_read": 0.26, - "cache_write": 0.0 + "cache_read": 0.26 }, "limit": { - "context": 200000, - "output": 131072 + "context": 202752, + "output": 202752 + } + }, + { + "id": "wandb/zai-org/GLM-5.2", + "name": "GLM 5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-16", + "last_updated": "2026-06-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.39, + "output": 4.4, + "cache_read": 0.26 + }, + "limit": { + "context": 262144, + "output": 262144 } }, { @@ -147384,6 +150463,37 @@ "output": 30000 } }, + { + "id": "x-ai/grok-4.5", + "name": "Grok 4.5", + "family": "grok", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-07-08", + "last_updated": "2026-07-08", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 6.0, + "cache_read": 0.5 + }, + "limit": { + "context": 500000, + "output": 500000 + } + }, { "id": "x-ai/grok-build-0.1", "name": "Grok Build 0.1", @@ -147499,40 +150609,6 @@ "output": 0 } }, - { - "id": "xiaomi-token-plan-ams/mimo-v2-omni", - "name": "MiMo-V2-Omni", - "family": "mimo", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-12", - "release_date": "2026-03-18", - "last_updated": "2026-03-18", - "modalities": { - "input": [ - "text", - "image", - "audio", - "video", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0, - "cache_read": 0.0 - }, - "limit": { - "context": 262144, - "output": 131072 - } - }, { "id": "xiaomi-token-plan-ams/mimo-v2-pro", "name": "MiMo-V2-Pro", @@ -147734,40 +150810,6 @@ "output": 8192 } }, - { - "id": "xiaomi-token-plan-cn/mimo-v2-omni", - "name": "MiMo-V2-Omni", - "family": "mimo", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-12", - "release_date": "2026-03-18", - "last_updated": "2026-03-18", - "modalities": { - "input": [ - "text", - "image", - "audio", - "video", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0, - "cache_read": 0.0 - }, - "limit": { - "context": 262144, - "output": 131072 - } - }, { "id": "xiaomi-token-plan-cn/mimo-v2-pro", "name": "MiMo-V2-Pro", @@ -147969,40 +151011,6 @@ "output": 8192 } }, - { - "id": "xiaomi-token-plan-sgp/mimo-v2-omni", - "name": "MiMo-V2-Omni", - "family": "mimo", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-12", - "release_date": "2026-03-18", - "last_updated": "2026-03-18", - "modalities": { - "input": [ - "text", - "image", - "audio", - "video", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0, - "cache_read": 0.0 - }, - "limit": { - "context": 262144, - "output": 131072 - } - }, { "id": "xiaomi-token-plan-sgp/mimo-v2-pro", "name": "MiMo-V2-Pro", @@ -148214,7 +151222,7 @@ "temperature": true, "knowledge": "2024-12-01", "release_date": "2025-12-16", - "last_updated": "2026-02-04", + "last_updated": "2026-06-24", "modalities": { "input": [ "text" @@ -148225,9 +151233,9 @@ }, "open_weights": true, "cost": { - "input": 0.1, - "output": 0.3, - "cache_read": 0.01 + "input": 0.14, + "output": 0.28, + "cache_read": 0.0028 }, "limit": { "context": 262144, @@ -148244,7 +151252,7 @@ "temperature": true, "knowledge": "2024-12", "release_date": "2026-03-18", - "last_updated": "2026-03-18", + "last_updated": "2026-06-24", "modalities": { "input": [ "text", @@ -148259,9 +151267,9 @@ }, "open_weights": false, "cost": { - "input": 0.4, - "output": 2.0, - "cache_read": 0.08 + "input": 0.14, + "output": 0.28, + "cache_read": 0.0028 }, "limit": { "context": 262144, @@ -148278,7 +151286,7 @@ "temperature": true, "knowledge": "2024-12", "release_date": "2026-03-18", - "last_updated": "2026-03-18", + "last_updated": "2026-06-24", "modalities": { "input": [ "text" @@ -148289,9 +151297,9 @@ }, "open_weights": false, "cost": { - "input": 1.0, - "output": 3.0, - "cache_read": 0.2 + "input": 0.435, + "output": 0.87, + "cache_read": 0.0036 }, "limit": { "context": 1048576, @@ -148308,7 +151316,7 @@ "temperature": true, "knowledge": "2024-12", "release_date": "2026-04-22", - "last_updated": "2026-04-22", + "last_updated": "2026-06-24", "modalities": { "input": [ "text", @@ -148322,9 +151330,9 @@ }, "open_weights": true, "cost": { - "input": 0.4, - "output": 2.0, - "cache_read": 0.08 + "input": 0.14, + "output": 0.28, + "cache_read": 0.0028 }, "limit": { "context": 1048576, @@ -148341,7 +151349,7 @@ "temperature": true, "knowledge": "2024-12", "release_date": "2026-04-22", - "last_updated": "2026-04-22", + "last_updated": "2026-06-24", "modalities": { "input": [ "text" @@ -148352,9 +151360,9 @@ }, "open_weights": true, "cost": { - "input": 1.0, - "output": 3.0, - "cache_read": 0.2 + "input": 0.435, + "output": 0.87, + "cache_read": 0.0036 }, "limit": { "context": 1048576, @@ -148391,6 +151399,37 @@ "output": 131072 } }, + { + "id": "xpersona/claude-fable-5", + "name": "Claude Fable 5", + "family": "claude-fable", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-09", + "last_updated": "2026-06-09", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 18.0, + "cache_read": 0.3 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "xpersona/xpersona-frieren-coder", "name": "Xpersona Frieren 1", @@ -149072,7 +152111,7 @@ { "id": "zeldoc/z-code", "name": "Z-Code", - "attachment": true, + "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, @@ -149081,9 +152120,7 @@ "last_updated": "2026-04-15", "modalities": { "input": [ - "text", - "image", - "video" + "text" ], "output": [ "text" @@ -149094,9 +152131,37 @@ "input": 0.0, "output": 0.0 }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "zenifra/alibaba/qwen3.6-35b-a3b", + "name": "Qwen3.6 35B-A3B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-17", + "last_updated": "2026-04-17", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.19, + "output": 0.48 + }, "limit": { "context": 262144, - "output": 262144 + "output": 65536 } }, { @@ -149393,6 +152458,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -149512,6 +152578,72 @@ "output": 64000 } }, + { + "id": "zenmux/anthropic/claude-sonnet-5", + "name": "Claude Sonnet 5", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 10.0, + "cache_read": 0.2, + "cache_write": 4.0 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "zenmux/anthropic/claude-sonnet-5-free", + "name": "Claude Sonnet 5 (Free)", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "zenmux/baidu/ernie-5.0-thinking-preview", "name": "ERNIE 5.0", @@ -153031,9 +156163,9 @@ }, "open_weights": false, "cost": { - "input": 6.0, - "output": 24.0, - "cache_read": 1.3, + "input": 1.4, + "output": 4.4, + "cache_read": 0.26, "cache_write": 0.0 }, "limit": { diff --git a/crates/goose-providers/src/canonical/data/provider_metadata.json b/crates/goose-provider-types/src/canonical/data/provider_metadata.json similarity index 91% rename from crates/goose-providers/src/canonical/data/provider_metadata.json rename to crates/goose-provider-types/src/canonical/data/provider_metadata.json index a21477d5ff..94656c79be 100644 --- a/crates/goose-providers/src/canonical/data/provider_metadata.json +++ b/crates/goose-provider-types/src/canonical/data/provider_metadata.json @@ -52,7 +52,7 @@ "env": [ "DASHSCOPE_API_KEY" ], - "model_count": 83 + "model_count": 84 }, { "id": "alibaba-coding-plan", @@ -173,7 +173,7 @@ "env": [ "BERGET_API_KEY" ], - "model_count": 7 + "model_count": 8 }, { "id": "chutes", @@ -184,7 +184,7 @@ "env": [ "CHUTES_API_KEY" ], - "model_count": 39 + "model_count": 13 }, { "id": "clarifai", @@ -253,6 +253,17 @@ ], "model_count": 23 }, + { + "id": "crossmodel", + "display_name": "CrossModel", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.crossmodel.ai/v1", + "doc": "https://www.crossmodel.ai/docs", + "env": [ + "CROSSMODEL_API_KEY" + ], + "model_count": 37 + }, { "id": "databricks", "display_name": "Databricks", @@ -285,7 +296,7 @@ "env": [ "DIGITALOCEAN_ACCESS_TOKEN" ], - "model_count": 80 + "model_count": 82 }, { "id": "dinference", @@ -318,7 +329,7 @@ "env": [ "EVROC_API_KEY" ], - "model_count": 13 + "model_count": 15 }, { "id": "fastrouter", @@ -340,7 +351,7 @@ "env": [ "FIREWORKS_API_KEY" ], - "model_count": 15 + "model_count": 16 }, { "id": "freemodel", @@ -384,7 +395,7 @@ "env": [ "GITHUB_TOKEN" ], - "model_count": 22 + "model_count": 25 }, { "id": "github-models", @@ -406,7 +417,7 @@ "env": [ "GMICLOUD_API_KEY" ], - "model_count": 8 + "model_count": 13 }, { "id": "helicone", @@ -439,7 +450,7 @@ "env": [ "HF_TOKEN" ], - "model_count": 46 + "model_count": 51 }, { "id": "iflowcn", @@ -472,7 +483,7 @@ "env": [ "INCEPTRON_API_KEY" ], - "model_count": 4 + "model_count": 6 }, { "id": "inference", @@ -507,6 +518,17 @@ ], "model_count": 61 }, + { + "id": "kenari", + "display_name": "Kenari", + "npm": "@ai-sdk/openai-compatible", + "api": "https://kenari.id/v1", + "doc": "https://kenari.id/docs", + "env": [ + "KENARI_API_KEY" + ], + "model_count": 23 + }, { "id": "kilo", "display_name": "Kilo Gateway", @@ -516,7 +538,7 @@ "env": [ "KILO_API_KEY" ], - "model_count": 345 + "model_count": 346 }, { "id": "kimi-for-coding", @@ -560,7 +582,7 @@ "env": [ "LLMGATEWAY_API_KEY" ], - "model_count": 199 + "model_count": 181 }, { "id": "llmtr", @@ -584,6 +606,17 @@ ], "model_count": 3 }, + { + "id": "longcat", + "display_name": "LongCat", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.longcat.chat/openai", + "doc": "https://longcat.chat/platform/docs/", + "env": [ + "LONGCAT_API_KEY" + ], + "model_count": 1 + }, { "id": "lucidquery", "display_name": "LucidQuery", @@ -606,6 +639,17 @@ ], "model_count": 19 }, + { + "id": "meta", + "display_name": "Meta", + "npm": "@ai-sdk/openai", + "api": "https://api.meta.ai/v1", + "doc": "https://dev.meta.ai/docs", + "env": [ + "META_MODEL_API_KEY" + ], + "model_count": 1 + }, { "id": "meta-llama", "display_name": "Llama", @@ -758,7 +802,7 @@ "env": [ "NEBIUS_API_KEY" ], - "model_count": 29 + "model_count": 30 }, { "id": "neon", @@ -770,7 +814,7 @@ "NEON_AI_GATEWAY_BASE_URL", "NEON_AI_GATEWAY_TOKEN" ], - "model_count": 25 + "model_count": 36 }, { "id": "neuralwatt", @@ -781,7 +825,7 @@ "env": [ "NEURALWATT_API_KEY" ], - "model_count": 13 + "model_count": 18 }, { "id": "nova", @@ -814,7 +858,7 @@ "env": [ "NVIDIA_API_KEY" ], - "model_count": 83 + "model_count": 84 }, { "id": "ollama-cloud", @@ -836,7 +880,7 @@ "env": [ "OPENCODE_API_KEY" ], - "model_count": 70 + "model_count": 76 }, { "id": "opencode-go", @@ -869,7 +913,7 @@ "env": [ "OVHCLOUD_API_KEY" ], - "model_count": 15 + "model_count": 14 }, { "id": "perplexity-agent", @@ -964,12 +1008,23 @@ "id": "routing-run", "display_name": "routing.run", "npm": "@ai-sdk/openai-compatible", - "api": "https://ai.routing.sh/v1", + "api": "https://api.routing.run/v1", "doc": "https://docs.routing.run/api-reference/models", "env": [ "ROUTING_RUN_API_KEY" ], - "model_count": 26 + "model_count": 12 + }, + { + "id": "sakana", + "display_name": "Sakana AI", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.sakana.ai/v1", + "doc": "https://console.sakana.ai/models", + "env": [ + "SAKANA_API_KEY" + ], + "model_count": 3 }, { "id": "sarvam", @@ -991,7 +1046,7 @@ "env": [ "SCALEWAY_API_KEY" ], - "model_count": 16 + "model_count": 17 }, { "id": "siliconflow", @@ -1002,7 +1057,7 @@ "env": [ "SILICONFLOW_API_KEY" ], - "model_count": 70 + "model_count": 49 }, { "id": "siliconflow-cn", @@ -1013,7 +1068,7 @@ "env": [ "SILICONFLOW_CN_API_KEY" ], - "model_count": 74 + "model_count": 47 }, { "id": "snowflake-cortex", @@ -1047,7 +1102,7 @@ "env": [ "STEPFUN_API_KEY" ], - "model_count": 4 + "model_count": 8 }, { "id": "stepfun-ai", @@ -1058,6 +1113,17 @@ "env": [ "STEPFUN_API_KEY" ], + "model_count": 8 + }, + { + "id": "subconscious", + "display_name": "Subconscious", + "npm": "@ai-sdk/anthropic", + "api": "https://api.subconscious.dev/v1", + "doc": "https://docs.subconscious.dev", + "env": [ + "SUBCONSCIOUS_API_KEY" + ], "model_count": 2 }, { @@ -1080,7 +1146,7 @@ "env": [ "SYNTHETIC_API_KEY" ], - "model_count": 34 + "model_count": 7 }, { "id": "tencent-coding-plan", @@ -1093,6 +1159,17 @@ ], "model_count": 8 }, + { + "id": "tencent-token-plan", + "display_name": "Tencent Token Plan", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.lkeap.cloud.tencent.com/plan/v3", + "doc": "https://cloud.tencent.com/document/product/1823/130060", + "env": [ + "TENCENT_TOKEN_PLAN_API_KEY" + ], + "model_count": 1 + }, { "id": "tencent-tokenhub", "display_name": "Tencent TokenHub", @@ -1102,7 +1179,7 @@ "env": [ "TENCENT_TOKENHUB_API_KEY" ], - "model_count": 1 + "model_count": 2 }, { "id": "the-grid-ai", @@ -1115,6 +1192,28 @@ ], "model_count": 9 }, + { + "id": "tinfoil", + "display_name": "Tinfoil", + "npm": "@ai-sdk/openai-compatible", + "api": "https://inference.tinfoil.sh/v1", + "doc": "https://docs.tinfoil.sh", + "env": [ + "TINFOIL_API_KEY" + ], + "model_count": 7 + }, + { + "id": "trustedrouter", + "display_name": "TrustedRouter", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.trustedrouter.com/v1", + "doc": "https://trustedrouter.com/docs", + "env": [ + "TRUSTEDROUTER_API_KEY" + ], + "model_count": 7 + }, { "id": "umans-ai", "display_name": "Umans AI", @@ -1157,7 +1256,7 @@ "env": [ "VIVGRID_API_KEY" ], - "model_count": 13 + "model_count": 14 }, { "id": "vultr", @@ -1168,7 +1267,7 @@ "env": [ "VULTR_API_KEY" ], - "model_count": 7 + "model_count": 10 }, { "id": "wafer.ai", @@ -1179,7 +1278,7 @@ "env": [ "WAFER_API_KEY" ], - "model_count": 7 + "model_count": 8 }, { "id": "wandb", @@ -1190,7 +1289,7 @@ "env": [ "WANDB_API_KEY" ], - "model_count": 18 + "model_count": 29 }, { "id": "xiaomi", @@ -1212,7 +1311,7 @@ "env": [ "XIAOMI_API_KEY" ], - "model_count": 8 + "model_count": 7 }, { "id": "xiaomi-token-plan-cn", @@ -1223,7 +1322,7 @@ "env": [ "XIAOMI_API_KEY" ], - "model_count": 8 + "model_count": 7 }, { "id": "xiaomi-token-plan-sgp", @@ -1234,7 +1333,7 @@ "env": [ "XIAOMI_API_KEY" ], - "model_count": 8 + "model_count": 7 }, { "id": "xpersona", @@ -1245,7 +1344,7 @@ "env": [ "XPERSONA_API_KEY" ], - "model_count": 2 + "model_count": 3 }, { "id": "zai", @@ -1280,6 +1379,17 @@ ], "model_count": 1 }, + { + "id": "zenifra", + "display_name": "Zenifra", + "npm": "@ai-sdk/openai-compatible", + "api": "https://ai.zenifra.com/v1", + "doc": "https://docs.zenifra.com", + "env": [ + "ZENIFRA_AI_KEY" + ], + "model_count": 1 + }, { "id": "zenmux", "display_name": "ZenMux", @@ -1289,7 +1399,7 @@ "env": [ "ZENMUX_API_KEY" ], - "model_count": 112 + "model_count": 114 }, { "id": "zhipuai", diff --git a/crates/goose-providers/src/canonical/model.rs b/crates/goose-provider-types/src/canonical/model.rs similarity index 100% rename from crates/goose-providers/src/canonical/model.rs rename to crates/goose-provider-types/src/canonical/model.rs diff --git a/crates/goose-providers/src/canonical/name_builder.rs b/crates/goose-provider-types/src/canonical/name_builder.rs similarity index 80% rename from crates/goose-providers/src/canonical/name_builder.rs rename to crates/goose-provider-types/src/canonical/name_builder.rs index 6408201017..7dadd1e847 100644 --- a/crates/goose-providers/src/canonical/name_builder.rs +++ b/crates/goose-provider-types/src/canonical/name_builder.rs @@ -35,7 +35,10 @@ pub fn canonical_name(provider: &str, model: &str) -> String { } fn is_meta_provider(provider: &str) -> bool { - matches!(provider, "databricks" | "tetrate" | "bedrock" | "azure") + matches!( + provider, + "databricks" | "databricks_v2" | "tetrate" | "bedrock" | "azure" + ) } pub fn map_provider_name(provider: &str) -> &str { @@ -46,6 +49,7 @@ pub fn map_provider_name(provider: &str) -> &str { "aws_bedrock" => "amazon-bedrock", "gcp_vertex_ai" => "google-vertex", "gemini_oauth" => "google", + "databricks_v2" => "databricks", "zhipu" => "zhipuai", "novita" => "novita-ai", "opencode_go" => "opencode-go", @@ -121,6 +125,21 @@ pub fn map_to_canonical_model( } } + // Fallback for meta-providers: some native aliases are keyed under the + // meta-provider itself (e.g. "databricks/databricks-gpt-oss-120b") and do + // not infer back to a first-party provider. Only try this after inference, + // so models that DO infer (e.g. databricks-claude-* -> anthropic/*) keep + // resolving to the richer first-party catalog entry. + if is_meta_provider(provider) { + if let Some(canonical) = registry.get(registry_provider, model) { + return Some(canonical.id.clone()); + } + let normalized_model = strip_version_suffix(model); + if let Some(canonical) = registry.get(registry_provider, &normalized_model) { + return Some(canonical.id.clone()); + } + } + None } @@ -310,8 +329,8 @@ mod tests { // === Direct provider (non-hosting) === assert_eq!( - map_to_canonical_model("anthropic", "claude-3-5-sonnet-20241022", r), - Some("anthropic/claude-3.5-sonnet".to_string()) + map_to_canonical_model("anthropic", "claude-sonnet-4-5-20250929", r), + Some("anthropic/claude-sonnet-4.5".to_string()) ); assert_eq!( map_to_canonical_model("openai", "gpt-4o-latest", r), @@ -334,28 +353,22 @@ mod tests { // === Anthropic Claude - basic === assert_eq!( - map_to_canonical_model("databricks", "claude-3-5-sonnet", r), - Some("anthropic/claude-3.5-sonnet".to_string()) + map_to_canonical_model("databricks", "claude-sonnet-4-5", r), + Some("anthropic/claude-sonnet-4.5".to_string()) ); assert_eq!( - map_to_canonical_model("databricks", "claude-3-5-sonnet-20241022", r), - Some("anthropic/claude-3.5-sonnet".to_string()) + map_to_canonical_model("databricks", "claude-sonnet-4-5-20250929", r), + Some("anthropic/claude-sonnet-4.5".to_string()) ); assert_eq!( - map_to_canonical_model("databricks", "claude-3-5-sonnet-latest", r), - Some("anthropic/claude-3.5-sonnet".to_string()) - ); - - // 3.x: {model}-{version} โ†’ {version}-{model} - assert_eq!( - map_to_canonical_model("databricks", "claude-haiku-3-5", r), - Some("anthropic/claude-3.5-haiku".to_string()) + map_to_canonical_model("databricks", "claude-sonnet-4-5-latest", r), + Some("anthropic/claude-sonnet-4.5".to_string()) ); // 4.x: {version}-{model} โ†’ {model}-{version} assert_eq!( - map_to_canonical_model("databricks", "claude-4-sonnet", r), - Some("anthropic/claude-sonnet-4".to_string()) + map_to_canonical_model("databricks", "claude-sonnet-4-5", r), + Some("anthropic/claude-sonnet-4.5".to_string()) ); // 4.x with minor version + prefix stripping @@ -366,16 +379,24 @@ mod tests { // === Claude with platform suffixes === assert_eq!( - map_to_canonical_model("databricks", "claude-4-sonnet-bedrock", r), - Some("anthropic/claude-sonnet-4".to_string()) + map_to_canonical_model("databricks", "claude-sonnet-4-5-bedrock", r), + Some("anthropic/claude-sonnet-4.5".to_string()) ); assert_eq!( - map_to_canonical_model("databricks", "goose-claude-4-sonnet-bedrock", r), - Some("anthropic/claude-sonnet-4".to_string()) + map_to_canonical_model("databricks", "goose-claude-sonnet-4-5-bedrock", r), + Some("anthropic/claude-sonnet-4.5".to_string()) ); assert_eq!( - map_to_canonical_model("bedrock", "claude-3-5-sonnet", r), - Some("anthropic/claude-3.5-sonnet".to_string()) + map_to_canonical_model("bedrock", "claude-sonnet-4-5", r), + Some("anthropic/claude-sonnet-4.5".to_string()) + ); + assert_eq!( + map_to_canonical_model("aws_bedrock", "global.anthropic.claude-sonnet-5", r), + Some("amazon-bedrock/global.anthropic.claude-sonnet-5".to_string()) + ); + assert_eq!( + map_to_canonical_model("anthropic", "claude-sonnet-5", r), + Some("anthropic/claude-sonnet-5".to_string()) ); // === OpenAI GPT === @@ -473,8 +494,8 @@ mod tests { // === Provider-prefixed extraction === assert_eq!( - map_to_canonical_model("databricks", "anthropic-claude-3-5-sonnet", r), - Some("anthropic/claude-3.5-sonnet".to_string()) + map_to_canonical_model("databricks", "anthropic-claude-sonnet-4-5", r), + Some("anthropic/claude-sonnet-4.5".to_string()) ); assert_eq!( map_to_canonical_model("databricks", "openai-gpt-4o", r), @@ -537,4 +558,36 @@ mod tests { Some("google-vertex/claude-haiku-4.5".to_string()) ); } + + // Databricks-native open-weight ids are keyed under the meta-provider itself + // (e.g. "databricks/databricks-gpt-oss-120b") and do not infer back to another + // provider, so they must resolve via the direct meta-provider lookup. These + // particular ids are unversioned, so the assertions are not catalog-version brittle. + #[test] + fn test_databricks_native_open_weight_ids_resolve() { + let r = super::super::CanonicalModelRegistry::bundled().unwrap(); + + assert_eq!( + map_to_canonical_model("databricks_v2", "databricks-gpt-oss-120b", r), + Some("databricks/databricks-gpt-oss-120b".to_string()) + ); + assert_eq!( + map_to_canonical_model("databricks_v2", "databricks-gpt-oss-20b", r), + Some("databricks/databricks-gpt-oss-20b".to_string()) + ); + // Legacy provider name resolves identically. + assert_eq!( + map_to_canonical_model("databricks", "databricks-gpt-oss-120b", r), + Some("databricks/databricks-gpt-oss-120b".to_string()) + ); + + // Regression guard: the meta-provider lookup must remain a *fallback* + // after inference. databricks-claude-* aliases infer back to the richer + // first-party "anthropic/*" entry (which carries thinking_mode used for + // adaptive thinking), not the metadata-poor "databricks/databricks-*" one. + assert_eq!( + map_to_canonical_model("databricks", "databricks-claude-opus-4-7", r), + Some("anthropic/claude-opus-4.7".to_string()) + ); + } } diff --git a/crates/goose-providers/src/canonical/registry.rs b/crates/goose-provider-types/src/canonical/registry.rs similarity index 100% rename from crates/goose-providers/src/canonical/registry.rs rename to crates/goose-provider-types/src/canonical/registry.rs diff --git a/crates/goose-providers/src/conversation.rs b/crates/goose-provider-types/src/conversation.rs similarity index 81% rename from crates/goose-providers/src/conversation.rs rename to crates/goose-provider-types/src/conversation.rs index 4f8c92197b..7f8276579d 100644 --- a/crates/goose-providers/src/conversation.rs +++ b/crates/goose-provider-types/src/conversation.rs @@ -43,6 +43,10 @@ impl Conversation { &self.0 } + pub fn messages_mut(&mut self) -> &mut Vec { + &mut self.0 + } + pub fn push(&mut self, message: Message) { if message.content.is_empty() && message.metadata.inference.is_some() { if let Some(existing) = self @@ -131,6 +135,10 @@ impl Conversation { self.0.pop() } + pub fn remove(&mut self, index: usize) -> Message { + self.0.remove(index) + } + pub fn truncate(&mut self, len: usize) { self.0.truncate(len); } @@ -243,6 +251,7 @@ fn fix_messages(messages: Vec) -> (Vec, Vec) { fix_empty_tool_results, fix_tool_calling, merge_consecutive_messages, + dedupe_signed_thinking, fix_lead_trail, populate_if_empty, ] @@ -498,6 +507,71 @@ pub fn merge_consecutive_messages(messages: Vec) -> (Vec, Vec< (merged_messages, issues) } +/// Signed thinking carries a signature; redacted thinking is always signed. +/// Signed blocks must be replayed exactly; unsigned reasoning summaries need not. +fn is_signed_thinking(content: &MessageContent) -> bool { + match content { + MessageContent::Thinking(t) => !t.signature.is_empty(), + MessageContent::RedactedThinking(_) => true, + _ => false, + } +} + +/// Drops duplicate signed thinking blocks, keeping the first occurrence. Some +/// signed-replay APIs (like Anthropic) reject a request that repeats the same +/// signed block more than once. +/// +/// Duplicates arise two ways, both handled here: +/// - Within one assistant message, when a standalone thinking message is +/// merged with a tool-call message that re-embedded the same thinking. +/// - Across assistant messages, when the agent splits one provider turn into +/// several tool-call messages (interleaved with tool results) that each +/// carry a copy of the turn's signed thinking. +/// +/// The `seen` set spans the whole conversation. A signed block carries a +/// cryptographic signature unique to its generation, so an exact (text + +/// signature) match can only be the same turn's thinking copied onto split +/// messages โ€” never two genuinely distinct thoughts. Unsigned reasoning +/// summaries are left untouched, since providers like Kimi/DeepSeek require +/// them echoed on every tool-call message. +/// +/// This runs before any provider formatter, so it covers every Claude transport +/// (direct Anthropic, Bedrock, Databricks, Vertex) in one place. +fn dedupe_signed_thinking(messages: Vec) -> (Vec, Vec) { + let mut issues = Vec::new(); + let mut seen: Vec = Vec::new(); + + let fixed_messages = messages + .into_iter() + .map(|mut message| { + if message.role != Role::Assistant { + return message; + } + + let original_len = message.content.len(); + let mut deduped: Vec = Vec::with_capacity(original_len); + for content in &message.content { + let is_signed = is_signed_thinking(content); + if is_signed && seen.contains(content) { + continue; + } + if is_signed { + seen.push(content.clone()); + } + deduped.push(content.clone()); + } + + if deduped.len() != original_len { + issues.push("Removed duplicate signed thinking block".to_string()); + message.content = deduped; + } + message + }) + .collect(); + + (fixed_messages, issues) +} + fn has_tool_response(message: &Message) -> bool { message .content @@ -505,6 +579,16 @@ fn has_tool_response(message: &Message) -> bool { .any(|content| matches!(content, MessageContent::ToolResponse(_))) } +pub const TURN_CONTEXT_TAG: &str = "turn-context"; +pub const CURRENT_TIME_TAG: &str = "current-time"; +pub const WORKING_DIRECTORY_TAG: &str = "working-directory"; + +pub fn is_turn_context_text(text: &str) -> bool { + text.starts_with(&format!("<{TURN_CONTEXT_TAG}>\n<{CURRENT_TIME_TAG}>")) + && text.contains(&format!("\n<{WORKING_DIRECTORY_TAG}>")) + && text.trim_end().ends_with(&format!("")) +} + pub fn effective_role(message: &Message) -> String { if message.role == Role::User && has_tool_response(message) { "tool".to_string() @@ -1290,6 +1374,237 @@ mod tests { assert!(!fixed_messages[5].metadata.agent_visible); } + #[test] + fn test_dedupes_duplicate_signed_thinking_around_tool_call() { + use crate::conversation::message::MessageContent; + use rmcp::model::Content; + + // Reproduces the Anthropic 400 scenario: a standalone signed thinking + // message immediately followed by an assistant message that repeats the + // same signed thinking plus a tool_use. After merging consecutive + // assistant messages these become two adjacent identical thinking blocks. + let messages = vec![ + Message::user().with_text("Do the thing"), + Message::assistant().with_thinking("Let me think about this", "sig-1"), + Message::assistant() + .with_thinking("Let me think about this", "sig-1") + .with_tool_request( + "tool_1", + Ok(CallToolRequestParams::new("do_thing").with_arguments(object!({"x": 1}))), + ), + Message::user().with_tool_response( + "tool_1", + Ok(rmcp::model::CallToolResult::success(vec![Content::text( + "done", + )])), + ), + Message::user().with_text("Now continue"), + ]; + + let (fixed, issues) = fix_conversation(Conversation::new_unvalidated(messages)); + + assert!( + issues + .iter() + .any(|i| i == "Removed duplicate signed thinking block"), + "expected dedupe issue, got: {:?}", + issues + ); + + let fixed_messages = fixed.messages(); + let assistant = fixed_messages + .iter() + .find(|m| { + m.role == Role::Assistant + && m.content + .iter() + .any(|c| matches!(c, MessageContent::ToolRequest(_))) + }) + .expect("assistant tool-call message should exist"); + + let thinking_count = assistant + .content + .iter() + .filter(|c| { + matches!( + c, + MessageContent::Thinking(_) | MessageContent::RedactedThinking(_) + ) + }) + .count(); + assert_eq!( + thinking_count, 1, + "duplicate signed thinking should be collapsed to one block" + ); + } + + #[test] + fn test_keeps_distinct_signed_thinking_blocks_in_assistant_message() { + use crate::conversation::message::MessageContent; + use rmcp::model::Content; + + // Distinct signed thinking blocks (different signatures) must be preserved. + let messages = vec![ + Message::user().with_text("Do the thing"), + Message::assistant() + .with_thinking("First thought", "sig-A") + .with_thinking("Second thought", "sig-B") + .with_tool_request( + "tool_1", + Ok(CallToolRequestParams::new("do_thing").with_arguments(object!({"x": 1}))), + ), + Message::user().with_tool_response( + "tool_1", + Ok(rmcp::model::CallToolResult::success(vec![Content::text( + "done", + )])), + ), + Message::user().with_text("Now continue"), + ]; + + let (fixed, issues) = fix_conversation(Conversation::new_unvalidated(messages)); + + assert!( + !issues + .iter() + .any(|i| i == "Removed duplicate signed thinking block"), + "should not dedupe distinct thinking blocks, got: {:?}", + issues + ); + + let fixed_messages = fixed.messages(); + let thinking_count = fixed_messages[1] + .content + .iter() + .filter(|c| { + matches!( + c, + MessageContent::Thinking(_) | MessageContent::RedactedThinking(_) + ) + }) + .count(); + assert_eq!(thinking_count, 2, "distinct thinking blocks must be kept"); + } + + #[test] + fn test_keeps_duplicate_unsigned_thinking_blocks() { + use crate::conversation::message::MessageContent; + + // Unsigned thinking (reasoning summaries from non-Anthropic providers) + // can legitimately repeat and must not be dropped, since only signed + // blocks trigger the Anthropic exact-replay 400. + let messages = vec![ + Message::user().with_text("Do the thing"), + Message::assistant() + .with_thinking("same reasoning", "") + .with_thinking("same reasoning", ""), + Message::user().with_text("Now continue"), + ]; + + let (fixed, issues) = fix_conversation(Conversation::new_unvalidated(messages)); + + assert!( + !issues + .iter() + .any(|i| i == "Removed duplicate signed thinking block"), + "unsigned thinking must not be deduped, got: {:?}", + issues + ); + + let fixed_messages = fixed.messages(); + let thinking_count = fixed_messages + .iter() + .flat_map(|m| m.content.iter()) + .filter(|c| matches!(c, MessageContent::Thinking(_))) + .count(); + assert_eq!( + thinking_count, 2, + "duplicate unsigned thinking blocks must be kept" + ); + } + + #[test] + fn test_dedupes_signed_thinking_across_split_tool_messages() { + use crate::conversation::message::MessageContent; + use rmcp::model::Content; + + // The agent splits one provider turn with multiple tool calls into one + // assistant message per call (interleaved with tool results), each + // carrying the same signed thinking. merge_consecutive_messages cannot + // merge them because tool results sit between, so the dedupe must span + // messages and keep the signed block only on the first. + let messages = vec![ + Message::user().with_text("Use both tools"), + Message::assistant() + .with_thinking("multi-tool reasoning", "sig-1") + .with_tool_request( + "call_1", + Ok(CallToolRequestParams::new("tool_a").with_arguments(object!({"p": 1}))), + ), + Message::user().with_tool_response( + "call_1", + Ok(rmcp::model::CallToolResult::success(vec![Content::text( + "ok", + )])), + ), + Message::assistant() + .with_thinking("multi-tool reasoning", "sig-1") + .with_tool_request( + "call_2", + Ok(CallToolRequestParams::new("tool_b").with_arguments(object!({"p": 2}))), + ), + Message::user().with_tool_response( + "call_2", + Ok(rmcp::model::CallToolResult::success(vec![Content::text( + "ok", + )])), + ), + Message::user().with_text("Now continue"), + ]; + + let (fixed, issues) = fix_conversation(Conversation::new_unvalidated(messages)); + + assert!( + issues + .iter() + .any(|i| i == "Removed duplicate signed thinking block"), + "expected cross-message dedupe issue, got: {:?}", + issues + ); + + let fixed_messages = fixed.messages(); + let total_thinking = fixed_messages + .iter() + .flat_map(|m| m.content.iter()) + .filter(|c| { + matches!( + c, + MessageContent::Thinking(_) | MessageContent::RedactedThinking(_) + ) + }) + .count(); + assert_eq!( + total_thinking, 1, + "the repeated signed block must survive only once across the turn" + ); + + let total_tool_requests = fixed_messages + .iter() + .flat_map(|m| m.content.iter()) + .filter(|c| matches!(c, MessageContent::ToolRequest(_))) + .count(); + assert_eq!(total_tool_requests, 2, "both tool calls must be preserved"); + + // The first split message keeps the thinking; the second loses it. + assert!( + fixed_messages[1] + .content + .iter() + .any(|c| matches!(c, MessageContent::Thinking(_))), + "first split message keeps signed thinking" + ); + } + #[test] fn test_push_coalesces_thinking_deltas() { use crate::conversation::message::MessageContent; diff --git a/crates/goose-providers/src/conversation/message.rs b/crates/goose-provider-types/src/conversation/message.rs similarity index 96% rename from crates/goose-providers/src/conversation/message.rs rename to crates/goose-provider-types/src/conversation/message.rs index 38c3a50069..1c095089c7 100644 --- a/crates/goose-providers/src/conversation/message.rs +++ b/crates/goose-provider-types/src/conversation/message.rs @@ -1,3 +1,4 @@ +use crate::conversation::token_usage::{CostSource, ProviderUsage}; use crate::conversation::tool_result_serde; use crate::mcp_utils::extract_text_from_resource; use crate::utils::sanitize_unicode_tags; @@ -252,6 +253,7 @@ pub struct FrontendToolRequest { #[serde(rename_all = "camelCase")] pub enum SystemNotificationType { ThinkingMessage, + ProgressMessage, InlineMessage, CreditsExhausted, } @@ -666,6 +668,49 @@ pub struct InferenceMetadata { pub resolved_model: Option, } +#[derive(ToSchema, Clone, PartialEq, Serialize, Deserialize, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct MessageUsage { + #[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, + #[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, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub is_compaction: bool, +} + +impl MessageUsage { + pub fn from_provider_usage(usage: &ProviderUsage, is_compaction: bool) -> Self { + let stats = usage.stats.as_ref(); + MessageUsage { + input_tokens: usage.usage.input_tokens, + output_tokens: usage.usage.output_tokens, + total_tokens: usage.usage.total_tokens, + cache_read_tokens: usage.usage.cache_read_input_tokens, + cache_write_tokens: usage.usage.cache_write_input_tokens, + cost: usage.cost, + cost_source: usage.cost_source, + elapsed_ms: stats.and_then(|s| s.elapsed_ms), + time_to_first_token_ms: stats.and_then(|s| s.time_to_first_token_ms), + is_compaction, + } + } +} + #[derive(ToSchema, Clone, PartialEq, Serialize, Deserialize, Debug)] /// Metadata for message visibility and model inference details #[serde(rename_all = "camelCase")] @@ -681,6 +726,8 @@ pub struct MessageMetadata { /// without matching user-visible text. Never sent to providers. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub steer: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option>, } impl Default for MessageMetadata { @@ -690,6 +737,7 @@ impl Default for MessageMetadata { agent_visible: true, inference: None, steer: false, + usage: None, } } } diff --git a/crates/goose-providers/src/conversation/token_usage.rs b/crates/goose-provider-types/src/conversation/token_usage.rs similarity index 91% rename from crates/goose-providers/src/conversation/token_usage.rs rename to crates/goose-provider-types/src/conversation/token_usage.rs index d62a4d3701..4fa2db5c52 100644 --- a/crates/goose-providers/src/conversation/token_usage.rs +++ b/crates/goose-provider-types/src/conversation/token_usage.rs @@ -9,11 +9,23 @@ pub struct ProviderUsage { 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, @@ -35,6 +47,8 @@ impl ProviderUsage { model, usage, stats: None, + cost: None, + cost_source: None, } } @@ -43,14 +57,10 @@ impl ProviderUsage { self } - /// Combine this ProviderUsage with another, adding their token counts - /// Uses the model from this ProviderUsage - pub fn combine_with(&self, other: &ProviderUsage) -> ProviderUsage { - ProviderUsage { - model: self.model.clone(), - usage: self.usage + other.usage, - stats: self.stats.clone().or_else(|| other.stats.clone()), - } + pub fn with_cost(mut self, cost: f64, source: CostSource) -> Self { + self.cost = Some(cost); + self.cost_source = Some(source); + self } } diff --git a/crates/goose-providers/src/conversation/tool_result_serde.rs b/crates/goose-provider-types/src/conversation/tool_result_serde.rs similarity index 100% rename from crates/goose-providers/src/conversation/tool_result_serde.rs rename to crates/goose-provider-types/src/conversation/tool_result_serde.rs diff --git a/crates/goose-providers/src/errors.rs b/crates/goose-provider-types/src/errors.rs similarity index 100% rename from crates/goose-providers/src/errors.rs rename to crates/goose-provider-types/src/errors.rs diff --git a/crates/goose-provider-types/src/formats.rs b/crates/goose-provider-types/src/formats.rs new file mode 100644 index 0000000000..03f7590961 --- /dev/null +++ b/crates/goose-provider-types/src/formats.rs @@ -0,0 +1,8 @@ +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/src/providers/formats/anthropic.rs b/crates/goose-provider-types/src/formats/anthropic.rs similarity index 71% rename from crates/goose/src/providers/formats/anthropic.rs rename to crates/goose-provider-types/src/formats/anthropic.rs index fa5315b36b..5ceb5fe57c 100644 --- a/crates/goose/src/providers/formats/anthropic.rs +++ b/crates/goose-provider-types/src/formats/anthropic.rs @@ -1,13 +1,13 @@ +use crate::canonical::maybe_get_canonical_model; +use crate::canonical::ThinkingMode; use crate::conversation::message::{Message, MessageContent}; +use crate::conversation::token_usage::{CostSource, ProviderUsage, Usage}; +use crate::errors::ProviderError; +use crate::images::{convert_image, ImageFormat}; use crate::mcp_utils::extract_text_from_resource; -use crate::providers::canonical::maybe_get_canonical_model; +use crate::model::ModelConfig; +use crate::thinking::ThinkingEffort; use anyhow::{anyhow, Result}; -use goose_providers::canonical::ThinkingMode; -use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; -use goose_providers::errors::ProviderError; -use goose_providers::images::{convert_image, ImageFormat}; -use goose_providers::model::ModelConfig; -use goose_providers::thinking::ThinkingEffort; use rmcp::model::{object, CallToolRequestParams, ErrorCode, ErrorData, JsonObject, Role, Tool}; use rmcp::object as json_object; use serde_json::{json, Value}; @@ -16,7 +16,7 @@ use std::fmt; use std::str::FromStr; use std::sync::Arc; -pub(crate) const ANTHROPIC_PROVIDER_NAME: &str = "anthropic"; +pub const ANTHROPIC_PROVIDER_NAME: &str = "anthropic"; macro_rules! string_enum { ($name:ident { $($variant:ident => $str:literal),+ $(,)? }) => { @@ -47,31 +47,25 @@ string_enum!(ThinkingType { Adaptive => "adaptive", Enabled => "enabled", Disabl pub struct AnthropicFormatOptions { pub preserve_unsigned_thinking: bool, pub preserve_thinking_context: bool, + pub thinking_disabled: bool, } impl AnthropicFormatOptions { fn for_model(self, model_config: &ModelConfig) -> Self { let preserve_thinking_context = model_config .request_param::("preserve_thinking_context") - .or_else(|| { - crate::config::Config::global() - .get_param("ANTHROPIC_PRESERVE_THINKING_CONTEXT") - .ok() - }) .unwrap_or(self.preserve_thinking_context); let preserve_unsigned_thinking = model_config .request_param::("preserve_unsigned_thinking") - .or_else(|| { - crate::config::Config::global() - .get_param("ANTHROPIC_PRESERVE_UNSIGNED_THINKING") - .ok() - }) .unwrap_or(self.preserve_unsigned_thinking) || preserve_thinking_context; + let thinking_disabled = model_config.reasoning == Some(false) + || model_config.thinking_effort() == Some(ThinkingEffort::Off); Self { preserve_unsigned_thinking, preserve_thinking_context, + thinking_disabled, } } } @@ -111,7 +105,7 @@ pub fn thinking_type_for_provider(provider_name: &str, model_config: &ModelConfi let effort = model_config.thinking_effort(); - if effort.is_none() && legacy_thinking_budget_tokens().is_some() { + if effort.is_none() && model_config.request_param::("budget_tokens").is_some() { return match mode { Some(ThinkingMode::Adaptive) => ThinkingType::Adaptive, _ => ThinkingType::Enabled, @@ -207,8 +201,16 @@ fn format_messages_with_options( })); } Err(_tool_error) => { - // Skip malformed tool requests - they shouldn't be sent to Anthropic - // This maintains the existing behavior for ToolRequest errors + // The paired tool response carries the parse error and + // serializes to a tool_result below; Anthropic rejects a + // tool_result without a preceding tool_use, so emit a + // placeholder tool_use with the same id to keep history valid. + content.push(json!({ + TYPE_FIELD: TOOL_USE_TYPE, + ID_FIELD: tool_request.id, + NAME_FIELD: "unparseable_tool_call", + INPUT_FIELD: json!({}) + })); } } } @@ -257,24 +259,31 @@ fn format_messages_with_options( // Skip } MessageContent::Thinking(thinking) => { - if !thinking.signature.is_empty() { - content.push(json!({ - TYPE_FIELD: THINKING_TYPE, - THINKING_TYPE: thinking.thinking, - SIGNATURE_FIELD: thinking.signature - })); - } else if options.preserve_unsigned_thinking && !thinking.thinking.is_empty() { - content.push(json!({ - TYPE_FIELD: THINKING_TYPE, - THINKING_TYPE: thinking.thinking - })); + // Anthropic rejects thinking blocks sent without a matching thinking config. + if !options.thinking_disabled { + if !thinking.signature.is_empty() { + content.push(json!({ + TYPE_FIELD: THINKING_TYPE, + THINKING_TYPE: thinking.thinking, + SIGNATURE_FIELD: thinking.signature + })); + } else if options.preserve_unsigned_thinking + && !thinking.thinking.is_empty() + { + content.push(json!({ + TYPE_FIELD: THINKING_TYPE, + THINKING_TYPE: thinking.thinking + })); + } } } MessageContent::RedactedThinking(redacted) => { - content.push(json!({ - TYPE_FIELD: REDACTED_THINKING_TYPE, - DATA_FIELD: redacted.data - })); + if !options.thinking_disabled { + content.push(json!({ + TYPE_FIELD: REDACTED_THINKING_TYPE, + DATA_FIELD: redacted.data + })); + } } MessageContent::Image(image) => { content.push(convert_image(image, &ImageFormat::Anthropic)); @@ -301,7 +310,6 @@ fn format_messages_with_options( } } - // If no messages, add a default one if anthropic_messages.is_empty() { anthropic_messages.push(json!({ ROLE_FIELD: USER_ROLE, @@ -312,23 +320,33 @@ fn format_messages_with_options( })); } - // Add "cache_control" to the last and second-to-last "user" messages. - // During each turn, we mark the final message with cache_control so the conversation can be - // incrementally cached. The second-to-last user message is also marked for caching with the - // cache_control parameter, so that this checkpoint can read from the previous cache. + // The volatile turn-context must sit after every cache breakpoint, or it invalidates the + // message-level cached prefix (Anthropic hashes tools -> system -> messages). Move it to the + // tail and place cache_control on the last non-turn-context block. + relocate_turn_context_to_tail(&mut anthropic_messages); + let mut user_count = 0; for message in anthropic_messages.iter_mut().rev() { - if message.get(ROLE_FIELD) == Some(&json!(USER_ROLE)) { - if let Some(content) = message.get_mut(CONTENT_FIELD) { - if let Some(content_array) = content.as_array_mut() { - if let Some(last_content) = content_array.last_mut() { - last_content.as_object_mut().unwrap().insert( - CACHE_CONTROL_FIELD.to_string(), - json!({ TYPE_FIELD: "ephemeral" }), - ); - } - } - } + if message.get(ROLE_FIELD) != Some(&json!(USER_ROLE)) { + continue; + } + let Some(content_array) = message + .get_mut(CONTENT_FIELD) + .and_then(|content| content.as_array_mut()) + else { + continue; + }; + let Some(target) = cache_control_target_index(content_array) else { + continue; + }; + if let Some(block) = content_array + .get_mut(target) + .and_then(|b| b.as_object_mut()) + { + block.insert( + CACHE_CONTROL_FIELD.to_string(), + json!({ TYPE_FIELD: "ephemeral" }), + ); user_count += 1; if user_count >= 2 { break; @@ -339,6 +357,52 @@ fn format_messages_with_options( anthropic_messages } +fn relocate_turn_context_to_tail(messages: &mut [Value]) { + let Some(last) = messages.len().checked_sub(1) else { + return; + }; + let source = messages.iter().enumerate().rev().find_map(|(mi, m)| { + m.get(CONTENT_FIELD) + .and_then(|c| c.as_array()) + .and_then(|a| a.iter().position(is_turn_context_block)) + .map(|bi| (mi, bi)) + }); + let Some((mi, bi)) = source else { + return; + }; + if mi != last + && messages[mi] + .get(CONTENT_FIELD) + .and_then(|c| c.as_array()) + .map_or(0, |a| a.len()) + <= 1 + { + return; + } + let block = messages[mi][CONTENT_FIELD] + .as_array_mut() + .unwrap() + .remove(bi); + messages[last][CONTENT_FIELD] + .as_array_mut() + .unwrap() + .push(block); +} + +fn cache_control_target_index(content_array: &[Value]) -> Option { + content_array + .iter() + .rposition(|block| !is_turn_context_block(block)) +} + +fn is_turn_context_block(block: &Value) -> bool { + block.get(TYPE_FIELD).and_then(Value::as_str) == Some(TEXT_TYPE) + && block + .get(TEXT_TYPE) + .and_then(Value::as_str) + .is_some_and(crate::conversation::is_turn_context_text) +} + fn anthropic_flavored_input_schema(input_schema: Arc) -> Arc { if input_schema.is_empty() { return Arc::new(json_object!({ @@ -516,6 +580,19 @@ pub fn get_usage(data: &Value) -> Result { } } +fn provider_usage_with_cost( + model: String, + usage: Usage, + data: &Value, + fallback_cost: Option, +) -> ProviderUsage { + let provider_usage = ProviderUsage::new(model, usage); + match super::openai::get_cost(data).or(fallback_cost) { + Some(cost) => provider_usage.with_cost(cost, CostSource::ProviderReported), + None => provider_usage, + } +} + pub fn thinking_effort(model_config: &ModelConfig) -> ThinkingEffort { model_config .thinking_effort() @@ -539,10 +616,6 @@ pub fn thinking_budget_tokens(model_config: &ModelConfig) -> i32 { return request_param.max(1024); } - if let Some(budget) = legacy_thinking_budget_tokens() { - return budget; - } - let effort = model_config .thinking_effort() .unwrap_or(ThinkingEffort::High); @@ -555,20 +628,11 @@ pub fn thinking_budget_tokens(model_config: &ModelConfig) -> i32 { } } -fn legacy_thinking_budget_tokens() -> Option { - let config = crate::config::Config::global(); - for key in ["ANTHROPIC_THINKING_BUDGET", "CLAUDE_THINKING_BUDGET"] { - if let Ok(budget) = config.get_param::(key) { - return Some(budget.max(1024)); - } - } - None -} - // Anthropic counts thinking tokens against max_tokens, so the budget must leave // room for a response. Clamp it to preserve at least this many answer tokens, and // drop thinking only when even a minimal budget wouldn't fit under the cap. -const MIN_ANSWER_TOKENS: i32 = 1024; +// Shared with the Bedrock formatter, which applies the same clamp. +pub const MIN_ANSWER_TOKENS: i32 = 1024; fn apply_thinking_config( payload: &mut Value, @@ -600,7 +664,7 @@ fn apply_thinking_config( ThinkingType::Disabled => {} } - if options.preserve_thinking_context { + if options.preserve_thinking_context && !options.thinking_disabled { if !obj.contains_key("thinking") { let budget_tokens = thinking_budget_tokens(model_config) .min(max_tokens.saturating_sub(MIN_ANSWER_TOKENS)); @@ -621,40 +685,7 @@ fn apply_thinking_config( } } -/// Create a complete request payload for Anthropic's API pub fn create_request( - model_config: &ModelConfig, - system: &str, - messages: &[Message], - tools: &[Tool], -) -> Result { - create_request_with_options( - model_config, - system, - messages, - tools, - AnthropicFormatOptions::default(), - ) -} - -pub fn create_request_with_options( - model_config: &ModelConfig, - system: &str, - messages: &[Message], - tools: &[Tool], - options: AnthropicFormatOptions, -) -> Result { - create_request_with_options_for_provider( - ANTHROPIC_PROVIDER_NAME, - model_config, - system, - messages, - tools, - options, - ) -} - -pub fn create_request_with_options_for_provider( provider_name: &str, model_config: &ModelConfig, system: &str, @@ -752,6 +783,7 @@ where let mut final_usage: Option = None; let mut message_id: Option = None; let mut thinking: Option = None; + let mut stop_reason: Option = None; while let Some(line_result) = stream.next().await { let line = line_result?; @@ -791,7 +823,7 @@ where .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); - final_usage = Some(ProviderUsage::new(model, usage)); + final_usage = Some(provider_usage_with_cost(model, usage, usage_data, None)); } } continue; @@ -878,18 +910,20 @@ where } } if let Some(tool_id) = current_tool_id.take() { - // Tool call finished, yield complete tool call if let Some((name, args)) = accumulated_tool_calls.remove(&tool_id) { let parsed_args = if args.is_empty() { json!({}) } else { - match serde_json::from_str::(&args) { - Ok(parsed) => parsed, - Err(_) => { - // If parsing fails, create an error tool request + match crate::json::parse_tool_arguments(&args) { + Some(parsed) => parsed, + None => { + let message_text = crate::json::truncation_error_message(&args) + .unwrap_or_else(|| { + format!("Could not parse tool arguments: {args}") + }); let error = ErrorData::new( ErrorCode::INVALID_PARAMS, - format!("Could not parse tool arguments: {}", args), + message_text, None, ); let mut message = Message::new( @@ -923,17 +957,27 @@ where if let Some(existing_usage) = &final_usage { let merged_usage = merge_delta_usage(&existing_usage.usage, &delta_usage, usage_data); - final_usage = Some(ProviderUsage::new(existing_usage.model.clone(), merged_usage)); + final_usage = Some(provider_usage_with_cost( + existing_usage.model.clone(), + merged_usage, + usage_data, + existing_usage.cost, + )); } else { let model = event.data.get("model") .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); - final_usage = Some(ProviderUsage::new(model, delta_usage)); + final_usage = Some(provider_usage_with_cost(model, delta_usage, usage_data, None)); } } if let Some(delta) = event.data.get("delta") { let stop_details = delta.get("stop_details").filter(|d| !d.is_null()); + if stop_reason.is_none() { + if let Some(sr) = delta.get("stop_reason").and_then(|v| v.as_str()) { + stop_reason = Some(sr.to_string()); + } + } if delta.get("stop_reason").and_then(|v| v.as_str()) == Some(STOP_REASON_REFUSAL) { let str_field = |key: &str| stop_details .and_then(|d| d.get(key)) @@ -968,7 +1012,8 @@ where .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); - final_usage = Some(ProviderUsage::new(model, usage)); + let fallback_cost = final_usage.as_ref().and_then(|u| u.cost); + final_usage = Some(provider_usage_with_cost(model, usage, usage_data, fallback_cost)); } break; } @@ -980,6 +1025,38 @@ where } } + // A tool_use block left open at stream end never received its + // content_block_stop, so its args are truncated rather than complete. + if !accumulated_tool_calls.is_empty() { + let truncated_by_limit = stop_reason.as_deref() == Some("max_tokens"); + let mut ids: Vec = accumulated_tool_calls.keys().cloned().collect(); + ids.sort(); + for id in ids { + if let Some((_name, args)) = accumulated_tool_calls.remove(&id) { + let guidance = if truncated_by_limit { + "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 { + "A tool call was not completed before the stream ended. \ + Try resending your message or breaking the task into smaller steps." + }; + let snippet_len = args.chars().count(); + let tail: String = args.chars().rev().take(80).collect::>().into_iter().rev().collect(); + let message_text = format!( + "{guidance}\nReceived {snippet_len} characters of arguments; cut off at: โ€ฆ{tail}" + ); + let error = ErrorData::new(ErrorCode::INVALID_PARAMS, message_text, None); + let mut message = Message::new( + Role::Assistant, + chrono::Utc::now().timestamp(), + vec![MessageContent::tool_request(id, Err(error))], + ); + message.id = message_id.clone(); + yield (Some(message), None); + } + } + } + if let Some(usage) = final_usage { yield (None, Some(usage)); } @@ -990,10 +1067,43 @@ where mod tests { use super::*; use crate::conversation::message::Message; - use goose_providers::model::ModelConfig; + use crate::model::ModelConfig; use rmcp::object; use serde_json::json; + /// Create a complete request payload for Anthropic's API + fn create_request_with_default_options( + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result { + create_request_with_options_provider( + model_config, + system, + messages, + tools, + AnthropicFormatOptions::default(), + ) + } + + fn create_request_with_options_provider( + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + options: AnthropicFormatOptions, + ) -> Result { + create_request( + ANTHROPIC_PROVIDER_NAME, + model_config, + system, + messages, + tools, + options, + ) + } + #[test] fn test_parse_text_response() -> Result<()> { let response = json!({ @@ -1155,6 +1265,7 @@ mod tests { AnthropicFormatOptions { preserve_unsigned_thinking: true, preserve_thinking_context: false, + thinking_disabled: false, }, ); @@ -1268,7 +1379,7 @@ mod tests { config.max_tokens = Some(4096); config.request_params = Some(params); let messages = vec![Message::user().with_text("Hello")]; - let payload = create_request(&config, "system", &messages, &[])?; + let payload = create_request_with_default_options(&config, "system", &messages, &[])?; assert_eq!(payload["thinking"]["type"], "adaptive"); assert_eq!(payload["output_config"]["effort"], "high"); @@ -1284,11 +1395,11 @@ mod tests { ("ANTHROPIC_PRESERVE_THINKING_CONTEXT", None::<&str>), ]); - let mut config = cfg_with_effort("claude-3-7-sonnet-20250219", "high"); + let mut config = cfg_with_effort("claude-sonnet-4-5-20250929", "high"); config.max_tokens = Some(64000); let messages = vec![Message::user().with_text("Hello")]; - let payload = create_request(&config, "system", &messages, &[])?; + let payload = create_request_with_default_options(&config, "system", &messages, &[])?; assert_eq!(payload["thinking"]["type"], "enabled"); let budget = payload["thinking"]["budget_tokens"].as_i64().unwrap(); @@ -1306,12 +1417,12 @@ mod tests { ("ANTHROPIC_PRESERVE_THINKING_CONTEXT", None::<&str>), ]); - let mut config = cfg_with_effort("claude-3-7-sonnet-20250219", "high"); + let mut config = cfg_with_effort("claude-sonnet-4-5-20250929", "high"); let messages = vec![Message::user().with_text("Hello")]; // Budget larger than max_tokens is clamped to leave room for a response. config.max_tokens = Some(4096); - let payload = create_request(&config, "system", &messages, &[])?; + let payload = create_request_with_default_options(&config, "system", &messages, &[])?; let budget = payload["thinking"]["budget_tokens"].as_i64().unwrap(); assert!(budget >= 1024); assert!(budget <= 4096 - 1024); @@ -1319,7 +1430,7 @@ mod tests { // Too small to fit any thinking alongside a response โ€” drop it. config.max_tokens = Some(1500); - let payload = create_request(&config, "system", &messages, &[])?; + let payload = create_request_with_default_options(&config, "system", &messages, &[])?; assert!(payload.get("thinking").is_none()); assert_eq!(payload["max_tokens"], 1500); @@ -1335,7 +1446,7 @@ mod tests { let config = cfg_with_effort("claude-sonnet-4-20250514", "off"); let messages = vec![Message::user().with_text("Hello")]; - let payload = create_request(&config, "system", &messages, &[])?; + let payload = create_request_with_default_options(&config, "system", &messages, &[])?; assert!(payload.get("thinking").is_none()); assert!(payload.get("output_config").is_none()); @@ -1346,10 +1457,7 @@ mod tests { #[test] fn test_create_request_preserves_thinking_context_for_compatible_models() -> Result<()> { let _guard = env_lock::lock_env([ - ("CLAUDE_THINKING_TYPE", None::<&str>), ("CLAUDE_THINKING_ENABLED", None::<&str>), - ("ANTHROPIC_THINKING_BUDGET", None::<&str>), - ("CLAUDE_THINKING_BUDGET", None::<&str>), ("ANTHROPIC_PRESERVE_THINKING_CONTEXT", None::<&str>), ("ANTHROPIC_PRESERVE_UNSIGNED_THINKING", None::<&str>), ]); @@ -1361,7 +1469,7 @@ mod tests { Message::user().with_text("Continue"), ]; - let payload = create_request_with_options( + let payload = create_request_with_options_provider( &config, "system", &messages, @@ -1369,6 +1477,7 @@ mod tests { AnthropicFormatOptions { preserve_unsigned_thinking: true, preserve_thinking_context: true, + thinking_disabled: false, }, )?; @@ -1388,10 +1497,7 @@ mod tests { #[test] fn test_create_request_model_params_enable_preserved_thinking_context() -> Result<()> { let _guard = env_lock::lock_env([ - ("CLAUDE_THINKING_TYPE", None::<&str>), ("CLAUDE_THINKING_ENABLED", None::<&str>), - ("ANTHROPIC_THINKING_BUDGET", None::<&str>), - ("CLAUDE_THINKING_BUDGET", None::<&str>), ("ANTHROPIC_PRESERVE_THINKING_CONTEXT", None::<&str>), ("ANTHROPIC_PRESERVE_UNSIGNED_THINKING", None::<&str>), ]); @@ -1407,7 +1513,7 @@ mod tests { Message::user().with_text("Continue"), ]; - let payload = create_request(&config, "system", &messages, &[])?; + let payload = create_request_with_default_options(&config, "system", &messages, &[])?; assert_eq!(payload["thinking"]["clear_thinking"], false); assert_eq!(payload["messages"][0]["content"][0]["type"], "thinking"); @@ -1549,6 +1655,42 @@ mod tests { assert!(!value.is_null()); } + #[test] + fn test_unparseable_tool_request_emits_placeholder_tool_use() { + use rmcp::model::{ErrorCode, ErrorData}; + + let err = ErrorData::new( + ErrorCode::INVALID_PARAMS, + "Tool arguments for id call_bad must be a JSON object".to_string(), + None, + ); + let mut response = Message::user(); + response.add_tool_response_with_metadata("call_bad", Err(err.clone()), None); + let messages = vec![ + Message::assistant().with_tool_request("call_bad", Err(err)), + response, + ]; + + let spec = format_messages(&messages); + + let mut open = std::collections::HashSet::new(); + for m in &spec { + for block in m["content"].as_array().into_iter().flatten() { + match block["type"].as_str() { + Some("tool_use") => { + open.insert(block["id"].as_str().unwrap().to_string()); + } + Some("tool_result") => { + let id = block["tool_use_id"].as_str().unwrap(); + assert!(open.contains(id), "orphan tool_result for id {id:?}"); + } + _ => {} + } + } + } + assert!(open.contains("call_bad")); + } + #[test] fn test_args_to_input_value_preserves_existing_args() { let args = object!({"query": "rust"}); @@ -1595,20 +1737,13 @@ mod tests { } fn cfg(name: &str) -> ModelConfig { - ModelConfig { - model_name: name.to_string(), - ..Default::default() - } + ModelConfig::new(name) } fn cfg_with_effort(name: &str, effort: &str) -> ModelConfig { let mut params = std::collections::HashMap::new(); params.insert("thinking_effort".to_string(), json!(effort)); - ModelConfig { - model_name: name.to_string(), - request_params: Some(params), - ..Default::default() - } + ModelConfig::new(name).with_merged_request_params(params) } #[test] @@ -1634,12 +1769,12 @@ mod tests { ); // Non-adaptive Claude with effort โ†’ enabled assert_eq!( - thinking_type(&cfg_with_effort("claude-3-7-sonnet-20250219", "high")), + thinking_type(&cfg_with_effort("claude-sonnet-4-5-20250929", "high")), ThinkingType::Enabled ); // Non-adaptive Claude with off โ†’ disabled assert_eq!( - thinking_type(&cfg_with_effort("claude-3-7-sonnet-20250219", "off")), + thinking_type(&cfg_with_effort("claude-sonnet-4-5-20250929", "off")), ThinkingType::Disabled ); } @@ -1670,7 +1805,7 @@ mod tests { config.temperature = Some(0.7); let messages = vec![Message::user().with_text("Hello")]; - let payload = create_request(&config, "system", &messages, &[])?; + let payload = create_request_with_default_options(&config, "system", &messages, &[])?; assert_eq!(payload["thinking"]["type"], "adaptive"); assert!(payload.get("temperature").is_none()); @@ -1679,17 +1814,6 @@ mod tests { Ok(()) } - #[test] - fn test_thinking_budget_uses_legacy_env() { - let _guard = env_lock::lock_env([ - ("GOOSE_THINKING_EFFORT", None::<&str>), - ("ANTHROPIC_THINKING_BUDGET", Some("8192")), - ("CLAUDE_THINKING_BUDGET", None::<&str>), - ]); - let config = cfg_with_effort("claude-3-7-sonnet-20250219", "high"); - assert_eq!(thinking_budget_tokens(&config), 8192); - } - #[test] fn test_thinking_type_non_claude_always_disabled() { assert_eq!( @@ -1709,7 +1833,7 @@ mod tests { ThinkingType::Disabled ); assert_eq!( - thinking_type(&cfg_with_effort("claude-3-7-sonnet-20250219", "off")), + thinking_type(&cfg_with_effort("claude-sonnet-4-5-20250929", "off")), ThinkingType::Disabled ); } @@ -1720,6 +1844,7 @@ mod tests { redacted_thinking: Vec, text: Vec, tool_calls: Vec, + tool_errors: Vec, } async fn collect_stream(events: &str) -> StreamedParts { @@ -1740,11 +1865,10 @@ mod tests { MessageContent::Text(t) => { parts.text.push(t.text.clone()); } - MessageContent::ToolRequest(req) => { - if let Ok(call) = &req.tool_call { - parts.tool_calls.push(call.name.to_string()); - } - } + MessageContent::ToolRequest(req) => match &req.tool_call { + Ok(call) => parts.tool_calls.push(call.name.to_string()), + Err(e) => parts.tool_errors.push(e.message.to_string()), + }, _ => {} } } @@ -1916,6 +2040,35 @@ mod tests { assert_eq!(usage.usage.cache_write_input_tokens, Some(10000)); } + #[tokio::test] + async fn test_streaming_preserves_provider_cost_from_delta() { + let events = concat!( + r#"data: {"type":"message_start","message":{"id":"m1","role":"assistant","content":[],"model":"glm-4.7","usage":{"input_tokens":100,"output_tokens":0}}}"#, + "\n", + r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, + "\n", + r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}"#, + "\n", + r#"data: {"type":"content_block_stop","index":0}"#, + "\n", + r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":50,"cost":0.0123}}"#, + "\n", + r#"data: {"type":"message_stop"}"#, + ); + + let usage = collect_stream_results(events) + .await + .into_iter() + .filter_map(|r| r.ok().and_then(|(_, usage)| usage)) + .next_back() + .expect("stream should yield usage"); + + assert_eq!(usage.cost, Some(0.0123)); + assert_eq!(usage.cost_source, Some(CostSource::ProviderReported)); + assert_eq!(usage.usage.input_tokens, Some(100)); + assert_eq!(usage.usage.output_tokens, Some(50)); + } + #[tokio::test] async fn test_streaming_delta_usage_is_cumulative_and_wins() { // Server tool use grows input during the turn: the final @@ -2034,4 +2187,359 @@ mod tests { ); assert!(parts.text[0].contains("context_window")); } + + #[tokio::test] + async fn test_streaming_truncated_tool_args_in_content_block_stop() { + // Block is closed by content_block_stop, but the concatenated deltas form + // truncated JSON (each fragment is valid; together they're unterminated). + let events = concat!( + r##"data: {"type":"message_start","message":{"id":"msg_t","role":"assistant","content":[],"model":"glm-4.7","usage":{"input_tokens":10,"output_tokens":0}}}"##, + "\n", + r##"data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tool_t","name":"write","input":{}}}"##, + "\n", + r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"/some/path.md\","}}"#, + "\n", + r##"data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"content\":\"# Very long markdown"}}"##, + "\n", + r#"data: {"type":"content_block_stop","index":0}"#, + "\n", + r#"data: {"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":4096}}"#, + "\n", + r#"data: {"type":"message_stop"}"#, + ); + + let parts = collect_stream(events).await; + assert_eq!( + parts.tool_errors.len(), + 1, + "expected one tool error, got: {:?}", + parts.tool_errors + ); + let msg = &parts.tool_errors[0]; + assert!( + msg.contains("truncated") || msg.contains("output token limit"), + "expected actionable truncation message, got: {}", + msg + ); + assert!( + msg.contains("max_tokens") || msg.contains("smaller steps"), + "expected guidance to increase max_tokens or break up the task, got: {}", + msg + ); + } + + #[tokio::test] + async fn test_streaming_truncated_tool_args_no_content_block_stop() { + // The stream ends with the tool_use block still open (no content_block_stop), + // which is what happens when the model is cut off mid-tool-call. + let events = concat!( + r##"data: {"type":"message_start","message":{"id":"msg_t2","role":"assistant","content":[],"model":"glm-4.7","usage":{"input_tokens":10,"output_tokens":0}}}"##, + "\n", + r##"data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tool_t2","name":"write","input":{}}}"##, + "\n", + r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"/report.md\","}}"#, + "\n", + r##"data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"content\":\"# Big report that got cut off mid"}}"##, + "\n", + r#"data: {"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":8192}}"#, + "\n", + r#"data: {"type":"message_stop"}"#, + ); + + let parts = collect_stream(events).await; + assert_eq!( + parts.tool_errors.len(), + 1, + "expected one tool error for the dropped/truncated tool call, got: {:?}", + parts.tool_errors + ); + let msg = &parts.tool_errors[0]; + assert!( + msg.contains("truncated") || msg.contains("output token limit"), + "expected actionable truncation message, got: {}", + msg + ); + } + + #[tokio::test] + async fn test_streaming_complete_tool_call_unaffected() { + // Regression guard: a normal, complete tool call must still parse and + // produce no error even though stop_reason handling is added. + let events = concat!( + r#"data: {"type":"message_start","message":{"id":"msg_ok","role":"assistant","content":[],"model":"glm-4.7","usage":{"input_tokens":10,"output_tokens":0}}}"#, + "\n", + r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tool_ok","name":"write","input":{}}}"#, + "\n", + r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"/ok.md\",\"content\":\"hello\"}"}}"#, + "\n", + r#"data: {"type":"content_block_stop","index":0}"#, + "\n", + r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":15}}"#, + "\n", + r#"data: {"type":"message_stop"}"#, + ); + + let parts = collect_stream(events).await; + assert_eq!(parts.tool_calls, vec!["write"]); + assert!(parts.tool_errors.is_empty()); + } + + /// Anthropic prefix caching only pays off when the bytes up to a cache + /// breakpoint are identical turn over turn. The per-turn turn-context block + /// (timestamp, turn budget, compaction state) changes on every call, so if + /// it ever lands inside a cached prefix every request becomes a cache write + /// instead of a read. These tests pin the property that keeps caching alive + /// so a future refactor of the formatter or the turn-context format can't + /// silently regress it. + mod cache_prefix_stability { + use super::*; + use rmcp::model::CallToolResult; + + /// A turn-context block whose shape matches what `is_turn_context_text` + /// recognizes, varying only the volatile fields. + fn turn_context(time: &str, turn_budget: &str) -> String { + format!( + "\n\ + {time}\n\ + /Users/me/code/goose\n\ + {turn_budget}\n\ + " + ) + } + + fn sample_tools() -> Vec { + vec![ + Tool::new( + "read_file", + "Read a file from disk", + object!({ + "type": "object", + "properties": { "path": { "type": "string" } } + }), + ), + Tool::new( + "write_file", + "Write a file to disk", + object!({ + "type": "object", + "properties": { "path": { "type": "string" }, "content": { "type": "string" } } + }), + ), + ] + } + + /// A realistic multi-turn conversation. `inject_moim` prepends the + /// turn-context block to the latest genuine user message, so it sits as + /// the first text block of the final user message here. + fn conversation(turn_context_block: &str) -> Vec { + vec![ + Message::user().with_text("What does the main entrypoint do?"), + Message::assistant().with_tool_request( + "tool_1", + Ok(CallToolRequestParams::new("read_file") + .with_arguments(object!({"path": "src/main.rs"}))), + ), + Message::user().with_tool_response( + "tool_1", + Ok(CallToolResult::success(vec![rmcp::model::Content::text( + "fn main() { run(); }", + )])), + ), + Message::assistant().with_text("It calls `run()`."), + Message::user() + .with_text(turn_context_block) + .with_text("Now add error handling to it."), + ] + } + + /// The (message index, block index) of the last block carrying a + /// `cache_control` marker, scanning in canonical order. This is the far + /// edge of the furthest cached prefix. + fn last_breakpoint(messages: &[Value]) -> Option<(usize, usize)> { + let mut found = None; + for (mi, message) in messages.iter().enumerate() { + for (bi, block) in message["content"].as_array().unwrap().iter().enumerate() { + if block.get(CACHE_CONTROL_FIELD).is_some() { + found = Some((mi, bi)); + } + } + } + found + } + + fn find_turn_context(messages: &[Value]) -> Option<(usize, usize)> { + messages.iter().enumerate().find_map(|(mi, message)| { + message["content"] + .as_array() + .unwrap() + .iter() + .position(is_turn_context_block) + .map(|bi| (mi, bi)) + }) + } + + /// The exact bytes Anthropic hashes for its furthest cache breakpoint: + /// tools, then system, then messages truncated at the last + /// `cache_control` marker. Everything after that point is outside every + /// cached prefix and may change freely turn to turn. + fn cached_prefix(payload: &Value) -> String { + let messages = payload["messages"].as_array().unwrap(); + let (last_mi, last_bi) = last_breakpoint(messages) + .expect("request must carry at least one cache_control breakpoint"); + + let prefix_messages: Vec = messages + .iter() + .take(last_mi + 1) + .enumerate() + .map(|(mi, message)| { + let mut message = message.clone(); + if mi == last_mi { + message["content"] + .as_array_mut() + .unwrap() + .truncate(last_bi + 1); + } + message + }) + .collect(); + + json!({ + "tools": payload.get("tools"), + "system": payload.get("system"), + "messages": prefix_messages, + }) + .to_string() + } + + /// The production tool-loop case: `inject_moim` prepends turn-context to + /// the latest *genuine* user message, but the request then ends with a + /// later `tool_result` message. The block must be relocated *across* + /// messages to land after the trailing breakpoint, not merely reordered + /// within its own message. + fn tool_loop_conversation(turn_context_block: &str) -> Vec { + vec![ + Message::user().with_text("What does the main entrypoint do?"), + Message::assistant().with_text("Let me read it."), + Message::user() + .with_text(turn_context_block) + .with_text("Now add error handling to it."), + Message::assistant().with_tool_request( + "tool_1", + Ok(CallToolRequestParams::new("read_file") + .with_arguments(object!({"path": "src/main.rs"}))), + ), + Message::user().with_tool_response( + "tool_1", + Ok(CallToolResult::success(vec![rmcp::model::Content::text( + "fn main() { run(); }", + )])), + ), + ] + } + + fn request_with(messages: &[Message]) -> Value { + create_request_with_default_options( + &cfg("claude-sonnet-4-5"), + "You are a careful coding assistant.", + messages, + &sample_tools(), + ) + .unwrap() + } + + fn request(turn_context_block: &str) -> Value { + request_with(&conversation(turn_context_block)) + } + + #[test] + fn cached_prefix_is_invariant_to_turn_context_changes() { + let req_a = request(&turn_context("2026-06-25 12:00:00", "14/40 used")); + let req_b = request(&turn_context("2026-06-25 13:47:00", "31/40 used")); + + assert_ne!( + req_a.to_string(), + req_b.to_string(), + "test setup is vacuous: the two requests are byte-identical, so the \ + turn-context never reached the request body" + ); + + assert_eq!( + cached_prefix(&req_a), + cached_prefix(&req_b), + "the cached prefix changed when only the volatile turn-context changed; \ + prefix caching will collapse into a per-turn cache write" + ); + + assert!( + !cached_prefix(&req_a).contains("12:00:00"), + "the volatile turn-context timestamp leaked into the cached prefix" + ); + } + + #[test] + fn turn_context_sits_after_every_cache_breakpoint() { + let req = request(&turn_context("2026-06-25 12:00:00", "14/40 used")); + let messages = req["messages"].as_array().unwrap(); + + for message in messages { + for block in message["content"].as_array().unwrap() { + if block.get(CACHE_CONTROL_FIELD).is_some() { + assert!( + !is_turn_context_block(block), + "a cache_control breakpoint landed on the volatile turn-context block" + ); + } + } + } + + let breakpoint = last_breakpoint(messages).expect("a breakpoint should exist"); + let turn_context = find_turn_context(messages) + .expect("the turn-context block should survive into the formatted request"); + assert!( + turn_context > breakpoint, + "turn-context at {turn_context:?} is not after the last cache breakpoint at \ + {breakpoint:?}, so it sits inside a cached prefix" + ); + } + + /// Guards the tool-loop path: turn-context is injected onto an earlier + /// genuine user message while the request ends with a `tool_result`, so + /// keeping it out of the cached prefix requires relocating it across + /// messages. A regression that only reorders within a message would + /// pass the tests above but fail here. + #[test] + fn cached_prefix_is_invariant_in_tool_loop() { + let req_a = request_with(&tool_loop_conversation(&turn_context( + "2026-06-25 12:00:00", + "14/40", + ))); + let req_b = request_with(&tool_loop_conversation(&turn_context( + "2026-06-25 13:47:00", + "31/40", + ))); + + assert_ne!( + req_a.to_string(), + req_b.to_string(), + "test setup is vacuous: turn-context never reached the request body" + ); + + assert_eq!( + cached_prefix(&req_a), + cached_prefix(&req_b), + "the cached prefix changed when only the volatile turn-context changed during a \ + tool loop; the block was not relocated past the trailing tool_result breakpoint" + ); + + let messages = req_a["messages"].as_array().unwrap(); + let breakpoint = last_breakpoint(messages).expect("a breakpoint should exist"); + let turn_context = find_turn_context(messages) + .expect("the turn-context block should survive into the formatted request"); + assert!( + turn_context > breakpoint, + "turn-context at {turn_context:?} was not relocated across messages to after the \ + last breakpoint at {breakpoint:?}" + ); + } + } } diff --git a/crates/goose/src/providers/formats/databricks.rs b/crates/goose-provider-types/src/formats/databricks.rs similarity index 89% rename from crates/goose/src/providers/formats/databricks.rs rename to crates/goose-provider-types/src/formats/databricks.rs index b86542c87e..cbf0d2b9fc 100644 --- a/crates/goose/src/providers/formats/databricks.rs +++ b/crates/goose-provider-types/src/formats/databricks.rs @@ -1,17 +1,16 @@ use crate::conversation::message::{Message, MessageContent}; -use crate::providers::formats::anthropic::{ +use crate::formats::anthropic::{ adaptive_output_effort, model_supports_temperature, thinking_budget_tokens, thinking_type_for_provider, ThinkingType, }; -use goose_providers::model::ModelConfig; +use crate::model::ModelConfig; -use anyhow::{anyhow, Error}; -use goose_providers::formats::openai::{ +use crate::formats::openai::{ extract_reasoning_effort, is_openai_responses_model, is_valid_function_name, openai_reasoning_effort_for_thinking, sanitize_function_name, }; -use goose_providers::images::{convert_image, detect_image_path, load_image_file, ImageFormat}; -use goose_providers::json::safely_parse_json; +use crate::images::{convert_image, detect_image_path, load_image_file, ImageFormat}; +use anyhow::{anyhow, Error}; use rmcp::model::{ object, AnnotateAble, CallToolRequestParams, Content, ErrorCode, ErrorData, RawContent, ResourceContents, Role, Tool, @@ -20,7 +19,7 @@ use serde::Serialize; use serde_json::{json, Value}; use std::borrow::Cow; -pub(crate) const DATABRICKS_PROVIDER_NAME: &str = "databricks"; +pub const DATABRICKS_PROVIDER_NAME: &str = "databricks"; #[derive(Serialize)] struct DatabricksMessage { @@ -35,7 +34,7 @@ struct DatabricksMessage { fn format_text_content(text: &str, image_format: &ImageFormat) -> (Vec, bool) { let mut items = vec![json!({"type": "text", "text": text})]; let has_image = if let Some(path) = detect_image_path(text) { - if let Ok(image) = load_image_file(path) { + if let Ok(image) = load_image_file(path.as_ref()) { items.push(convert_image(&image, image_format)); } true @@ -190,9 +189,21 @@ fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec { - content_array - .push(json!({"type": "text", "text": format!("Error: {}", e)})); + Err(_e) => { + // Mirror the OpenAI formatter: emitting the error as assistant + // text leaves no `tool_calls` entry, so the paired tool response + // orphans (a `role:"tool"` with no preceding assistant + // `tool_calls`) and strict APIs reject it. Emit a placeholder + // call with the same id; the error rides on the tool response. + let tool_calls = converted.tool_calls.get_or_insert_default(); + tool_calls.push(json!({ + "id": request.id, + "type": "function", + "function": { + "name": "unparseable_tool_call", + "arguments": "{}", + } + })); } } } @@ -292,12 +303,10 @@ fn apply_claude_thinking_config( } } -pub fn format_tools(tools: &[Tool], model_name: &str) -> anyhow::Result> { +pub fn format_tools(tools: &[Tool], _model_name: &str) -> anyhow::Result> { let mut tool_names = std::collections::HashSet::new(); let mut result = Vec::new(); - let is_gemini = model_name.contains("gemini"); - for tool in tools { if !tool_names.insert(&tool.name) { return Err(anyhow!("Duplicate tool name: {}", tool.name)); @@ -309,25 +318,17 @@ pub fn format_tools(tools: &[Tool], model_name: &str) -> anyhow::Result anyhow::Result { }; content.push(MessageContent::tool_request(id, Err(error))); } else { - match safely_parse_json(&arguments_str) { - Ok(params) => { + match crate::json::parse_tool_arguments(&arguments_str) { + Some(params) if params.is_object() => { content.push(MessageContent::tool_request( id, Ok(CallToolRequestParams::new(function_name) .with_arguments(object(params))), )); } - Err(e) => { + // Valid JSON but NOT an object (a bare array/string/number). + // Surface a tool error so the model retries instead of + // crashing the run (rmcp's `object()` debug-asserts). + Some(_) => { let error = ErrorData { code: ErrorCode::INVALID_PARAMS, message: Cow::from(format!( - "Could not interpret tool use parameters for id {}: {}. Raw arguments: '{}'", - id, e, arguments_str + "Tool arguments for {} (id {}) must be a JSON object. Raw arguments: '{}'", + function_name, id, arguments_str )), data: None, }; content.push(MessageContent::tool_request(id, Err(error))); } + None => { + let message_text = + crate::json::truncation_error_message(&arguments_str) + .unwrap_or_else(|| { + format!( + "Could not interpret tool use parameters for id {id}" + ) + }); + let error = ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: Cow::from(message_text), + data: None, + }; + content.push(MessageContent::tool_request(id, Err(error))); + } } } } @@ -772,19 +791,17 @@ mod tests { "http://json-schema.org/draft-07/schema#" ); + // Databricks Gemini endpoints use OpenAI-compatible format, so tools use + // "parameters" (not "parametersJsonSchema") regardless of model family. let spec = format_tools(std::slice::from_ref(&tool), "gemini-2-5-flash")?; - assert!(spec[0]["function"].get("parametersJsonSchema").is_some()); - assert_eq!( - spec[0]["function"]["parametersJsonSchema"]["type"], - "object" - ); + assert!(spec[0]["function"].get("parametersJsonSchema").is_none()); + assert!(spec[0]["function"].get("parameters").is_some()); + assert_eq!(spec[0]["function"]["parameters"]["type"], "object"); let spec = format_tools(&[tool], "databricks-gemini-3-pro")?; - assert!(spec[0]["function"].get("parametersJsonSchema").is_some()); - assert_eq!( - spec[0]["function"]["parametersJsonSchema"]["type"], - "object" - ); + assert!(spec[0]["function"].get("parametersJsonSchema").is_none()); + assert!(spec[0]["function"].get("parameters").is_some()); + assert_eq!(spec[0]["function"]["parameters"]["type"], "object"); Ok(()) } @@ -1025,7 +1042,7 @@ mod tests { message: msg, data: None, }) => { - assert!(msg.starts_with("Could not interpret tool use parameters")); + assert!(msg.contains("tool arguments") || msg.contains("truncated")); } _ => panic!("Expected InvalidParameters error"), } @@ -1036,6 +1053,39 @@ mod tests { Ok(()) } + #[test] + fn test_response_to_message_non_object_arguments() -> anyhow::Result<()> { + // Weaker models sometimes emit tool arguments that are valid JSON but + // not an object (here, a bare array). This must surface as a tool error, + // NOT panic via rmcp's `object()` debug-assert. + let mut response: Value = serde_json::from_str(OPENAI_TOOL_USE_RESPONSE)?; + response["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"] = + json!("[1, 2, 3]"); + + let message = response_to_message(&response)?; + + if let MessageContent::ToolRequest(request) = &message.content[0] { + match &request.tool_call { + Err(ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: msg, + data: None, + }) => { + assert!(msg.contains("must be a JSON object")); + assert!( + msg.contains("example_fn"), + "error must name the original tool so the model can retry it: {msg}" + ); + } + _ => panic!("Expected InvalidParameters error for non-object args"), + } + } else { + panic!("Expected ToolRequest content"); + } + + Ok(()) + } + #[test] fn test_response_to_message_empty_argument() -> anyhow::Result<()> { let mut response: Value = serde_json::from_str(OPENAI_TOOL_USE_RESPONSE)?; @@ -1065,7 +1115,6 @@ mod tests { max_tokens: Some(1024), toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1100,7 +1149,6 @@ mod tests { max_tokens: Some(1024), toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: Some(params), reasoning: None, }; @@ -1120,7 +1168,6 @@ mod tests { max_tokens: Some(1024), toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: Some(params), reasoning: None, }; @@ -1141,7 +1188,6 @@ mod tests { max_tokens: Some(1024), toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: Some(params), reasoning: None, }; @@ -1160,7 +1206,6 @@ mod tests { max_tokens: Some(1024), toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1179,7 +1224,6 @@ mod tests { max_tokens: Some(1024), toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1198,7 +1242,6 @@ mod tests { max_tokens: Some(1024), toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1210,7 +1253,7 @@ mod tests { #[test] fn test_create_request_adaptive_thinking_for_46_models() -> anyhow::Result<()> { - let mut model_config = ModelConfig::new_or_fail("databricks-claude-opus-4-6"); + let mut model_config = ModelConfig::new("databricks-claude-opus-4-6"); model_config.max_tokens = Some(4096); let mut params = std::collections::HashMap::new(); params.insert("thinking_effort".to_string(), serde_json::json!("low")); @@ -1237,7 +1280,7 @@ mod tests { "databricks-claude-fable-5", "global.anthropic.claude-fable-5", ] { - let mut model_config = ModelConfig::new_or_fail(name); + let mut model_config = ModelConfig::new(name); model_config.max_tokens = Some(4096); let mut params = std::collections::HashMap::new(); params.insert("thinking_effort".to_string(), serde_json::json!("high")); @@ -1258,7 +1301,7 @@ mod tests { fn test_create_request_always_on_adaptive_off_effort_falls_back_to_high() -> anyhow::Result<()> { let _guard = env_lock::lock_env([("GOOSE_THINKING_EFFORT", None::<&str>)]); - let mut model_config = ModelConfig::new_or_fail("databricks-claude-fable-5"); + let mut model_config = ModelConfig::new("databricks-claude-fable-5"); model_config.max_tokens = Some(4096); let mut params = std::collections::HashMap::new(); params.insert("thinking_effort".to_string(), serde_json::json!("off")); @@ -1274,7 +1317,7 @@ mod tests { #[test] fn test_create_request_enabled_thinking_with_budget() -> anyhow::Result<()> { - let mut model_config = ModelConfig::new_or_fail("databricks-claude-3-7-sonnet"); + let mut model_config = ModelConfig::new("databricks-claude-sonnet-4.5"); model_config.max_tokens = Some(4096); let mut params = std::collections::HashMap::new(); params.insert("thinking_effort".to_string(), serde_json::json!("high")); @@ -1299,7 +1342,7 @@ mod tests { ("high", 16000), ("max", 32000), ] { - let mut model_config = ModelConfig::new_or_fail("databricks-claude-3-7-sonnet"); + let mut model_config = ModelConfig::new("databricks-claude-sonnet-4.5"); model_config.max_tokens = Some(4096); let mut params = std::collections::HashMap::new(); params.insert("thinking_effort".to_string(), serde_json::json!(effort)); @@ -1318,7 +1361,7 @@ mod tests { #[test] fn test_response_to_message_claude_thinking() -> anyhow::Result<()> { let response = json!({ - "model": "us.anthropic.claude-3-7-sonnet-20250219-v1:0", + "model": "us.anthropic.claude-sonnet-4-5-20250929-v1:0", "choices": [{ "message": { "role": "assistant", @@ -1366,7 +1409,7 @@ mod tests { #[test] fn test_response_to_message_claude_encrypted_thinking() -> anyhow::Result<()> { let response = json!({ - "model": "claude-3-7-sonnet-20250219", + "model": "claude-sonnet-4-5-20250929", "choices": [{ "message": { "role": "assistant", @@ -1433,6 +1476,51 @@ mod tests { Ok(()) } + #[test] + fn format_messages_post_parse_error_history_is_wellformed() -> anyhow::Result<()> { + // An unparseable tool call (ToolRequest(Err)) paired with its error tool + // response must not serialize as an orphan role:"tool" message. + use rmcp::model::{ErrorCode, ErrorData}; + let err = ErrorData::new( + ErrorCode::INVALID_PARAMS, + "Tool arguments for id call_bad must be a JSON object".to_string(), + None, + ); + let request_msg = Message::assistant().with_tool_request("call_bad", Err(err.clone())); + let mut final_resp = Message::user(); + final_resp.add_tool_response_with_metadata("call_bad", Err(err), None); + let messages = vec![ + Message::user().with_text("do the thing"), + request_msg, + final_resp, + ]; + + let spec = serde_json::to_value(format_messages(&messages, &ImageFormat::OpenAi))?; + let mut open = std::collections::HashSet::new(); + for m in spec.as_array().unwrap() { + match m.get("role").and_then(|v| v.as_str()) { + Some("assistant") => { + for tc in m + .get("tool_calls") + .and_then(|v| v.as_array()) + .into_iter() + .flatten() + { + if let Some(id) = tc.get("id").and_then(|v| v.as_str()) { + open.insert(id.to_string()); + } + } + } + Some("tool") => { + let id = m.get("tool_call_id").and_then(|v| v.as_str()).unwrap_or(""); + assert!(open.contains(id), "orphan role:tool message for id {id:?}"); + } + _ => {} + } + } + Ok(()) + } + #[test] fn test_format_messages_tool_request_with_some_arguments() -> anyhow::Result<()> { // Test that tool calls with Some arguments are properly JSON-serialized @@ -1466,7 +1554,7 @@ mod tests { #[test] fn test_is_claude_model() { assert!(is_claude_model("databricks-claude-sonnet-4")); - assert!(is_claude_model("databricks-claude-3-7-sonnet")); + assert!(is_claude_model("databricks-claude-sonnet-4.5")); assert!(is_claude_model("claude-sonnet-4")); assert!(is_claude_model("goose-claude-sonnet")); assert!(!is_claude_model("gpt-4o")); @@ -1643,7 +1731,6 @@ mod tests { max_tokens: Some(8192), toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1696,7 +1783,6 @@ mod tests { max_tokens: Some(4096), toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; diff --git a/crates/goose/src/providers/formats/google.rs b/crates/goose-provider-types/src/formats/google.rs similarity index 93% rename from crates/goose/src/providers/formats/google.rs rename to crates/goose-provider-types/src/formats/google.rs index cf4d120cac..8d9d5e913a 100644 --- a/crates/goose/src/providers/formats/google.rs +++ b/crates/goose-provider-types/src/formats/google.rs @@ -1,9 +1,9 @@ +use crate::conversation::token_usage::{ProviderUsage, Usage}; +use crate::errors::ProviderError; +use crate::formats::openai::{is_valid_function_name, sanitize_function_name}; +use crate::model::ModelConfig; +use crate::thinking::ThinkingEffort; use anyhow::Result; -use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; -use goose_providers::errors::ProviderError; -use goose_providers::formats::openai::{is_valid_function_name, sanitize_function_name}; -use goose_providers::model::ModelConfig; -use goose_providers::thinking::ThinkingEffort; use rmcp::model::{ object, AnnotateAble, CallToolRequestParams, ErrorCode, ErrorData, RawContent, Role, Tool, }; @@ -17,7 +17,7 @@ use std::ops::Deref; pub const THOUGHT_SIGNATURE_KEY: &str = "thoughtSignature"; const SYNTHETIC_THOUGHT_SIGNATURE: &str = "skip_thought_signature_validator"; -const GEMINI25_DEFAULT_THINKING_BUDGET: i32 = 8192; +const DEFAULT_THINKING_BUDGET: i32 = 8192; pub fn metadata_with_signature(signature: &str) -> ProviderMetadata { let mut map = ProviderMetadata::new(); @@ -537,7 +537,28 @@ struct GoogleRequest<'a> { generation_config: Option, } -fn get_thinking_config(model_config: &ModelConfig) -> Option { +fn get_thinking_config( + model_config: &ModelConfig, + thinking_budget: Option, +) -> Option { + if model_config.reasoning == Some(false) + || model_config.thinking_effort() == Some(ThinkingEffort::Off) + { + // Gemini 2.5 Flash defaults to dynamic thinking; only an explicit budget + // of 0 turns it off. Other families can't be disabled, so leave them unset. + if model_config + .model_name + .to_lowercase() + .starts_with("gemini-2.5-flash") + { + return Some(ThinkingConfig { + thinking_level: None, + thinking_budget: Some(0), + include_thoughts: false, + }); + } + return None; + } let model_name = model_config.model_name.to_lowercase(); let is_gemini_3 = model_name.starts_with("gemini-3"); let is_gemini_25 = model_name.starts_with("gemini-2.5"); @@ -567,22 +588,19 @@ fn get_thinking_config(model_config: &ModelConfig) -> Option { } else { let thinking_budget = match model_config .request_param::("thinking_budget") - .or_else(|| { - crate::config::Config::global() - .get_param("GEMINI25_THINKING_BUDGET") - .ok() - }) { + .or(thinking_budget) + { Some(budget) if budget >= 0 => budget, Some(budget) => { tracing::warn!( "Invalid thinking budget '{}' for model '{}'. Must be >= 0. Using '{}'.", budget, model_config.model_name, - GEMINI25_DEFAULT_THINKING_BUDGET, + DEFAULT_THINKING_BUDGET, ); - GEMINI25_DEFAULT_THINKING_BUDGET + DEFAULT_THINKING_BUDGET } - None => GEMINI25_DEFAULT_THINKING_BUDGET, + None => DEFAULT_THINKING_BUDGET, }; Some(ThinkingConfig { thinking_level: None, @@ -597,6 +615,26 @@ pub fn create_request( system: &str, messages: &[Message], tools: &[Tool], +) -> Result { + create_request_impl(model_config, system, messages, tools, None) +} + +pub fn create_request_with_thinking_budget( + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + thinking_budget: Option, +) -> Result { + create_request_impl(model_config, system, messages, tools, thinking_budget) +} + +fn create_request_impl( + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + thinking_budget: Option, ) -> Result { let tools_wrapper = if tools.is_empty() { None @@ -606,7 +644,7 @@ pub fn create_request( }) }; - let thinking_config = get_thinking_config(model_config); + let thinking_config = get_thinking_config(model_config, thinking_budget); let generation_config = Some(GenerationConfig { temperature: model_config.temperature.map(|t| t as f64), @@ -1397,16 +1435,29 @@ data: [DONE]"#; assert!(schema.get("$defs").is_some()); } + #[test] + fn test_get_thinking_config_disabled_reasoning() { + use crate::model::ModelConfig; + + let config = ModelConfig::new("gemini-2.5-flash").with_thinking_effort(ThinkingEffort::Off); + let thinking_config = get_thinking_config(&config, None).unwrap(); + assert_eq!(thinking_config.thinking_budget, Some(0)); + assert!(!thinking_config.include_thoughts); + + let config = ModelConfig::new("gemini-2.5-pro").with_thinking_effort(ThinkingEffort::Off); + assert!(get_thinking_config(&config, None).is_none()); + } + #[test] fn test_get_thinking_config() { - use goose_providers::model::ModelConfig; + use crate::model::ModelConfig; // Test 1: Gemini 3 model with low thinking effort let mut params = std::collections::HashMap::new(); params.insert("thinking_effort".to_string(), serde_json::json!("low")); - let mut config = ModelConfig::new("gemini-3-pro").unwrap(); + let mut config = ModelConfig::new("gemini-3-pro"); config.request_params = Some(params); - let result = get_thinking_config(&config); + let result = get_thinking_config(&config, None); assert!(result.is_some()); let thinking_config = result.unwrap(); assert!(thinking_config.thinking_level.is_some()); @@ -1416,9 +1467,9 @@ data: [DONE]"#; // Test 2: Gemini 3 model with high thinking effort let mut params = std::collections::HashMap::new(); params.insert("thinking_effort".to_string(), serde_json::json!("high")); - let mut config = ModelConfig::new("Gemini-3-Flash").unwrap(); + let mut config = ModelConfig::new("Gemini-3-Flash"); config.request_params = Some(params); - let result = get_thinking_config(&config); + let result = get_thinking_config(&config, None); assert!(result.is_some()); let thinking_config = result.unwrap(); assert!(matches!( @@ -1426,46 +1477,42 @@ data: [DONE]"#; Some(ThinkingLevel::High) )); - let config = ModelConfig::new("gemini-2.5-flash").unwrap(); - let result = get_thinking_config(&config); + let config = ModelConfig::new("gemini-2.5-flash"); + let result = get_thinking_config(&config, None); assert!(result.is_some()); let thinking_config = result.unwrap(); assert!(thinking_config.include_thoughts); assert!(thinking_config.thinking_level.is_none()); assert_eq!( thinking_config.thinking_budget, - Some(GEMINI25_DEFAULT_THINKING_BUDGET) + Some(DEFAULT_THINKING_BUDGET) ); let mut params = HashMap::new(); params.insert("thinking_budget".to_string(), json!(4096)); - let config = ModelConfig::new("gemini-2.5-flash") - .unwrap() - .with_merged_request_params(params); - let result = get_thinking_config(&config); + let config = ModelConfig::new("gemini-2.5-flash").with_merged_request_params(params); + let result = get_thinking_config(&config, None); assert!(result.is_some()); let thinking_config = result.unwrap(); assert_eq!(thinking_config.thinking_budget, Some(4096)); let mut params = HashMap::new(); params.insert("thinking_budget".to_string(), json!(-1)); - let config = ModelConfig::new("gemini-2.5-flash") - .unwrap() - .with_merged_request_params(params); - let result = get_thinking_config(&config); + let config = ModelConfig::new("gemini-2.5-flash").with_merged_request_params(params); + let result = get_thinking_config(&config, None); assert!(result.is_some()); let thinking_config = result.unwrap(); assert_eq!( thinking_config.thinking_budget, - Some(GEMINI25_DEFAULT_THINKING_BUDGET) + Some(DEFAULT_THINKING_BUDGET) ); - let config = ModelConfig::new("gemini-2.0-flash").unwrap(); - let result = get_thinking_config(&config); + let config = ModelConfig::new("gemini-2.0-flash"); + let result = get_thinking_config(&config, None); assert!(result.is_none()); - let config = ModelConfig::new("gpt-4o").unwrap(); - let result = get_thinking_config(&config); + let config = ModelConfig::new("gpt-4o"); + let result = get_thinking_config(&config, None); assert!(result.is_none()); } } diff --git a/crates/goose/src/providers/formats/ollama.rs b/crates/goose-provider-types/src/formats/ollama.rs similarity index 99% rename from crates/goose/src/providers/formats/ollama.rs rename to crates/goose-provider-types/src/formats/ollama.rs index 77e4634493..b8d8ebbdc9 100644 --- a/crates/goose/src/providers/formats/ollama.rs +++ b/crates/goose-provider-types/src/formats/ollama.rs @@ -10,20 +10,20 @@ //! - qwen3-coder-32b use crate::conversation::message::{Message, MessageContent}; -use async_stream::try_stream; -use chrono; -use futures::Stream; -use goose_providers::{ +use crate::{ conversation::token_usage::ProviderUsage, formats::openai::{self, is_valid_function_name}, }; +use async_stream::try_stream; +use chrono; +use futures::Stream; use regex::Regex; use rmcp::model::{object, CallToolRequestParams, ErrorCode, ErrorData, Role}; use serde_json::Value; use std::borrow::Cow; use uuid::Uuid; -pub use goose_providers::formats::openai::{ +pub use crate::formats::openai::{ create_request, format_messages, format_tools, get_usage, validate_tool_schemas, }; diff --git a/crates/goose-providers/src/formats/openai.rs b/crates/goose-provider-types/src/formats/openai.rs similarity index 82% rename from crates/goose-providers/src/formats/openai.rs rename to crates/goose-provider-types/src/formats/openai.rs index a824e56a66..5069f73801 100644 --- a/crates/goose-providers/src/formats/openai.rs +++ b/crates/goose-provider-types/src/formats/openai.rs @@ -1,8 +1,8 @@ use crate::conversation::message::{Message, MessageContent, ProviderMetadata}; -use crate::conversation::token_usage::{ProviderUsage, Usage}; +use crate::conversation::token_usage::{CostSource, ProviderUsage, Usage}; use crate::errors::ProviderError; use crate::images::{convert_image, detect_image_path, load_image_file, ImageFormat}; -use crate::json::safely_parse_json; +use crate::json::{parse_tool_arguments, truncation_error_message}; use crate::mcp_utils::extract_text_from_resource; use crate::model::ModelConfig; use crate::thinking::{ @@ -41,6 +41,17 @@ where Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) } +fn describe_json_value(value: &Value) -> &'static str { + match value { + Value::Array(_) => "an array", + Value::String(_) => "a string", + Value::Number(_) => "a number", + Value::Bool(_) => "a boolean", + Value::Null => "null", + Value::Object(_) => "an object", + } +} + fn is_reserved_request_param_key(key: &str) -> bool { matches!(key, "messages" | "model" | "stream" | "stream_options") } @@ -182,12 +193,37 @@ pub fn format_messages_with_options( ) -> Vec { let mut messages_spec = Vec::new(); let mut pending_assistant_reasoning = String::new(); + // Reasoning to propagate across consecutive tool-call messages in the same turn. + // DeepSeek/Kimi require reasoning_content on every assistant tool-call message. + let mut tool_call_turn_reasoning = String::new(); + let mut saw_tool_response = false; for message in messages { if options.preserve_thinking_context && message.role != Role::Assistant { pending_assistant_reasoning.clear(); } + if options.preserve_thinking_context && message.role == Role::User { + if message + .content + .iter() + .any(|c| matches!(c, MessageContent::ToolResponse(_))) + { + saw_tool_response = true; + } else { + tool_call_turn_reasoning.clear(); + saw_tool_response = false; + } + } + + // A new assistant message after tool results creates a new turn. + // Prevents reasoning from the previous turn leaking into the new one. + if options.preserve_thinking_context && message.role == Role::Assistant && saw_tool_response + { + tool_call_turn_reasoning.clear(); + saw_tool_response = false; + } + let mut converted = json!({ "role": message.role }); @@ -203,7 +239,7 @@ pub fn format_messages_with_options( if !text.text.is_empty() { if message.role == Role::User { if let Some(image_path) = detect_image_path(&text.text) { - if let Ok(image) = load_image_file(image_path) { + if let Ok(image) = load_image_file(image_path.as_ref()) { has_non_text_content = true; content_array.push(json!({"type": "text", "text": text.text})); content_array.push(convert_image(&image, image_format)); @@ -260,11 +296,27 @@ pub fn format_messages_with_options( tool_calls.as_array_mut().unwrap().push(tool_call_json); } - Err(e) => { - output.push(json!({ - "role": "tool", - "content": format!("Error: {}", e), - "tool_call_id": request.id + Err(_e) => { + // An unparseable tool call still needs a valid assistant + // `tool_calls` entry. Emitting the error as a bare `role:"tool"` + // message (the old behavior) leaves the paired tool response โ€” + // which carries the parse error โ€” as an orphan `role:"tool"` with + // no preceding assistant `tool_calls`, which strict + // OpenAI-compatible APIs reject. Emit a placeholder call with the + // same id so the history stays well-formed; the error rides on the + // following tool response. + let tool_calls = converted + .as_object_mut() + .unwrap() + .entry("tool_calls") + .or_insert(json!([])); + tool_calls.as_array_mut().unwrap().push(json!({ + "id": request.id, + "type": "function", + "function": { + "name": "unparseable_tool_call", + "arguments": "{}", + } })); } }, @@ -408,6 +460,25 @@ pub fn format_messages_with_options( merge_reasoning_text(&pending_assistant_reasoning, &reasoning_text); pending_assistant_reasoning.clear(); } + + let has_tool_calls = converted + .get("tool_calls") + .and_then(|tc| tc.as_array()) + .is_some_and(|a| !a.is_empty()); + + if has_tool_calls { + if reasoning_text.is_empty() { + reasoning_text = tool_call_turn_reasoning.clone(); + } else { + tool_call_turn_reasoning = reasoning_text.clone(); + } + } else { + // Carry reasoning forward even through non-tool assistant messages + // (e.g., a visible text chunk that's is sent before a tool-call chunk + // in the same streaming turn). An empty reasoning_text is equivalent + // to clear. + tool_call_turn_reasoning = reasoning_text.clone(); + } } // Include reasoning_content only when non-empty. Kimi rejects empty @@ -638,13 +709,13 @@ pub fn response_to_message(response: &Value) -> anyhow::Result { }) .filter(|m: &serde_json::Map| !m.is_empty()); - if !is_valid_function_name(&function_name) { + if function_name.is_empty() { let error = 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_-]+", - function_name - )), + message: Cow::from( + "The provided function name was empty; a tool call must name a tool" + .to_string(), + ), data: None, }; content.push(MessageContent::tool_request_with_metadata( @@ -652,31 +723,50 @@ pub fn response_to_message(response: &Value) -> anyhow::Result { Err(error), metadata.as_ref(), )); - } else { - match safely_parse_json(&arguments_str) { - Ok(params) => { - content.push(MessageContent::tool_request_with_metadata( + continue; + } + match parse_tool_arguments(&arguments_str) { + Some(params) if params.is_object() => { + content.push(MessageContent::tool_request_with_metadata( + id, + Ok(CallToolRequestParams::new(function_name) + .with_arguments(object(params))), + metadata.as_ref(), + )); + } + Some(other) => { + let error = ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: Cow::from(format!( + "Tool arguments for {} (id {}) must be a JSON object, got {}. Raw arguments: '{}'", + function_name, id, - Ok(CallToolRequestParams::new(function_name) - .with_arguments(object(params))), - metadata.as_ref(), - )); - } - Err(e) => { - let error = ErrorData { - code: ErrorCode::INVALID_PARAMS, - message: Cow::from(format!( - "Could not interpret tool use parameters for id {}: {}. Raw arguments: '{}'", - id, e, arguments_str - )), - data: None, - }; - content.push(MessageContent::tool_request_with_metadata( - id, - Err(error), - metadata.as_ref(), - )); - } + describe_json_value(&other), + arguments_str + )), + data: None, + }; + content.push(MessageContent::tool_request_with_metadata( + id, + Err(error), + metadata.as_ref(), + )); + } + None => { + let message_text = + truncation_error_message(&arguments_str).unwrap_or_else(|| { + format!("Could not interpret tool use parameters for id {id}") + }); + let error = ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: Cow::from(message_text), + data: None, + }; + content.push(MessageContent::tool_request_with_metadata( + id, + Err(error), + metadata.as_ref(), + )); } } } @@ -742,6 +832,13 @@ pub fn get_usage(usage: &Value) -> Usage { .with_cache_tokens(cache_read_input_tokens, cache_write_input_tokens) } +pub fn get_cost(usage: &Value) -> Option { + usage + .get("cost") + .and_then(|v| v.as_f64()) + .filter(|c| c.is_finite() && *c >= 0.0) +} + fn extract_usage_with_output_tokens( chunk: &StreamingChunk, fallback_model: Option<&str>, @@ -750,11 +847,13 @@ fn extract_usage_with_output_tokens( .usage .as_ref() .and_then(|u| { - chunk - .model - .as_deref() - .or(fallback_model) - .map(|model| ProviderUsage::new(model.to_string(), get_usage(u))) + chunk.model.as_deref().or(fallback_model).map(|model| { + let usage = ProviderUsage::new(model.to_string(), get_usage(u)); + match get_cost(u) { + Some(cost) => usage.with_cost(cost, CostSource::ProviderReported), + None => usage, + } + }) }) .filter(|u| u.usage.output_tokens.is_some()) } @@ -959,8 +1058,9 @@ where let mut tool_call_data: ToolCallData = HashMap::new(); if let Some(tool_calls) = &chunk.choices[0].delta.tool_calls { - for tool_call in tool_calls { - if let (Some(index), Some(id), Some(name)) = (tool_call.index, &tool_call.id, &tool_call.function.name) { + for (position, tool_call) in tool_calls.iter().enumerate() { + if let (Some(id), Some(name)) = (&tool_call.id, &tool_call.function.name) { + let index = tool_call.index.unwrap_or(position as i32); tool_call_data.insert(index, (id.clone(), name.clone(), tool_call.function.arguments.clone(), tool_call.extra.clone())); } } @@ -1085,12 +1185,6 @@ where for index in sorted_indices { if let Some((id, function_name, arguments, extra_fields)) = tool_call_data.get(&index) { - let parsed = if arguments.is_empty() { - Ok(json!({})) - } else { - safely_parse_json(arguments) - }; - let metadata = if let Some(sig) = &last_signature { let mut combined = extra_fields.clone().unwrap_or_default(); combined.insert( @@ -1102,26 +1196,49 @@ where extra_fields.as_ref().filter(|m| !m.is_empty()).cloned() }; - let content = match parsed { - Ok(params) => { - MessageContent::tool_request_with_metadata( + let content = if arguments.is_empty() { + MessageContent::tool_request_with_metadata( + id.clone(), + Ok(CallToolRequestParams::new(function_name.clone()).with_arguments(object(json!({})))), + metadata.as_ref(), + ) + } else { + match parse_tool_arguments(arguments) { + Some(params) if params.is_object() => MessageContent::tool_request_with_metadata( id.clone(), Ok(CallToolRequestParams::new(function_name.clone()).with_arguments(object(params))), metadata.as_ref(), - ) - }, - Err(e) => { - let error = ErrorData { - code: ErrorCode::INVALID_PARAMS, - message: Cow::from(format!( - "Could not interpret tool use parameters for id {}: {}", - id, e - )), - data: None, - }; - MessageContent::tool_request_with_metadata(id.clone(), Err(error), metadata.as_ref()) + ), + // Valid JSON but NOT an object (a bare array/string/number). + // Surface a tool error so the model retries instead of + // crashing the run (rmcp's `object()` debug-asserts on + // non-objects). Mirrors the non-streaming decoder. + Some(other) => { + let error = ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: Cow::from(format!( + "Tool arguments for {} (id {}) must be a JSON object, got {}. Raw arguments: '{}'", + function_name, id, describe_json_value(&other), arguments + )), + data: None, + }; + MessageContent::tool_request_with_metadata(id.clone(), Err(error), metadata.as_ref()) + } + None => { + let message_text = truncation_error_message(arguments) + .unwrap_or_else(|| { + format!("Could not interpret tool use parameters for id {id}") + }); + let error = ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: Cow::from(message_text), + data: None, + }; + MessageContent::tool_request_with_metadata(id.clone(), Err(error), metadata.as_ref()) + } } }; + contents.push(content); } } @@ -1418,6 +1535,8 @@ fn openai_reasoning_efforts_for_model(model_name: &str) -> &'static [&'static st || normalized.contains("gpt-5-4") || normalized.contains("gpt-5.5") || normalized.contains("gpt-5-5") + || normalized.contains("gpt-5.6") + || normalized.contains("gpt-5-6") { &["low", "medium", "high", "xhigh"] } else { @@ -1452,10 +1571,7 @@ mod tests { use tokio_stream::{self, StreamExt}; fn test_model_config(model_name: &str) -> ModelConfig { - ModelConfig { - model_name: model_name.to_string(), - ..Default::default() - } + ModelConfig::new(model_name) } #[test] @@ -1894,10 +2010,9 @@ mod tests { } #[test] - fn test_response_to_message_invalid_func_name() -> anyhow::Result<()> { + fn test_response_to_message_empty_func_name() -> anyhow::Result<()> { let mut response: Value = serde_json::from_str(OPENAI_TOOL_USE_RESPONSE)?; - response["choices"][0]["message"]["tool_calls"][0]["function"]["name"] = - json!("invalid fn"); + response["choices"][0]["message"]["tool_calls"][0]["function"]["name"] = json!(""); let message = response_to_message(&response)?; @@ -1910,7 +2025,7 @@ mod tests { }) => { assert!(msg.starts_with("The provided function name")); } - _ => panic!("Expected ToolNotFound error"), + _ => panic!("Expected invalid-request error for empty name"), } } else { panic!("Expected ToolRequest content"); @@ -1919,6 +2034,48 @@ mod tests { Ok(()) } + #[test] + fn test_response_to_message_passes_names_through_to_dispatch() -> anyhow::Result<()> { + for name in [ + "developer.shell", + "functions.example_fn", + "example fn", + "???!", + ] { + let mut response: Value = serde_json::from_str(OPENAI_TOOL_USE_RESPONSE)?; + response["choices"][0]["message"]["tool_calls"][0]["function"]["name"] = json!(name); + + let message = response_to_message(&response)?; + + if let MessageContent::ToolRequest(request) = &message.content[0] { + let tool_call = request.tool_call.as_ref().expect("tool call should parse"); + assert_eq!(tool_call.name, name, "name must pass through verbatim"); + } else { + panic!("Expected ToolRequest content"); + } + } + + Ok(()) + } + + #[test] + fn test_response_to_message_fenced_arguments() -> anyhow::Result<()> { + let mut response: Value = serde_json::from_str(OPENAI_TOOL_USE_RESPONSE)?; + response["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"] = + json!("```json\n{\"param\": \"value\"}\n```"); + + let message = response_to_message(&response)?; + + if let MessageContent::ToolRequest(request) = &message.content[0] { + let tool_call = request.tool_call.as_ref().expect("tool call should parse"); + assert_eq!(tool_call.arguments, Some(object!({"param": "value"}))); + } else { + panic!("Expected ToolRequest content"); + } + + Ok(()) + } + #[test] fn test_response_to_message_json_decode_error() -> anyhow::Result<()> { let mut response: Value = serde_json::from_str(OPENAI_TOOL_USE_RESPONSE)?; @@ -1934,7 +2091,7 @@ mod tests { message: msg, data: None, }) => { - assert!(msg.starts_with("Could not interpret tool use parameters")); + assert!(msg.contains("tool arguments") || msg.contains("truncated")); } _ => panic!("Expected InvalidParameters error"), } @@ -1945,6 +2102,40 @@ mod tests { Ok(()) } + #[test] + fn test_response_to_message_non_object_arguments() -> anyhow::Result<()> { + // Weaker models sometimes emit tool arguments that are valid JSON but + // not an object (here, a bare array). This must surface as a tool error, + // NOT panic via rmcp's `object()` debug-assert. + let mut response: Value = serde_json::from_str(OPENAI_TOOL_USE_RESPONSE)?; + response["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"] = + json!("[1, 2, 3]"); + + let message = response_to_message(&response)?; + + if let MessageContent::ToolRequest(request) = &message.content[0] { + match &request.tool_call { + Err(ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: msg, + data: None, + }) => { + assert!(msg.contains("must be a JSON object")); + assert!(msg.contains("an array")); + assert!( + msg.contains("example_fn"), + "error must name the original tool so the model can retry it: {msg}" + ); + } + _ => panic!("Expected InvalidParameters error for non-object args"), + } + } else { + panic!("Expected ToolRequest content"); + } + + Ok(()) + } + #[test] fn test_response_to_message_empty_argument() -> anyhow::Result<()> { let mut response: Value = serde_json::from_str(OPENAI_TOOL_USE_RESPONSE)?; @@ -2323,6 +2514,27 @@ mod tests { Ok(()) } + #[test] + fn test_create_request_gpt56_max_effort_uses_xhigh() -> anyhow::Result<()> { + let model_config = test_model_config("gpt-5.6-luna") + .with_max_tokens(Some(1024)) + .with_thinking_effort(ThinkingEffort::Max); + let request = create_request( + &model_config, + "system", + &[], + &[], + &ImageFormat::OpenAi, + false, + )?; + let obj = request.as_object().unwrap(); + + assert_eq!(obj.get("reasoning_effort"), Some(&json!("xhigh"))); + assert!(obj.get("thinking_effort").is_none()); + + Ok(()) + } + #[test] fn test_create_request_gpt5_pro_max_effort_uses_supported_level() -> anyhow::Result<()> { let model_config = test_model_config("gpt-5.2-pro-2025-12-11") @@ -2733,6 +2945,42 @@ data: [DONE]"#; panic!("Expected tool call message with nested extra_content metadata"); } + #[tokio::test] + async fn test_streaming_non_object_arguments_does_not_panic() -> anyhow::Result<()> { + // Streamed tool call whose arguments are valid JSON but NOT an object. + // Must yield an INVALID_PARAMS tool error, not panic via rmcp `object()`. + let response_lines = r#"data: {"model":"test-model","choices":[{"delta":{"role":"assistant","tool_calls":[{"id":"call_bad","function":{"name":"test_tool","arguments":"[1, 2, 3]"},"type":"function","index":0}]},"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110},"object":"chat.completion.chunk","id":"test-id","created":1234567890} +data: [DONE]"#; + + let response_stream = + tokio_stream::iter(response_lines.lines().map(|line| Ok(line.to_string()))); + let messages = response_to_streaming_message(response_stream); + pin!(messages); + + while let Some(Ok((message, _usage))) = messages.next().await { + if let Some(msg) = message { + if let MessageContent::ToolRequest(request) = &msg.content[0] { + match &request.tool_call { + Err(ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: m, + .. + }) => { + assert!(m.contains("must be a JSON object")); + assert!( + m.contains("test_tool"), + "error must name the original tool so the model can retry it: {m}" + ); + return Ok(()); + } + _ => panic!("expected INVALID_PARAMS for non-object streamed args"), + } + } + } + } + panic!("expected a tool request message"); + } + #[tokio::test] async fn test_streaming_response_extracts_inline_think_blocks() -> anyhow::Result<()> { let response_lines = concat!( @@ -3135,6 +3383,180 @@ data: [DONE]"#; Ok(()) } + #[test] + fn test_format_messages_carries_reasoning_through_text_only_chunks() -> anyhow::Result<()> { + // Scenario B from the streaming bug: thinking arrives first, then multiple + // text-only assistant messages, then a tool call with thinking re-attached + // by agent.rs (via the earlier-chunk lookback). + // Text-only messages set tool_call_turn_reasoning="" (line 453 else-branch), + // but the TC's own Thinking content must repopulate it. + let messages = vec![ + Message::assistant().with_content(MessageContent::thinking("reason", "")), + Message::assistant().with_text("partial answer"), + Message::assistant().with_text("more text"), + // agent.rs attaches the earlier thinking to the TC message + Message::assistant() + .with_content(MessageContent::thinking("reason", "")) + .with_tool_request( + "tool1", + Ok(CallToolRequestParams::new("test_tool").with_arguments(object!({}))), + ), + ]; + + let spec = format_messages_with_options( + &messages, + &ImageFormat::OpenAi, + OpenAiFormatOptions { + preserve_thinking_context: true, + }, + ); + + let tool_call_msgs: Vec<_> = spec + .iter() + .filter(|m| { + m.get("tool_calls") + .and_then(|tc| tc.as_array()) + .is_some_and(|a| !a.is_empty()) + }) + .collect(); + + assert_eq!(tool_call_msgs.len(), 1); + assert_eq!( + tool_call_msgs[0]["reasoning_content"], "reason", + "reasoning_content must survive text-only chunks between thinking and tool call" + ); + + Ok(()) + } + + #[test] + fn test_format_messages_carries_reasoning_to_all_split_tool_calls() -> anyhow::Result<()> { + // Simulates DeepSeek/Kimi streaming: a thinking-only chunk arrives first, + // then the agent splits two tool calls into separate messages, each with + // the same reasoning attached (as agent.rs does via response_thinking). + // The formatter must keep reasoning_content on both so that + // merge_split_tool_call_messages can reunite them into one assistant message. + let tool_result1 = Message::user().with_tool_response( + "tool1", + Ok(rmcp::model::CallToolResult::success(vec![ + rmcp::model::Content::text("result1"), + ])), + ); + let messages = vec![ + // Standalone thinking message (created by agent.rs alongside request_msgs) + Message::assistant().with_content(MessageContent::thinking("reasoning", "")), + // Each request_msg has thinking explicitly attached (agent.rs behaviour) + Message::assistant() + .with_content(MessageContent::thinking("reasoning", "")) + .with_tool_request( + "tool1", + Ok(CallToolRequestParams::new("tool_a").with_arguments(object!({}))), + ), + tool_result1, + Message::assistant() + .with_content(MessageContent::thinking("reasoning", "")) + .with_tool_request( + "tool2", + Ok(CallToolRequestParams::new("tool_b").with_arguments(object!({}))), + ), + ]; + + let spec = format_messages_with_options( + &messages, + &ImageFormat::OpenAi, + OpenAiFormatOptions { + preserve_thinking_context: true, + }, + ); + + // After merge: one assistant message with both tool calls + let assistant_msgs: Vec<_> = spec + .iter() + .filter(|m| m.get("role") == Some(&json!("assistant"))) + .collect(); + assert_eq!(assistant_msgs.len(), 1); + assert_eq!(assistant_msgs[0]["reasoning_content"], "reasoning"); + let tool_calls = assistant_msgs[0]["tool_calls"].as_array().unwrap(); + assert_eq!(tool_calls.len(), 2); + + Ok(()) + } + + #[test] + fn test_sequential_tool_calls_not_merged() -> anyhow::Result<()> { + // Verifies that two tool calls from *different* turns are never merged, + // even when the second call carries no fresh reasoning (the previous + // turn's reasoning must not leak into it). + let tool_result1 = Message::user().with_tool_response( + "tool1", + Ok(rmcp::model::CallToolResult::success(vec![ + rmcp::model::Content::text("result1"), + ])), + ); + let messages = vec![ + // Turn 1: thinking then tool call + Message::assistant().with_content(MessageContent::thinking("turn1_reasoning", "")), + Message::assistant().with_tool_request( + "tool1", + Ok(CallToolRequestParams::new("tool_a").with_arguments(object!({}))), + ), + tool_result1, + // Turn 2: new tool call, no fresh thinking + Message::assistant().with_tool_request( + "tool2", + Ok(CallToolRequestParams::new("tool_b").with_arguments(object!({}))), + ), + ]; + + let spec = format_messages_with_options( + &messages, + &ImageFormat::OpenAi, + OpenAiFormatOptions { + preserve_thinking_context: true, + }, + ); + + let assistant_msgs: Vec<_> = spec + .iter() + .filter(|m| m.get("role") == Some(&json!("assistant"))) + .collect(); + + // Must remain two separate assistant messages โ€” not merged across turns. + assert_eq!( + assistant_msgs.len(), + 2, + "sequential tool calls must not be merged" + ); + + // Turn 1 carries reasoning; turn 2 must not inherit it. + assert_eq!(assistant_msgs[0]["reasoning_content"], "turn1_reasoning"); + assert!( + assistant_msgs[1].get("reasoning_content").is_none() + || assistant_msgs[1]["reasoning_content"].is_null(), + "turn 2 must not inherit stale reasoning from turn 1" + ); + + // The tool result must appear between the two assistant messages. + let tool_idx = spec + .iter() + .position(|m| m.get("role") == Some(&json!("tool"))) + .expect("tool result must be present"); + let asst1_idx = spec + .iter() + .position(|m| m.get("role") == Some(&json!("assistant"))) + .unwrap(); + let asst2_idx = spec + .iter() + .rposition(|m| m.get("role") == Some(&json!("assistant"))) + .unwrap(); + assert!( + asst1_idx < tool_idx && tool_idx < asst2_idx, + "tool result must sit between the two assistant messages" + ); + + Ok(()) + } + #[test_case( "data: {\"error\":{\"message\":\"Internal server error\",\"type\":\"server_error\",\"code\":500}}\ndata: [DONE]", "Internal server error"; @@ -3434,6 +3856,128 @@ data: [DONE]"#; Ok(()) } + #[tokio::test] + async fn test_streaming_tool_call_without_tool_call_index() -> anyhow::Result<()> { + let response_lines = concat!( + "data: {\"id\":\"x\",\"object\":\"chat.completion.chunk\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"id\":\"functions.get_weather:0\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n", + "data: [DONE]" + ); + let lines: Vec = response_lines.lines().map(|s| s.to_string()).collect(); + let response_stream = tokio_stream::iter(lines.into_iter().map(Ok)); + let mut messages = std::pin::pin!(response_to_streaming_message(response_stream)); + + let mut tool_calls = Vec::new(); + while let Some(result) = messages.next().await { + let (message, _usage) = result?; + if let Some(msg) = message { + for content in &msg.content { + if let MessageContent::ToolRequest(request) = content { + let tool_call = request.tool_call.as_ref().expect("tool call should parse"); + tool_calls.push((tool_call.name.to_string(), tool_call.arguments.clone())); + } + } + } + } + + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0].0, "get_weather"); + assert_eq!(tool_calls[0].1, Some(object!({"city": "Paris"}))); + Ok(()) + } + + #[tokio::test] + async fn test_streaming_tool_call_dotted_name_passes_through() -> anyhow::Result<()> { + let response_lines = concat!( + "data: {\"id\":\"x\",\"object\":\"chat.completion.chunk\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"tc1\",\"type\":\"function\",\"function\":{\"name\":\"ext__db.query\",\"arguments\":\"{\\\"command\\\": \\\"ls\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n", + "data: [DONE]" + ); + let lines: Vec = response_lines.lines().map(|s| s.to_string()).collect(); + let response_stream = tokio_stream::iter(lines.into_iter().map(Ok)); + let mut messages = std::pin::pin!(response_to_streaming_message(response_stream)); + + let mut tool_calls = Vec::new(); + while let Some(result) = messages.next().await { + let (message, _usage) = result?; + if let Some(msg) = message { + for content in &msg.content { + if let MessageContent::ToolRequest(request) = content { + let tool_call = request.tool_call.as_ref().expect("tool call should parse"); + tool_calls.push((tool_call.name.to_string(), tool_call.arguments.clone())); + } + } + } + } + + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0].0, "ext__db.query"); + assert_eq!(tool_calls[0].1, Some(object!({"command": "ls"}))); + Ok(()) + } + + #[tokio::test] + async fn test_streaming_tool_call_degenerate_name_passes_through() -> anyhow::Result<()> { + let response_lines = concat!( + "data: {\"id\":\"x\",\"object\":\"chat.completion.chunk\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"tc1\",\"type\":\"function\",\"function\":{\"name\":\"???\",\"arguments\":\"{}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n", + "data: [DONE]" + ); + let lines: Vec = response_lines.lines().map(|s| s.to_string()).collect(); + let response_stream = tokio_stream::iter(lines.into_iter().map(Ok)); + let mut messages = std::pin::pin!(response_to_streaming_message(response_stream)); + + let mut names = Vec::new(); + while let Some(result) = messages.next().await { + let (message, _usage) = result?; + if let Some(msg) = message { + for content in &msg.content { + if let MessageContent::ToolRequest(request) = content { + names.push( + request + .tool_call + .as_ref() + .expect("passes through") + .name + .to_string(), + ); + } + } + } + } + + assert_eq!(names, vec!["???".to_string()]); + Ok(()) + } + + #[tokio::test] + async fn test_streaming_multiple_tool_calls_without_tool_call_index() -> anyhow::Result<()> { + let response_lines = concat!( + "data: {\"id\":\"x\",\"object\":\"chat.completion.chunk\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"id\":\"functions.get_weather:0\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\"}},{\"id\":\"functions.get_weather:1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\": \\\"Tokyo\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n", + "data: [DONE]" + ); + let lines: Vec = response_lines.lines().map(|s| s.to_string()).collect(); + let response_stream = tokio_stream::iter(lines.into_iter().map(Ok)); + let mut messages = std::pin::pin!(response_to_streaming_message(response_stream)); + + let mut tool_calls = Vec::new(); + while let Some(result) = messages.next().await { + let (message, _usage) = result?; + if let Some(msg) = message { + for content in &msg.content { + if let MessageContent::ToolRequest(request) = content { + let tool_call = request.tool_call.as_ref().expect("tool call should parse"); + tool_calls.push((tool_call.name.to_string(), tool_call.arguments.clone())); + } + } + } + } + + assert_eq!(tool_calls.len(), 2); + assert_eq!(tool_calls[0].0, "get_weather"); + assert_eq!(tool_calls[0].1, Some(object!({"city": "Paris"}))); + assert_eq!(tool_calls[1].0, "get_weather"); + assert_eq!(tool_calls[1].1, Some(object!({"city": "Tokyo"}))); + Ok(()) + } + // Streaming counterpart: both fields in one delta must parse and yield // thinking content, not fail with "duplicate field `reasoning_content`". #[tokio::test] @@ -3554,4 +4098,48 @@ data: [DONE]"#; assert!(!is_valid_function_name("hello world")); assert!(!is_valid_function_name("hello@world")); } + + #[test] + fn formatter_post_parse_error_history_is_wellformed() { + use rmcp::model::{ErrorCode, ErrorData}; + let err = ErrorData::new( + ErrorCode::INVALID_PARAMS, + "Tool arguments for id call_bad must be a JSON object".to_string(), + None, + ); + // Shape the agent loop builds today for a failed parse: + let request_msg = Message::assistant().with_tool_request("call_bad", Err(err.clone())); + let mut final_resp = Message::user(); + final_resp.add_tool_response_with_metadata("call_bad", Err(err), None); + let messages = vec![ + Message::user().with_text("do the thing"), + request_msg, + final_resp, + ]; + + let spec = format_messages(&messages, &ImageFormat::OpenAi); + + let mut open = std::collections::HashSet::new(); + for m in &spec { + match m.get("role").and_then(|v| v.as_str()) { + Some("assistant") => { + for tc in m + .get("tool_calls") + .and_then(|v| v.as_array()) + .into_iter() + .flatten() + { + if let Some(id) = tc.get("id").and_then(|v| v.as_str()) { + open.insert(id.to_string()); + } + } + } + Some("tool") => { + let id = m.get("tool_call_id").and_then(|v| v.as_str()).unwrap_or(""); + assert!(open.contains(id), "orphan role:tool message for id {id:?}"); + } + _ => {} + } + } + } } diff --git a/crates/goose-providers/src/formats/openai_responses.rs b/crates/goose-provider-types/src/formats/openai_responses.rs similarity index 91% rename from crates/goose-providers/src/formats/openai_responses.rs rename to crates/goose-provider-types/src/formats/openai_responses.rs index 38a3023c5c..05aed70ff1 100644 --- a/crates/goose-providers/src/formats/openai_responses.rs +++ b/crates/goose-provider-types/src/formats/openai_responses.rs @@ -6,7 +6,7 @@ use crate::formats::openai::{ }; use crate::mcp_utils::extract_text_from_resource; use crate::model::ModelConfig; -use anyhow::Error; +use anyhow::{anyhow, Error}; use async_stream::try_stream; use chrono; use futures::Stream; @@ -317,19 +317,24 @@ pub struct ResponseMetadata { #[serde(rename_all = "snake_case")] pub enum ResponseOutputItemInfo { Reasoning { - id: String, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, #[serde(default)] summary: Vec, }, Message { - id: String, - status: String, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, role: String, content: Vec, }, FunctionCall { - id: String, - status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + status: Option, #[serde(skip_serializing_if = "Option::is_none")] call_id: Option, name: String, @@ -596,10 +601,11 @@ pub fn create_responses_request( None }; + let store = model_config.request_param::("store").unwrap_or(false); let mut payload = json!({ "model": model_name, "input": input_items, - "store": false, + "store": store, }); if let Some(effort) = reasoning_effort { @@ -690,7 +696,9 @@ pub fn responses_api_to_message(response: &ResponsesApiResponse) -> anyhow::Resu arguments, .. } => { - let request_id = call_id.clone().or_else(|| id.clone()).unwrap_or_default(); + let request_id = call_id.clone().or_else(|| id.clone()).ok_or_else(|| { + anyhow!("Responses function_call output missing call_id and id") + })?; let parsed_args = if arguments.is_empty() { json!({}) } else { @@ -723,7 +731,7 @@ pub fn get_responses_usage(response: &ResponsesApiResponse) -> Usage { fn process_streaming_output_items( output_items: Vec, is_text_response: bool, -) -> Vec { +) -> anyhow::Result> { let mut content = Vec::new(); for item in output_items { @@ -771,7 +779,9 @@ fn process_streaming_output_items( arguments, .. } => { - let request_id = call_id.unwrap_or(id); + let request_id = call_id.or(id).ok_or_else(|| { + anyhow!("Responses function_call output missing call_id and id") + })?; let parsed_args = if arguments.is_empty() { json!({}) } else { @@ -786,7 +796,7 @@ fn process_streaming_output_items( } } - content + Ok(content) } pub fn responses_api_to_streaming_message( @@ -944,7 +954,7 @@ where } // Process final output items and yield usage data - let content = process_streaming_output_items(output_items, is_text_response); + let content = process_streaming_output_items(output_items, is_text_response)?; if !content.is_empty() { let mut message = Message::new(Role::Assistant, chrono::Utc::now().timestamp(), content); @@ -1052,6 +1062,84 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_responses_stream_allows_message_output_without_id_status() -> anyhow::Result<()> { + let lines = vec![ + r#"data: {"type":"response.created","sequence_number":1,"response":{"id":"resp_1","object":"response","created_at":1737368310,"status":"in_progress","model":"gpt-5.2-pro","output":[]}}"#.to_string(), + r#"data: {"type":"response.output_text.delta","sequence_number":2,"item_id":"msg_1","output_index":0,"content_index":0,"delta":"Hello"}"#.to_string(), + r#"data: {"type":"response.output_text.delta","sequence_number":3,"item_id":"msg_1","output_index":0,"content_index":0,"delta":" world"}"#.to_string(), + r#"data: {"type":"response.completed","sequence_number":4,"response":{"id":"resp_1","object":"response","created_at":1737368310,"status":"completed","model":"gpt-5.2-pro","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello world"}]}],"usage":{"input_tokens":10,"output_tokens":4,"total_tokens":14}}}"#.to_string(), + "data: [DONE]".to_string(), + ]; + + let response_stream = tokio_stream::iter(lines.into_iter().map(Ok)); + let messages = responses_api_to_streaming_message(response_stream); + futures::pin_mut!(messages); + + let mut text_parts = Vec::new(); + let mut usage: Option = None; + + while let Some(item) = messages.next().await { + let (message, maybe_usage) = item?; + if let Some(msg) = message { + for content in msg.content { + if let MessageContent::Text(text) = content { + text_parts.push(text.text.clone()); + } + } + } + if let Some(final_usage) = maybe_usage { + usage = Some(final_usage); + } + } + + assert_eq!(text_parts.concat(), "Hello world"); + let usage = usage.expect("usage should be present at completion"); + assert_eq!(usage.model, "gpt-5.2-pro"); + assert_eq!(usage.usage.input_tokens, Some(10)); + assert_eq!(usage.usage.output_tokens, Some(4)); + assert_eq!(usage.usage.total_tokens, Some(14)); + + Ok(()) + } + + #[tokio::test] + async fn test_responses_stream_allows_function_call_without_id_status() -> anyhow::Result<()> { + let lines = vec![ + r#"data: {"type":"response.created","sequence_number":1,"response":{"id":"resp_1","object":"response","created_at":1737368310,"status":"in_progress","model":"gpt-5.2-pro","output":[]}}"#.to_string(), + r#"data: {"type":"response.completed","sequence_number":2,"response":{"id":"resp_1","object":"response","created_at":1737368310,"status":"completed","model":"gpt-5.2-pro","output":[{"type":"reasoning","summary":[]},{"type":"function_call","call_id":"call_abc","name":"shell","arguments":"{\"command\":\"pwd\"}"}],"usage":{"input_tokens":10,"output_tokens":4,"total_tokens":14}}}"#.to_string(), + "data: [DONE]".to_string(), + ]; + + let response_stream = tokio_stream::iter(lines.into_iter().map(Ok)); + let messages = responses_api_to_streaming_message(response_stream); + futures::pin_mut!(messages); + + let mut tool_request_id = None; + let mut usage: Option = None; + + while let Some(item) = messages.next().await { + let (message, maybe_usage) = item?; + if let Some(msg) = message { + for content in msg.content { + if let MessageContent::ToolRequest(request) = content { + tool_request_id = Some(request.id); + } + } + } + if let Some(final_usage) = maybe_usage { + usage = Some(final_usage); + } + } + + assert_eq!(tool_request_id.as_deref(), Some("call_abc")); + let usage = usage.expect("usage should be present at completion"); + assert_eq!(usage.model, "gpt-5.2-pro"); + assert_eq!(usage.usage.total_tokens, Some(14)); + + Ok(()) + } + #[test] fn test_responses_api_to_message_captures_reasoning_summary() -> anyhow::Result<()> { let response: ResponsesApiResponse = serde_json::from_value(serde_json::json!({ @@ -1200,7 +1288,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1292,7 +1379,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1336,7 +1422,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1358,7 +1443,7 @@ mod tests { #[test] fn test_responses_request_with_normalized_effort_suffix() { - let model_config = ModelConfig::new("o3-mini-high").unwrap(); + let model_config = ModelConfig::new("o3-mini-high"); let result = create_responses_request(&model_config, "You are helpful.", &[], &[]).unwrap(); @@ -1377,7 +1462,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1403,7 +1487,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1417,6 +1500,27 @@ mod tests { ); } + #[test] + fn test_request_params_override_store() { + let model_config = ModelConfig { + model_name: "o3".to_string(), + context_limit: None, + temperature: None, + max_tokens: None, + toolshim: false, + toolshim_model: None, + request_params: Some(std::collections::HashMap::from([( + "store".to_string(), + serde_json::json!(true), + )])), + reasoning: None, + }; + + let result = create_responses_request(&model_config, "", &[], &[]).unwrap(); + + assert_eq!(result["store"], true); + } + #[test] fn test_user_image_serialized_in_responses_request() { use crate::conversation::message::Message; @@ -1432,7 +1536,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1479,7 +1582,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1516,7 +1618,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1548,7 +1649,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1579,7 +1679,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1614,7 +1713,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1651,7 +1749,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1677,7 +1774,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1709,7 +1805,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1741,7 +1836,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1782,7 +1876,7 @@ mod tests { } #[test] - fn test_refusal_content_part_deserializes_in_streaming_output() { + fn test_refusal_content_part_deserializes_in_streaming_output() -> anyhow::Result<()> { let json = r#"{ "type": "message", "id": "msg_1", @@ -1792,13 +1886,15 @@ mod tests { }"#; let item: ResponseOutputItemInfo = serde_json::from_str(json).unwrap(); - let content = process_streaming_output_items(vec![item], false); + let content = process_streaming_output_items(vec![item], false)?; assert_eq!(content.len(), 1); if let MessageContent::Text(t) = &content[0] { assert_eq!(t.text, "I'm unable to assist."); } else { panic!("expected text content from refusal"); } + + Ok(()) } #[test] @@ -1815,28 +1911,47 @@ mod tests { } #[test] - fn test_streamed_refusal_not_duplicated_in_output_items() { + fn test_streamed_refusal_not_duplicated_in_output_items() -> anyhow::Result<()> { let output_items = vec![ResponseOutputItemInfo::Message { - id: "msg_1".to_string(), - status: "completed".to_string(), + id: Some("msg_1".to_string()), + status: Some("completed".to_string()), role: "assistant".to_string(), content: vec![ContentPart::Refusal { refusal: "I cannot help with that.".to_string(), }], }]; - let content = process_streaming_output_items(output_items.clone(), true); + let content = process_streaming_output_items(output_items.clone(), true)?; assert!( content.is_empty(), "refusal should be suppressed when already streamed" ); - let content = process_streaming_output_items(output_items, false); + let content = process_streaming_output_items(output_items, false)?; assert_eq!( content.len(), 1, "refusal should appear in non-streaming path" ); + + Ok(()) + } + + #[test] + fn test_function_call_output_requires_call_id_or_id() { + let output_items = vec![ResponseOutputItemInfo::FunctionCall { + id: None, + status: None, + call_id: None, + name: "shell".to_string(), + arguments: "{}".to_string(), + }]; + + let error = process_streaming_output_items(output_items, false).unwrap_err(); + assert!( + error.to_string().contains("missing call_id and id"), + "unexpected error: {error}" + ); } #[test] @@ -1863,7 +1978,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1901,7 +2015,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1939,7 +2052,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; diff --git a/crates/goose/src/providers/formats/snowflake.rs b/crates/goose-provider-types/src/formats/snowflake.rs similarity index 98% rename from crates/goose/src/providers/formats/snowflake.rs rename to crates/goose-provider-types/src/formats/snowflake.rs index ba574f9ff8..1c2ac06ea4 100644 --- a/crates/goose/src/providers/formats/snowflake.rs +++ b/crates/goose-provider-types/src/formats/snowflake.rs @@ -1,9 +1,9 @@ use crate::conversation::message::{Message, MessageContent}; +use crate::conversation::token_usage::Usage; +use crate::errors::ProviderError; use crate::mcp_utils::extract_text_from_resource; +use crate::model::ModelConfig; use anyhow::{anyhow, Result}; -use goose_providers::conversation::token_usage::Usage; -use goose_providers::errors::ProviderError; -use goose_providers::model::ModelConfig; use rmcp::model::{object, CallToolRequestParams, Role, Tool}; use rmcp::object; use serde_json::{json, Value}; @@ -560,10 +560,9 @@ data: {"id":"a9537c2c-2017-4906-9817-2456168d89fa","model":"claude-sonnet-4-2025 #[test] fn test_create_request_format() -> Result<()> { use crate::conversation::message::Message; - use goose_providers::model::ModelConfig; + use crate::model::ModelConfig; - let model_config = - ModelConfig::new_or_fail("claude-4-sonnet").with_canonical_limits("snowflake"); + let model_config = ModelConfig::new("claude-4-sonnet").with_canonical_limits("snowflake"); let system = "You are a helpful assistant that can use tools to get information."; let messages = vec![Message::user().with_text("What is the stock price of Nvidia?")]; @@ -670,10 +669,9 @@ data: {"id":"a9537c2c-2017-4906-9817-2456168d89fa","model":"claude-sonnet-4-2025 #[test] fn test_create_request_excludes_tools_for_description() -> Result<()> { use crate::conversation::message::Message; - use goose_providers::model::ModelConfig; + use crate::model::ModelConfig; - let model_config = - ModelConfig::new_or_fail("claude-4-sonnet").with_canonical_limits("snowflake"); + let model_config = ModelConfig::new("claude-4-sonnet").with_canonical_limits("snowflake"); let system = "Reply with only a description in four words or less"; let messages = vec![Message::user().with_text("Test message")]; let tools = vec![Tool::new( diff --git a/crates/goose-providers/src/goose_mode.rs b/crates/goose-provider-types/src/goose_mode.rs similarity index 100% rename from crates/goose-providers/src/goose_mode.rs rename to crates/goose-provider-types/src/goose_mode.rs diff --git a/crates/goose-providers/src/images.rs b/crates/goose-provider-types/src/images.rs similarity index 63% rename from crates/goose-providers/src/images.rs rename to crates/goose-provider-types/src/images.rs index 1f194862fc..5c27bd710b 100644 --- a/crates/goose-providers/src/images.rs +++ b/crates/goose-provider-types/src/images.rs @@ -1,4 +1,4 @@ -use std::{io::Read as _, path::Path}; +use std::{borrow::Cow, io::Read as _, path::Path}; use base64::Engine as _; use rmcp::model::{AnnotateAble as _, ImageContent, RawImageContent}; @@ -33,11 +33,11 @@ pub fn convert_image(image: &ImageContent, image_format: &ImageFormat) -> Value } } -pub fn detect_image_path(text: &str) -> Option<&str> { +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, &str)> = None; + let mut best: Option<(usize, Cow<'_, str>)> = None; let mut from = 0; while from < text.len() { let Some(end) = EXTENSIONS @@ -49,8 +49,7 @@ pub fn detect_image_path(text: &str) -> Option<&str> { }; let terminator = text.get(end..).and_then(|rest| rest.chars().next()); - let terminated = - terminator.is_none_or(|c| c == '/' || c.is_whitespace() || c == '"' || c == '\''); + let terminated = terminator.is_none_or(is_path_terminator); if terminated { let mut floor = end.saturating_sub(MAX_PATH_LEN); @@ -63,24 +62,23 @@ pub fn detect_image_path(text: &str) -> Option<&str> { let preceded_by_boundary = text .get(..start) .and_then(|prefix| prefix.chars().next_back()) - .is_none_or(|c| c.is_whitespace() || c == '"' || c == '\''); + .is_none_or(is_path_leading_boundary); if !preceded_by_boundary { continue; } let Some(candidate) = text.get(start..end) else { continue; }; - let path = Path::new(candidate); - if path.is_absolute() && path.is_file() && is_image_file(path) { + 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)); + best = Some((start, candidate_path)); } - None => best = Some((start, candidate)), + None => best = Some((start, candidate_path)), Some(_) => {} } break; @@ -93,6 +91,78 @@ pub fn detect_image_path(text: &str) -> Option<&str> { 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 { @@ -196,23 +266,23 @@ mod tests { // Test with valid PNG file using absolute path let text = format!("Here is an image {}", png_path_str); - assert_eq!(detect_image_path(&text), Some(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), None); + 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), None); + 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), None); + 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), None); + assert_eq!(detect_image_path(text).as_deref(), None); } #[test] @@ -225,25 +295,29 @@ mod tests { 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), Some(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), Some(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), Some(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), Some(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), Some(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. @@ -253,7 +327,7 @@ mod tests { 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), Some(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. @@ -266,7 +340,93 @@ mod tests { a.to_str().unwrap(), longer.to_str().unwrap() ); - assert_eq!(detect_image_path(&text), Some(a.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] @@ -280,14 +440,14 @@ mod tests { 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), None); + 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), None); + assert_eq!(detect_image_path(&backup).as_deref(), None); } #[test] @@ -295,7 +455,7 @@ mod tests { // 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), None); + assert_eq!(detect_image_path(&text).as_deref(), None); } #[test] diff --git a/crates/goose-provider-types/src/json.rs b/crates/goose-provider-types/src/json.rs new file mode 100644 index 0000000000..93ba4cc31d --- /dev/null +++ b/crates/goose-provider-types/src/json.rs @@ -0,0 +1,559 @@ +/// 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 new file mode 100644 index 0000000000..3ab49ef87e --- /dev/null +++ b/crates/goose-provider-types/src/lib.rs @@ -0,0 +1,15 @@ +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-providers/src/mcp_utils.rs b/crates/goose-provider-types/src/mcp_utils.rs similarity index 100% rename from crates/goose-providers/src/mcp_utils.rs rename to crates/goose-provider-types/src/mcp_utils.rs diff --git a/crates/goose-providers/src/model.rs b/crates/goose-provider-types/src/model.rs similarity index 67% rename from crates/goose-providers/src/model.rs rename to crates/goose-provider-types/src/model.rs index 22159af5df..341beaea99 100644 --- a/crates/goose-providers/src/model.rs +++ b/crates/goose-provider-types/src/model.rs @@ -4,22 +4,24 @@ use serde::de::Deserializer; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; -use thiserror::Error; use utoipa::ToSchema; pub const DEFAULT_CONTEXT_LIMIT: usize = 128_000; -#[derive(Error, Debug)] -pub enum ConfigError { - #[error("Environment variable '{0}' not found")] - EnvVarMissing(String), - #[error("Invalid value for '{0}': '{1}' - {2}")] - InvalidValue(String, String, String), - #[error("Value for '{0}' is out of valid range: {1}")] - InvalidRange(String, String), -} +/// 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, Default, Serialize, ToSchema)] +#[derive(Debug, Clone, Serialize, ToSchema)] pub struct ModelConfig { pub model_name: String, pub context_limit: Option, @@ -27,8 +29,6 @@ pub struct ModelConfig { pub max_tokens: Option, pub toolshim: bool, pub toolshim_model: Option, - #[serde(skip)] - pub fast_model_config: Option>, /// Provider-specific request parameters (e.g., anthropic_beta headers) #[serde(default, skip_serializing_if = "Option::is_none")] pub request_params: Option>, @@ -49,8 +49,6 @@ impl<'de> Deserialize<'de> for ModelConfig { max_tokens: Option, toolshim: bool, toolshim_model: Option, - #[serde(default)] - fast_model_config: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] request_params: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -65,7 +63,6 @@ impl<'de> Deserialize<'de> for ModelConfig { max_tokens: raw.max_tokens, toolshim: raw.toolshim, toolshim_model: raw.toolshim_model, - fast_model_config: raw.fast_model_config, request_params: raw.request_params, reasoning: raw.reasoning, }; @@ -75,7 +72,7 @@ impl<'de> Deserialize<'de> for ModelConfig { } impl ModelConfig { - pub fn new(model_name: impl AsRef) -> Result { + pub fn new(model_name: impl AsRef) -> Self { let mut config = Self { model_name: model_name.as_ref().to_string(), context_limit: None, @@ -83,12 +80,11 @@ impl ModelConfig { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; config.normalize_effort_suffix(); - Ok(config) + config } pub fn with_canonical_limits(mut self, provider_name: &str) -> Self { @@ -147,11 +143,6 @@ impl ModelConfig { if self.context_limit.is_none() { self.context_limit = limit; } - - if let Some(fast_config) = self.fast_model_config.take() { - self.fast_model_config = Some(Box::new(fast_config.with_default_context_limit(limit))); - } - self } @@ -159,11 +150,6 @@ impl ModelConfig { if self.max_tokens.is_none() { self.max_tokens = tokens; } - - if let Some(fast_config) = self.fast_model_config.take() { - self.fast_model_config = Some(Box::new(fast_config.with_default_max_tokens(tokens))); - } - self } @@ -177,21 +163,6 @@ impl ModelConfig { self } - pub fn with_fast( - mut self, - fast_model_name: &str, - provider_name: &str, - ) -> Result { - let fast_config = ModelConfig::new(fast_model_name)?.with_canonical_limits(provider_name); - self.fast_model_config = Some(Box::new(fast_config)); - Ok(self) - } - - pub fn with_fast_model_config(mut self, fast_model_config: ModelConfig) -> Self { - self.fast_model_config = Some(Box::new(fast_model_config)); - self - } - pub fn with_merged_request_params(mut self, params: HashMap) -> Self { match self.request_params.as_mut() { Some(existing) => { @@ -221,12 +192,6 @@ impl ModelConfig { self = self.with_thinking_effort(effort); } } - - if let Some(fast_config) = self.fast_model_config.take() { - self.fast_model_config = - Some(Box::new(fast_config.with_default_thinking_effort(effort))); - } - self } @@ -235,22 +200,13 @@ impl ModelConfig { previous: Option<&ModelConfig>, request_params: Option>, ) -> Self { - if let Some(previous) = previous { - let has_thinking_effort = self - .request_params - .as_ref() - .and_then(|params| params.get("thinking_effort")) - .is_some(); - - if !has_thinking_effort { - if let Some(thinking_effort) = previous - .request_params - .as_ref() - .and_then(|params| params.get("thinking_effort")) - .cloned() - { - let params = self.request_params.get_or_insert_with(HashMap::new); - params.insert("thinking_effort".to_string(), thinking_effort); + 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()); } } } @@ -262,14 +218,6 @@ impl ModelConfig { self } - pub fn use_fast_model(&self) -> Self { - if let Some(fast_config) = &self.fast_model_config { - *fast_config.clone() - } else { - self.clone() - } - } - pub fn context_limit(&self) -> usize { self.context_limit.unwrap_or(DEFAULT_CONTEXT_LIMIT) } @@ -347,66 +295,30 @@ impl ModelConfig { .and_then(|params| params.get(request_key)) .and_then(|v| serde_json::from_value(v.clone()).ok()) } - - pub fn new_or_fail(model_name: &str) -> ModelConfig { - ModelConfig::new(model_name) - .unwrap_or_else(|_| panic!("Failed to create model config for {}", model_name)) - } } #[cfg(test)] mod tests { use super::*; - #[test] - fn test_deserialize_preserves_fast_model_config() { - let config: ModelConfig = serde_json::from_value(serde_json::json!({ - "model_name": "primary-model", - "context_limit": null, - "temperature": null, - "max_tokens": null, - "toolshim": false, - "toolshim_model": null, - "fast_model_config": { - "model_name": "fast-model", - "context_limit": 4096, - "temperature": null, - "max_tokens": 1024, - "toolshim": false, - "toolshim_model": null - } - })) - .unwrap(); - - let fast_config = config.fast_model_config.as_ref().unwrap(); - assert_eq!(fast_config.model_name, "fast-model"); - assert_eq!(fast_config.context_limit, Some(4096)); - assert_eq!(fast_config.max_tokens, Some(1024)); - assert_eq!(config.use_fast_model().model_name, "fast-model"); - } - 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 = ModelConfig { - model_name: "test".to_string(), - request_params: Some(params), - ..Default::default() - }; + 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 { - model_name: "test".to_string(), - ..Default::default() - } - .with_thinking_effort(ThinkingEffort::High); + let config = ModelConfig::new("test").with_thinking_effort(ThinkingEffort::High); assert_eq!( config @@ -419,19 +331,12 @@ mod tests { #[test] fn preserves_explicit_thinking_effort() { - let previous = ModelConfig { - model_name: "previous".to_string(), - request_params: Some(HashMap::from([( - "thinking_effort".to_string(), - serde_json::json!("high"), - )])), - ..Default::default() - }; - let config = ModelConfig { - model_name: "next".to_string(), - ..Default::default() - } - .with_inherited_session_settings_from(Some(&previous), None); + 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 @@ -444,22 +349,14 @@ mod tests { #[test] fn does_not_override_existing_thinking_effort() { - let previous = ModelConfig { - model_name: "previous".to_string(), - request_params: Some(HashMap::from([( - "thinking_effort".to_string(), - serde_json::json!("high"), - )])), - ..Default::default() - }; - let config = ModelConfig { - model_name: "next".to_string(), - request_params: Some(HashMap::from([( - "thinking_effort".to_string(), - serde_json::json!("low"), - )])), - ..Default::default() - } + 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!( @@ -472,39 +369,37 @@ mod tests { } #[test] - fn does_not_preserve_unrelated_request_params() { - let previous = ModelConfig { - model_name: "previous".to_string(), - request_params: Some(HashMap::from([( - "provider_specific".to_string(), - serde_json::json!("old"), - )])), - ..Default::default() - }; - let config = ModelConfig { - model_name: "next".to_string(), - ..Default::default() - } - .with_inherited_session_settings_from(Some(&previous), None); + 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); - assert!(config.request_params.is_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 = ModelConfig { - model_name: "previous".to_string(), - request_params: Some(HashMap::from([( - "thinking_effort".to_string(), - serde_json::json!("high"), - )])), - ..Default::default() - }; - let config = ModelConfig { - model_name: "next".to_string(), - ..Default::default() - } - .with_inherited_session_settings_from( + 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(), @@ -531,7 +426,7 @@ mod tests { ("GOOSE_TOOLSHIM", None::<&str>), ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), ]); - let config = ModelConfig::new("o3-mini-high").unwrap(); + let config = ModelConfig::new("o3-mini-high"); assert_eq!(config.model_name, "o3-mini"); assert_eq!(config.thinking_effort(), Some(ThinkingEffort::High)); } @@ -546,7 +441,7 @@ mod tests { ("GOOSE_TOOLSHIM", None::<&str>), ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), ]); - let config = ModelConfig::new("o3-mini-none").unwrap(); + let config = ModelConfig::new("o3-mini-none"); assert_eq!(config.model_name, "o3-mini"); assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Off)); } @@ -561,7 +456,7 @@ mod tests { ("GOOSE_TOOLSHIM", None::<&str>), ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), ]); - let config = ModelConfig::new("gpt-5.4-xhigh").unwrap(); + let config = ModelConfig::new("gpt-5.4-xhigh"); assert_eq!(config.model_name, "gpt-5.4"); assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Max)); } @@ -578,7 +473,7 @@ mod tests { ]); let mut params = HashMap::new(); params.insert("thinking_effort".to_string(), serde_json::json!("low")); - let mut config = ModelConfig::new("o3-mini-high").unwrap(); + 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: @@ -599,7 +494,7 @@ mod tests { ("GOOSE_TOOLSHIM", None::<&str>), ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), ]); - let config = ModelConfig::new("o3-mini").unwrap(); + let config = ModelConfig::new("o3-mini"); assert_eq!(config.model_name, "o3-mini"); } @@ -613,7 +508,7 @@ mod tests { ("GOOSE_TOOLSHIM", None::<&str>), ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), ]); - let config = ModelConfig::new("claude-sonnet-4-high").unwrap(); + let config = ModelConfig::new("claude-sonnet-4-high"); assert_eq!(config.model_name, "claude-sonnet-4-high"); } @@ -640,7 +535,7 @@ mod tests { ("GOOSE_MAX_TOKENS", None::<&str>), ("GOOSE_CONTEXT_LIMIT", None::<&str>), ]); - let config = ModelConfig::new_or_fail("gpt-4o").with_canonical_limits("openai"); + 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)); @@ -653,7 +548,7 @@ mod tests { ("GOOSE_MAX_TOKENS", None::<&str>), ("GOOSE_CONTEXT_LIMIT", None::<&str>), ]); - let mut config = ModelConfig::new_or_fail("gpt-4o"); + let mut config = ModelConfig::new("gpt-4o"); config.context_limit = Some(64_000); let config = config.with_canonical_limits("openai"); @@ -666,7 +561,7 @@ mod tests { ("GOOSE_MAX_TOKENS", None::<&str>), ("GOOSE_CONTEXT_LIMIT", None::<&str>), ]); - let mut config = ModelConfig::new_or_fail("gpt-4o"); + let mut config = ModelConfig::new("gpt-4o"); config.max_tokens = Some(1_000); let config = config.with_canonical_limits("openai"); @@ -679,22 +574,34 @@ mod tests { ("GOOSE_MAX_TOKENS", None::<&str>), ("GOOSE_CONTEXT_LIMIT", None::<&str>), ]); - let config = - ModelConfig::new_or_fail("moonshotai/kimi-k2.6").with_canonical_limits("nvidia"); + 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_or_fail("totally-unknown-model").with_canonical_limits("openai"); + let config = ModelConfig::new("totally-unknown-model").with_canonical_limits("openai"); assert_eq!(config.context_limit, None); assert_eq!(config.max_tokens, None); @@ -709,17 +616,16 @@ mod tests { ]); // "databricks-gpt-5.4-high" should resolve via "databricks-gpt-5.4" - let config = ModelConfig::new_or_fail("databricks-gpt-5.4-high") - .with_canonical_limits("databricks"); + 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_or_fail("gpt-5.4-xhigh").with_canonical_limits("openai"); + 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_or_fail("gpt-5.4-nano-low").with_canonical_limits("openai"); + let config = ModelConfig::new("gpt-5.4-nano-low").with_canonical_limits("openai"); assert_eq!(config.context_limit, Some(400_000)); } } @@ -738,41 +644,39 @@ mod tests { #[test] fn bare_reasoning_models() { let _guard = env_lock::lock_env(ENV_LOCK_KEYS); - assert!(ModelConfig::new_or_fail("o1").is_openai_reasoning_model()); - assert!(ModelConfig::new_or_fail("o1-preview").is_openai_reasoning_model()); - assert!(ModelConfig::new_or_fail("o3").is_openai_reasoning_model()); - assert!(ModelConfig::new_or_fail("o3-mini").is_openai_reasoning_model()); - assert!(ModelConfig::new_or_fail("o4-mini").is_openai_reasoning_model()); - assert!(ModelConfig::new_or_fail("gpt-5").is_openai_reasoning_model()); - assert!(ModelConfig::new_or_fail("gpt-5-3-codex").is_openai_reasoning_model()); + 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_or_fail("goose-o3-mini").is_openai_reasoning_model()); - assert!(ModelConfig::new_or_fail("goose-o4-mini").is_openai_reasoning_model()); - assert!(ModelConfig::new_or_fail("goose-gpt-5").is_openai_reasoning_model()); + 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_or_fail("databricks-o3-mini").is_openai_reasoning_model()); - assert!(ModelConfig::new_or_fail("databricks-o4-mini").is_openai_reasoning_model()); - assert!(ModelConfig::new_or_fail("databricks-gpt-5").is_openai_reasoning_model()); + 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_or_fail("claude-sonnet-4").is_openai_reasoning_model()); - assert!(!ModelConfig::new_or_fail("gpt-4o").is_openai_reasoning_model()); - assert!( - !ModelConfig::new_or_fail("databricks-claude-sonnet-4").is_openai_reasoning_model() - ); - assert!(!ModelConfig::new_or_fail("goose-claude-sonnet-4").is_openai_reasoning_model()); - assert!(!ModelConfig::new_or_fail("llama-3-70b").is_openai_reasoning_model()); + 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()); } } @@ -790,19 +694,19 @@ mod tests { #[test] fn includes_reasoning_model_families() { let _guard = env_lock::lock_env(ENV_LOCK_KEYS); - assert!(ModelConfig::new_or_fail("o3-mini").is_reasoning_model()); - assert!(ModelConfig::new_or_fail("claude-sonnet-4").is_reasoning_model()); - assert!(ModelConfig::new_or_fail("gemini-3-pro").is_reasoning_model()); + 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_or_fail("provider-alias"); + let mut config = ModelConfig::new("provider-alias"); config.reasoning = Some(true); assert!(config.is_reasoning_model()); - let mut config = ModelConfig::new_or_fail("claude-sonnet-4"); + let mut config = ModelConfig::new("claude-sonnet-4"); config.reasoning = Some(false); assert!(!config.is_reasoning_model()); } diff --git a/crates/goose-providers/src/permission.rs b/crates/goose-provider-types/src/permission.rs similarity index 100% rename from crates/goose-providers/src/permission.rs rename to crates/goose-provider-types/src/permission.rs diff --git a/crates/goose-providers/src/request_log.rs b/crates/goose-provider-types/src/request_log.rs similarity index 100% rename from crates/goose-providers/src/request_log.rs rename to crates/goose-provider-types/src/request_log.rs diff --git a/crates/goose-providers/src/retry.rs b/crates/goose-provider-types/src/retry.rs similarity index 84% rename from crates/goose-providers/src/retry.rs rename to crates/goose-provider-types/src/retry.rs index 8660a9f1cf..49afd9bfe8 100644 --- a/crates/goose-providers/src/retry.rs +++ b/crates/goose-provider-types/src/retry.rs @@ -81,11 +81,27 @@ impl RetryConfig { } } +/// Substrings marking a `RequestFailed` (4xx) as deterministically permanent: +/// Anthropic rejects signed `thinking`/`redacted_thinking` blocks as immutable +/// once a thinking model's config changes mid-conversation, and the identical +/// payload is rebuilt on every retry โ€” so retrying can never succeed. +const PERMANENT_REQUEST_FAILURE_MARKERS: &[&str] = &[ + "blocks in the latest assistant message cannot be modified", + "must remain as they were in the original response", +]; + +fn is_permanent_request_failure(message: &str) -> bool { + PERMANENT_REQUEST_FAILURE_MARKERS + .iter() + .any(|marker| message.contains(marker)) +} + pub fn should_retry(error: &ProviderError, config: &RetryConfig) -> bool { match error { ProviderError::RateLimitExceeded { .. } | ProviderError::ServerError(_) | ProviderError::NetworkError(_) => true, + ProviderError::RequestFailed(message) if is_permanent_request_failure(message) => false, ProviderError::RequestFailed(_) => !config.transient_only, _ => false, } @@ -254,6 +270,33 @@ mod tests { assert!(should_retry(&error, &config)); } + #[test] + fn never_retries_permanent_thinking_block_400() { + let config = RetryConfig::default(); + let error = ProviderError::RequestFailed( + "Bad request (400): {\"message\":\"messages.3.content.1: `thinking` or \ + `redacted_thinking` blocks in the latest assistant message cannot be \ + modified. These blocks must remain as they were in the original \ + response.\"}" + .into(), + ); + assert!(!should_retry(&error, &config)); + } + + #[test] + fn permanent_request_failure_marker_detection() { + assert!(is_permanent_request_failure( + "messages.3.content.1: `thinking` blocks in the latest assistant message \ + cannot be modified" + )); + assert!(is_permanent_request_failure( + "These blocks must remain as they were in the original response." + )); + assert!(!is_permanent_request_failure( + "Bad request (400): model not found" + )); + } + #[test] fn transient_only_skips_request_failed() { let config = RetryConfig::default().transient_only(); diff --git a/crates/goose-providers/src/thinking.rs b/crates/goose-provider-types/src/thinking.rs similarity index 100% rename from crates/goose-providers/src/thinking.rs rename to crates/goose-provider-types/src/thinking.rs diff --git a/crates/goose-provider-types/src/utils.rs b/crates/goose-provider-types/src/utils.rs new file mode 100644 index 0000000000..1b7777ff2d --- /dev/null +++ b/crates/goose-provider-types/src/utils.rs @@ -0,0 +1,27 @@ +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 index 7fd0180c6f..5dd8c3e34e 100644 --- a/crates/goose-providers/Cargo.toml +++ b/crates/goose-providers/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "goose-providers" -version.workspace = true +version = "0.1.0-alpha.0" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -13,6 +13,10 @@ 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", @@ -29,31 +33,28 @@ native-tls = [ [dependencies] anyhow = { workspace = true } async-stream = { workspace = true } -base64 = { workspace = true } chrono = { workspace = true } futures = { workspace = true } -once_cell = { workspace = true } -regex = { workspace = true, features = ["unicode"] } +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 } -thiserror = { workspace = true } 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"] } async-trait = { workspace = true } -strum = { workspace = true } tokio = { workspace = true } tokio-stream = { workspace = true, features = ["io-util"] } tokio-util = { workspace = true, features = ["compat"] } -rand = { workspace = true } 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 } -pkcs8 = { version = "0.11.0", default-features = false, features = ["alloc", "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 } @@ -61,6 +62,7 @@ tempfile = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread"] } tokio-stream = { workspace = true } env-lock = { workspace = true } +wiremock.workspace = true [[example]] name = "streaming" diff --git a/crates/goose-providers/examples/declarative.rs b/crates/goose-providers/examples/declarative.rs new file mode 100644 index 0000000000..1c1aa189ea --- /dev/null +++ b/crates/goose-providers/examples/declarative.rs @@ -0,0 +1,27 @@ +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 index 1775cb1d69..66ea1da737 100644 --- a/crates/goose-providers/examples/streaming.rs +++ b/crates/goose-providers/examples/streaming.rs @@ -10,34 +10,30 @@ use goose_providers::{ 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 model = ModelConfig::new("gpt-5.4-mini")?; 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, model.clone()); - let system = "You are a knowledgable geography expert"; - let messages = [Message::user().with_text("what is the capital of France?")]; + let provider = OpenAiProvider::new(api_client); + let model = ModelConfig::new("gpt-5.4-mini"); - let mut stream = provider - .stream( - &model, - "", // session-id - system, - &messages, - &[], - ) - .await?; - - while let Some((Some(msg), _)) = stream.next().await.transpose()? { - print!("{}", msg.as_concat_text()); - } - println!(); - - Ok(()) + stream(provider, model).await } diff --git a/crates/goose-providers/src/anthropic.rs b/crates/goose-providers/src/anthropic.rs new file mode 100644 index 0000000000..33755a6862 --- /dev/null +++ b/crates/goose-providers/src/anthropic.rs @@ -0,0 +1,402 @@ +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/api_client.rs b/crates/goose-providers/src/api_client.rs index 2493e6ada7..34a6d7f021 100644 --- a/crates/goose-providers/src/api_client.rs +++ b/crates/goose-providers/src/api_client.rs @@ -11,10 +11,13 @@ use std::fmt; #[cfg(any(feature = "rustls-tls", feature = "native-tls"))] use std::fs::read_to_string; use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; const DEFAULT_PROVIDER_TIMEOUT_SECS: u64 = 600; -const SESSION_ID_HEADER: &str = "agent-session-id"; + +pub type RequestBuilderDecorator = + Arc Result + Send + Sync>; pub struct ApiClient { client: Client, @@ -24,6 +27,7 @@ pub struct ApiClient { default_query: Vec<(String, String)>, timeout: Duration, tls_config: Option, + request_builder: Option, } pub enum AuthMethod { @@ -225,7 +229,6 @@ pub struct ApiRequestBuilder<'a> { client: &'a ApiClient, path: &'a str, headers: HeaderMap, - session_id: Option<&'a str>, } impl ApiClient { @@ -264,6 +267,7 @@ impl ApiClient { default_query: Vec::new(), timeout, tls_config, + request_builder: None, }) } @@ -339,44 +343,33 @@ impl ApiClient { Ok(self) } - /// - `session_id`: Use `None` only for configuration or pre-session tasks. - pub fn request<'a>( - &'a self, - session_id: Option<&'a str>, - path: &'a str, - ) -> ApiRequestBuilder<'a> { + pub fn with_request_builder(mut self, request_builder: RequestBuilderDecorator) -> Self { + self.request_builder = Some(request_builder); + self + } + + pub fn request<'a>(&'a self, path: &'a str) -> ApiRequestBuilder<'a> { ApiRequestBuilder { client: self, - session_id: session_id.filter(|id| !id.is_empty()), path, headers: HeaderMap::new(), } } - pub async fn api_post( - &self, - session_id: Option<&str>, - path: &str, - payload: &Value, - ) -> Result { - self.request(session_id, path).api_post(payload).await + pub async fn api_post(&self, path: &str, payload: &Value) -> Result { + self.request(path).api_post(payload).await } - pub async fn response_post( - &self, - session_id: Option<&str>, - path: &str, - payload: &Value, - ) -> Result { - self.request(session_id, path).response_post(payload).await + pub async fn response_post(&self, path: &str, payload: &Value) -> Result { + self.request(path).response_post(payload).await } - pub async fn api_get(&self, session_id: Option<&str>, path: &str) -> Result { - self.request(session_id, path).api_get().await + pub async fn api_get(&self, path: &str) -> Result { + self.request(path).api_get().await } - pub async fn response_get(&self, session_id: Option<&str>, path: &str) -> Result { - self.request(session_id, path).response_get().await + pub async fn response_get(&self, path: &str) -> Result { + self.request(path).response_get().await } fn build_url(&self, path: &str) -> Result { @@ -445,17 +438,14 @@ impl<'a> ApiRequestBuilder<'a> { F: FnOnce(url::Url, &Client) -> reqwest::RequestBuilder, { let url = self.client.build_url(self.path)?; - let mut headers = self.headers.clone(); - headers.remove(SESSION_ID_HEADER); - if let Some(session_id) = self.session_id { - let header_name = HeaderName::from_static(SESSION_ID_HEADER); - let header_value = HeaderValue::from_str(session_id)?; - headers.insert(header_name, header_value); - } - + let headers = self.headers.clone(); let mut request = request_builder(url, &self.client.client); request = request.headers(headers); + if let Some(decorator) = &self.client.request_builder { + request = decorator(request)?; + } + request = match &self.client.auth { AuthMethod::NoAuth => request, AuthMethod::BearerToken(token) => { @@ -607,17 +597,9 @@ ShGoCNbfNS+COlPMRAujyDlATZcLs9p4tA== #[cfg(test)] mod tests { use super::*; - use test_case::test_case; - #[test_case(Some("test-session_id-456"), None, Some("test-session_id-456"); "header set")] - #[test_case(Some("new-session"), Some(("Agent-Session-Id", "old-session")), Some("new-session"); "replaces existing")] - #[test_case(None, Some(("Agent-Session-Id", "old-session")), None; "removes existing on none")] - #[test_case(Some(""), Some(("agent-session-id", "old-session")), None; "removes existing on empty")] - fn test_session_id_header( - session_id: Option<&str>, - existing_header: Option<(&str, &str)>, - expected: Option<&str>, - ) { + #[test] + fn test_request_builder_decorator() { let runtime = tokio::runtime::Runtime::new().unwrap(); runtime.block_on(async { let client = ApiClient::new_with_tls( @@ -625,23 +607,22 @@ mod tests { AuthMethod::BearerToken("test-token".to_string()), None, ) - .unwrap(); + .unwrap() + .with_request_builder(Arc::new(|request| { + Ok(request.header("test-my-session-id", "test-session_id-456")) + })); - let mut builder = client.request(session_id, "/test"); - if let Some((key, value)) = existing_header { - builder = builder.header(key, value).unwrap(); - } - let request = builder + let request = client + .request("/test") .send_request(|url, client| client.get(url)) .await .unwrap(); let headers = request.build().unwrap().headers().clone(); - let actual = headers - .get(SESSION_ID_HEADER) + .get("test-my-session-id") .and_then(|value| value.to_str().ok()); - assert_eq!(actual, expected); + assert_eq!(actual, Some("test-session_id-456")); }); } } diff --git a/crates/goose/src/providers/databricks.rs b/crates/goose-providers/src/databricks.rs similarity index 85% rename from crates/goose/src/providers/databricks.rs rename to crates/goose-providers/src/databricks.rs index 012a39d4b2..b857b91371 100644 --- a/crates/goose/src/providers/databricks.rs +++ b/crates/goose-providers/src/databricks.rs @@ -1,39 +1,38 @@ -use anyhow::Result; -use async_trait::async_trait; -use futures::future::BoxFuture; -use goose_providers::formats::openai::{ +use crate::formats::openai::{ extract_reasoning_effort, is_openai_responses_model, openai_reasoning_effort_for_thinking, }; -use goose_providers::images::ImageFormat; +use crate::images::ImageFormat; +use anyhow::Result; +use async_trait::async_trait; use serde_json::Value; use std::collections::HashSet; use std::sync::LazyLock; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use super::api_client::{ApiClient, AuthMethod}; -use super::base::{ - ConfigKey, MessageStream, ModelInfo, Provider, ProviderDef, ProviderMetadata, - DEFAULT_PROVIDER_TIMEOUT_SECS, +use crate::api_client::{ApiClient, AuthMethod, TlsConfig}; +use crate::base::{ConfigKey, MessageStream, ModelInfo, Provider, ProviderMetadata}; +const DEFAULT_PROVIDER_TIMEOUT_SECS: u64 = 600; +use crate::conversation::message::Message; +use crate::databricks_auth::{ + DatabricksAuth, DatabricksAuthProvider, DatabricksOauthTokenProvider, DatabricksRefreshHook, + DatabricksSessionIdProvider, DatabricksTokenResolver, }; -use super::databricks_auth::{DatabricksAuth, DatabricksAuthProvider}; -use super::formats::databricks::{create_request_for_provider, DATABRICKS_PROVIDER_NAME}; -use super::openai_compatible::{ +use crate::errors::ProviderError; +use crate::formats::databricks::create_request_for_provider; +pub use crate::formats::databricks::DATABRICKS_PROVIDER_NAME; +use crate::formats::openai_responses::create_responses_request; +use crate::model::ModelConfig; +use crate::openai_compatible::{ handle_status, map_http_error_to_provider_error, sanitize_url, stream_openai_compat, stream_responses_compat, }; -use super::retry::ProviderRetry; -use crate::config::ConfigError; -use crate::conversation::message::Message; -use crate::instance_id::get_instance_id; -use crate::providers::retry::{ +use crate::request_log::{start_log, LoggerHandleExt}; +use crate::retry::ProviderRetry; +use crate::retry::{ RetryConfig, DEFAULT_BACKOFF_MULTIPLIER, DEFAULT_INITIAL_RETRY_INTERVAL_MS, DEFAULT_MAX_RETRIES, DEFAULT_MAX_RETRY_INTERVAL_MS, }; -use goose_providers::errors::ProviderError; -use goose_providers::formats::openai_responses::create_responses_request; -use goose_providers::model::ModelConfig; -use goose_providers::request_log::{start_log, LoggerHandleExt}; use rmcp::model::Tool; use serde_json::json; @@ -63,7 +62,7 @@ static DATABRICKS_ENDPOINT_INFO_CACHE: LazyLock< Mutex>, > = LazyLock::new(|| Mutex::new(std::collections::HashMap::new())); pub const DATABRICKS_DEFAULT_MODEL: &str = "databricks-claude-sonnet-4"; -const DATABRICKS_DEFAULT_FAST_MODEL: &str = "databricks-claude-haiku-4-5"; +pub const DATABRICKS_DEFAULT_FAST_MODEL: &str = "databricks-claude-haiku-4-5"; pub const DATABRICKS_KNOWN_MODELS: &[&str] = &[ "databricks-claude-sonnet-4-5", "databricks-meta-llama-3-3-70b-instruct", @@ -73,14 +72,13 @@ pub const DATABRICKS_KNOWN_MODELS: &[&str] = &[ pub const DATABRICKS_DOC_URL: &str = "https://docs.databricks.com/en/generative-ai/external-models/index.html"; -#[derive(Debug, serde::Serialize)] +#[derive(serde::Serialize)] pub struct DatabricksProvider { #[serde(skip)] api_client: ApiClient, #[serde(skip)] host: String, auth: DatabricksAuth, - model: ModelConfig, image_format: ImageFormat, #[serde(skip)] retry_config: RetryConfig, @@ -90,40 +88,30 @@ pub struct DatabricksProvider { token_cache: Arc>>, #[serde(skip)] instance_id: Option, + #[serde(skip)] + refresh_hook: Option, + #[serde(skip)] + session_id_provider: Option, } impl DatabricksProvider { pub async fn cleanup() -> Result<()> { - super::oauth::cleanup_oauth_cache() + Ok(()) } - pub async fn from_env( - model: ModelConfig, - tls_config: Option, + #[allow(clippy::too_many_arguments)] + pub fn new( + host: String, + auth: DatabricksAuth, + retry_config: RetryConfig, + tls_config: Option, + oauth_token_provider: Option, + token_resolver: Option, + request_builder: Option, + instance_id: Option, + refresh_hook: Option, + session_id_provider: Option, ) -> Result { - let config = crate::config::Config::global(); - - let mut host: Result = config.get_param("DATABRICKS_HOST"); - if host.is_err() { - host = config.get_secret("DATABRICKS_HOST") - } - - if host.is_err() { - return Err(ConfigError::NotFound( - "Did not find DATABRICKS_HOST in either config file or keyring".to_string(), - ) - .into()); - } - - let host = host?; - let retry_config = Self::load_retry_config(config); - - let auth = if let Ok(api_key) = config.get_secret("DATABRICKS_TOKEN") { - DatabricksAuth::token(api_key) - } else { - DatabricksAuth::oauth(host.clone()) - }; - let token_cache = Arc::new(Mutex::new(match &auth { DatabricksAuth::Token(t) => Some(t.clone()), _ => None, @@ -132,57 +120,49 @@ impl DatabricksProvider { let auth_method = AuthMethod::Custom(Box::new(DatabricksAuthProvider { auth: auth.clone(), token_cache: token_cache.clone(), + oauth_token_provider, + token_resolver, })); - let api_client = ApiClient::with_timeout_and_tls( + let mut api_client = ApiClient::with_timeout_and_tls( host.clone(), auth_method, Duration::from_secs(DEFAULT_PROVIDER_TIMEOUT_SECS), - tls_config.clone(), + tls_config, )?; + if let Some(request_builder) = request_builder { + api_client = api_client.with_request_builder(request_builder); + } - let mut provider = Self { + Ok(Self { api_client, host, auth, - model: model.clone(), image_format: ImageFormat::OpenAi, retry_config, name: DATABRICKS_PROVIDER_NAME.to_string(), token_cache, - instance_id: Self::resolve_instance_id(), - }; - provider.model = crate::model_config::with_configured_fast_model( - model, - DATABRICKS_PROVIDER_NAME, - DATABRICKS_DEFAULT_FAST_MODEL, - )?; - Ok(provider) + instance_id, + refresh_hook, + session_id_provider, + }) } - fn load_retry_config(config: &crate::config::Config) -> RetryConfig { - let max_retries = config - .get_param("DATABRICKS_MAX_RETRIES") - .ok() - .and_then(|v: String| v.parse::().ok()) + pub fn load_retry_config(get_param: impl Fn(&str) -> Option) -> RetryConfig { + let max_retries = get_param("DATABRICKS_MAX_RETRIES") + .and_then(|v| v.parse::().ok()) .unwrap_or(DEFAULT_MAX_RETRIES); - let initial_interval_ms = config - .get_param("DATABRICKS_INITIAL_RETRY_INTERVAL_MS") - .ok() - .and_then(|v: String| v.parse::().ok()) + let initial_interval_ms = get_param("DATABRICKS_INITIAL_RETRY_INTERVAL_MS") + .and_then(|v| v.parse::().ok()) .unwrap_or(DEFAULT_INITIAL_RETRY_INTERVAL_MS); - let backoff_multiplier = config - .get_param("DATABRICKS_BACKOFF_MULTIPLIER") - .ok() - .and_then(|v: String| v.parse::().ok()) + let backoff_multiplier = get_param("DATABRICKS_BACKOFF_MULTIPLIER") + .and_then(|v| v.parse::().ok()) .unwrap_or(DEFAULT_BACKOFF_MULTIPLIER); - let max_interval_ms = config - .get_param("DATABRICKS_MAX_RETRY_INTERVAL_MS") - .ok() - .and_then(|v: String| v.parse::().ok()) + let max_interval_ms = get_param("DATABRICKS_MAX_RETRY_INTERVAL_MS") + .and_then(|v| v.parse::().ok()) .unwrap_or(DEFAULT_MAX_RETRY_INTERVAL_MS); RetryConfig::new( @@ -193,17 +173,6 @@ impl DatabricksProvider { ) } - fn resolve_instance_id() -> Option { - let enabled = crate::config::Config::global() - .get_param::("GOOSE_DATABRICKS_CLIENT_REQUEST_ID") - .unwrap_or(false); - if enabled { - Some(get_instance_id().to_string()) - } else { - None - } - } - fn is_claude_model(model_name: &str) -> bool { model_name.to_lowercase().contains("claude") } @@ -344,13 +313,10 @@ impl DatabricksProvider { ) -> Result { let response = self .api_client - .request( - None, - &format!( - "api/2.0/serving-endpoints/{}", - urlencoding::encode(endpoint_name) - ), - ) + .request(&format!( + "api/2.0/serving-endpoints/{}", + urlencoding::encode(endpoint_name) + )) .response_get() .await .map_err(|e| { @@ -487,12 +453,12 @@ impl DatabricksProvider { fn model_info_from_endpoint(info: DatabricksEndpointInfo) -> ModelInfo { let context_model = info.upstream_model_name.as_deref().unwrap_or(&info.name); - let context_limit = ModelConfig::new_or_fail(context_model) + let context_limit = ModelConfig::new(context_model) .with_canonical_limits(DATABRICKS_PROVIDER_NAME) .context_limit(); let reasoning = info .reasoning - .unwrap_or_else(|| ModelConfig::new_or_fail(context_model).is_reasoning_model()); + .unwrap_or_else(|| ModelConfig::new(context_model).is_reasoning_model()); ModelInfo { name: info.name, @@ -525,7 +491,7 @@ impl DatabricksProvider { } } -impl goose_providers::base::ProviderDescriptor for DatabricksProvider { +impl crate::base::ProviderDescriptor for DatabricksProvider { fn metadata() -> ProviderMetadata { ProviderMetadata::new( DATABRICKS_PROVIDER_NAME, @@ -539,18 +505,7 @@ impl goose_providers::base::ProviderDescriptor for DatabricksProvider { ConfigKey::new("DATABRICKS_TOKEN", false, true, None, true), ], ) - } -} - -impl ProviderDef for DatabricksProvider { - type Provider = Self; - - fn from_env( - model: ModelConfig, - _extensions: Vec, - tls_config: Option, - ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model, tls_config)) + .with_fast_model(DATABRICKS_DEFAULT_FAST_MODEL) } } @@ -565,24 +520,26 @@ impl Provider for DatabricksProvider { } async fn refresh_credentials(&self) -> Result<(), ProviderError> { - crate::config::Config::global().invalidate_secrets_cache(); + if let Some(refresh_hook) = &self.refresh_hook { + refresh_hook(); + } *self.token_cache.lock().unwrap() = None; tracing::info!("Invalidated secrets cache and token cache for credential refresh"); Ok(()) } - fn get_model_config(&self) -> ModelConfig { - self.model.clone() - } - async fn stream( &self, model_config: &ModelConfig, - session_id: &str, system: &str, messages: &[Message], tools: &[Tool], ) -> Result { + let session_id = self + .session_id_provider + .as_ref() + .and_then(|provider| provider()) + .unwrap_or_default(); let (endpoint_name, _) = extract_reasoning_effort(&model_config.model_name); let endpoint_info = self.resolve_endpoint_info_cached(&endpoint_name).await.ok(); let effective_model_name = endpoint_info @@ -598,7 +555,7 @@ impl Provider for DatabricksProvider { } else { self.get_endpoint_path(&model_config.model_name, is_responses_model) }; - let client_request_id = self.build_client_request_id(session_id); + let client_request_id = self.build_client_request_id(&session_id); if is_responses_model { let responses_model_config; @@ -638,10 +595,7 @@ impl Provider for DatabricksProvider { let response = self .with_retry(|| async { let payload_clone = payload.clone(); - let resp = self - .api_client - .response_post(Some(session_id), &path, &payload_clone) - .await?; + let resp = self.api_client.response_post(&path, &payload_clone).await?; handle_status(resp).await }) .await @@ -701,10 +655,7 @@ impl Provider for DatabricksProvider { let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { - let resp = self - .api_client - .response_post(Some(session_id), &path, &payload) - .await?; + let resp = self.api_client.response_post(&path, &payload).await?; if !resp.status().is_success() { let status = resp.status(); let url = sanitize_url(resp.url().as_str()); @@ -721,10 +672,7 @@ impl Provider for DatabricksProvider { Err(e) if e.to_string().contains("stream_options") => { payload.as_object_mut().unwrap().remove("stream_options"); self.with_retry(|| async { - let resp = self - .api_client - .response_post(Some(session_id), &path, &payload) - .await?; + let resp = self.api_client.response_post(&path, &payload).await?; if !resp.status().is_success() { let status = resp.status(); let url = sanitize_url(resp.url().as_str()); @@ -766,7 +714,7 @@ impl Provider for DatabricksProvider { async fn fetch_supported_model_info(&self) -> Result, ProviderError> { let response = self .api_client - .request(None, "api/2.0/serving-endpoints") + .request("api/2.0/serving-endpoints") .response_get() .await .map_err(|e| { @@ -812,7 +760,10 @@ impl Provider for DatabricksProvider { Ok(Self::model_info_from_endpoint(endpoint_info)) } - async fn fetch_recommended_model_info(&self) -> Result, ProviderError> { + async fn fetch_recommended_model_info( + &self, + _toolshim: bool, + ) -> Result, ProviderError> { self.fetch_supported_model_info().await } } diff --git a/crates/goose/src/providers/databricks_auth.rs b/crates/goose-providers/src/databricks_auth.rs similarity index 53% rename from crates/goose/src/providers/databricks_auth.rs rename to crates/goose-providers/src/databricks_auth.rs index e2795e0ef5..46973c4481 100644 --- a/crates/goose/src/providers/databricks_auth.rs +++ b/crates/goose-providers/src/databricks_auth.rs @@ -1,15 +1,23 @@ use anyhow::Result; use async_trait::async_trait; use serde::{Deserialize, Serialize}; +use std::future::Future; +use std::pin::Pin; use std::sync::{Arc, Mutex}; -use super::api_client::AuthProvider; -use super::oauth; +use crate::api_client::AuthProvider; const DEFAULT_CLIENT_ID: &str = "databricks-cli"; const DEFAULT_REDIRECT_URL: &str = "http://localhost"; const DEFAULT_SCOPES: &[&str] = &["all-apis", "offline_access"]; +pub type DatabricksOauthTokenFuture = Pin> + Send>>; +pub type DatabricksOauthTokenProvider = + Arc) -> DatabricksOauthTokenFuture + Send + Sync>; +pub type DatabricksTokenResolver = Arc Option + Send + Sync>; +pub type DatabricksRefreshHook = Arc; +pub type DatabricksSessionIdProvider = Arc Option + Send + Sync>; + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum DatabricksAuth { Token(String), @@ -36,9 +44,11 @@ impl DatabricksAuth { } } -pub(crate) struct DatabricksAuthProvider { +pub struct DatabricksAuthProvider { pub auth: DatabricksAuth, pub token_cache: Arc>>, + pub oauth_token_provider: Option, + pub token_resolver: Option, } #[async_trait] @@ -50,9 +60,11 @@ impl AuthProvider for DatabricksAuthProvider { match cached { Some(t) => t, None => { - let fresh = crate::config::Config::global() - .get_secret::("DATABRICKS_TOKEN") - .unwrap_or_else(|_| original.clone()); + let fresh = self + .token_resolver + .as_ref() + .and_then(|resolve| resolve()) + .unwrap_or_else(|| original.clone()); *self.token_cache.lock().unwrap() = Some(fresh.clone()); fresh } @@ -63,8 +75,19 @@ impl AuthProvider for DatabricksAuthProvider { client_id, redirect_url, scopes, - } => oauth::get_oauth_token_async(host, client_id, redirect_url, scopes).await?, + } => { + let Some(provider) = &self.oauth_token_provider else { + anyhow::bail!("Databricks OAuth token provider is not configured") + }; + provider( + host.clone(), + client_id.clone(), + redirect_url.clone(), + scopes.clone(), + ) + .await? + } }; - Ok(("Authorization".to_string(), format!("Bearer {}", token))) + Ok(("Authorization".to_string(), format!("Bearer {token}"))) } } diff --git a/crates/goose/src/providers/databricks_v2.rs b/crates/goose-providers/src/databricks_v2.rs similarity index 72% rename from crates/goose/src/providers/databricks_v2.rs rename to crates/goose-providers/src/databricks_v2.rs index 2c1d4b30cc..af30140590 100644 --- a/crates/goose/src/providers/databricks_v2.rs +++ b/crates/goose-providers/src/databricks_v2.rs @@ -1,10 +1,10 @@ +use crate::formats::anthropic::{AnthropicFormatOptions, ANTHROPIC_PROVIDER_NAME}; +use crate::formats::openai::{self, extract_reasoning_effort, is_openai_responses_model}; +use crate::images::ImageFormat; use anyhow::Result; use async_stream::try_stream; use async_trait::async_trait; -use futures::future::BoxFuture; use futures::TryStreamExt; -use goose_providers::formats::openai::{self, extract_reasoning_effort, is_openai_responses_model}; -use goose_providers::images::ImageFormat; use serde::Serialize; use serde_json::Value; use std::io; @@ -13,25 +13,25 @@ use std::time::Duration; use tokio::pin; use tokio_util::io::StreamReader; -use super::api_client::{ApiClient, AuthMethod}; -use super::base::{ - ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata, - DEFAULT_PROVIDER_TIMEOUT_SECS, -}; -use super::databricks_auth::{DatabricksAuth, DatabricksAuthProvider}; -use super::formats::anthropic; -use super::openai_compatible::{handle_status, stream_openai_compat, stream_responses_compat}; -use super::retry::ProviderRetry; -use crate::config::ConfigError; +use crate::api_client::{ApiClient, AuthMethod, TlsConfig}; +use crate::base::{ConfigKey, MessageStream, Provider, ProviderMetadata}; +const DEFAULT_PROVIDER_TIMEOUT_SECS: u64 = 600; use crate::conversation::message::Message; -use crate::providers::retry::{ +use crate::databricks_auth::{ + DatabricksAuth, DatabricksAuthProvider, DatabricksOauthTokenProvider, DatabricksRefreshHook, + DatabricksTokenResolver, +}; +use crate::errors::ProviderError; +use crate::formats::anthropic; +use crate::formats::openai_responses; +use crate::model::ModelConfig; +use crate::openai_compatible::{handle_status, stream_openai_compat, stream_responses_compat}; +use crate::request_log::{start_log, LoggerHandleExt}; +use crate::retry::ProviderRetry; +use crate::retry::{ RetryConfig, DEFAULT_BACKOFF_MULTIPLIER, DEFAULT_INITIAL_RETRY_INTERVAL_MS, DEFAULT_MAX_RETRIES, DEFAULT_MAX_RETRY_INTERVAL_MS, }; -use goose_providers::errors::ProviderError; -use goose_providers::formats::openai_responses; -use goose_providers::model::ModelConfig; -use goose_providers::request_log::{start_log, LoggerHandleExt}; use rmcp::model::Tool; const DATABRICKS_V2_PROVIDER_NAME: &str = "databricks_v2"; @@ -50,60 +50,35 @@ enum DatabricksV2Route { MlflowChatCompletions, } -#[derive(Debug, Serialize)] +#[derive(Serialize)] pub struct DatabricksV2Provider { #[serde(skip)] api_client: ApiClient, - model: ModelConfig, #[serde(skip)] retry_config: RetryConfig, #[serde(skip)] name: String, #[serde(skip)] token_cache: Arc>>, + #[serde(skip)] + refresh_hook: Option, } impl DatabricksV2Provider { pub async fn cleanup() -> Result<()> { - super::oauth::cleanup_oauth_cache() + Ok(()) } - pub async fn from_env( - model: ModelConfig, - tls_config: Option, - ) -> Result { - let config = crate::config::Config::global(); - - let mut host: Result = config.get_param("DATABRICKS_HOST"); - if host.is_err() { - host = config.get_secret("DATABRICKS_HOST") - } - - if host.is_err() { - return Err(ConfigError::NotFound( - "Did not find DATABRICKS_HOST in either config file or keyring".to_string(), - ) - .into()); - } - - let host = host?; - let retry_config = Self::load_retry_config(config); - - let auth = if let Ok(api_key) = config.get_secret("DATABRICKS_TOKEN") { - DatabricksAuth::token(api_key) - } else { - DatabricksAuth::oauth(host.clone()) - }; - - Self::new(host, auth, model, retry_config, tls_config) - } - - fn new( + #[allow(clippy::too_many_arguments)] + pub fn new( host: String, auth: DatabricksAuth, - model: ModelConfig, retry_config: RetryConfig, - tls_config: Option, + tls_config: Option, + oauth_token_provider: Option, + token_resolver: Option, + request_builder: Option, + refresh_hook: Option, ) -> Result { let token_cache = Arc::new(Mutex::new(match &auth { DatabricksAuth::Token(t) => Some(t.clone()), @@ -113,47 +88,44 @@ impl DatabricksV2Provider { let auth_method = AuthMethod::Custom(Box::new(DatabricksAuthProvider { auth: auth.clone(), token_cache: token_cache.clone(), + oauth_token_provider, + token_resolver, })); - let api_client = ApiClient::with_timeout_and_tls( + let mut api_client = ApiClient::with_timeout_and_tls( host, auth_method, Duration::from_secs(DEFAULT_PROVIDER_TIMEOUT_SECS), tls_config, )?; + if let Some(request_builder) = request_builder { + api_client = api_client.with_request_builder(request_builder); + } Ok(Self { api_client, - model, retry_config, name: DATABRICKS_V2_PROVIDER_NAME.to_string(), token_cache, + refresh_hook, }) } - fn load_retry_config(config: &crate::config::Config) -> RetryConfig { - let max_retries = config - .get_param("DATABRICKS_MAX_RETRIES") - .ok() - .and_then(|v: String| v.parse::().ok()) + pub fn load_retry_config(get_param: impl Fn(&str) -> Option) -> RetryConfig { + let max_retries = get_param("DATABRICKS_MAX_RETRIES") + .and_then(|v| v.parse::().ok()) .unwrap_or(DEFAULT_MAX_RETRIES); - let initial_interval_ms = config - .get_param("DATABRICKS_INITIAL_RETRY_INTERVAL_MS") - .ok() - .and_then(|v: String| v.parse::().ok()) + let initial_interval_ms = get_param("DATABRICKS_INITIAL_RETRY_INTERVAL_MS") + .and_then(|v| v.parse::().ok()) .unwrap_or(DEFAULT_INITIAL_RETRY_INTERVAL_MS); - let backoff_multiplier = config - .get_param("DATABRICKS_BACKOFF_MULTIPLIER") - .ok() - .and_then(|v: String| v.parse::().ok()) + let backoff_multiplier = get_param("DATABRICKS_BACKOFF_MULTIPLIER") + .and_then(|v| v.parse::().ok()) .unwrap_or(DEFAULT_BACKOFF_MULTIPLIER); - let max_interval_ms = config - .get_param("DATABRICKS_MAX_RETRY_INTERVAL_MS") - .ok() - .and_then(|v: String| v.parse::().ok()) + let max_interval_ms = get_param("DATABRICKS_MAX_RETRY_INTERVAL_MS") + .and_then(|v| v.parse::().ok()) .unwrap_or(DEFAULT_MAX_RETRY_INTERVAL_MS); RetryConfig::new( @@ -220,7 +192,6 @@ impl DatabricksV2Provider { async fn stream_openai_responses( &self, model_config: &ModelConfig, - session_id: &str, system: &str, messages: &[Message], tools: &[Tool], @@ -234,7 +205,7 @@ impl DatabricksV2Provider { .with_retry(|| async { let resp = self .api_client - .response_post(Some(session_id), "ai-gateway/openai/v1/responses", &payload) + .response_post("ai-gateway/openai/v1/responses", &payload) .await?; handle_status(resp).await }) @@ -249,7 +220,6 @@ impl DatabricksV2Provider { async fn stream_mlflow_chat_completions( &self, model_config: &ModelConfig, - session_id: &str, system: &str, messages: &[Message], tools: &[Tool], @@ -271,11 +241,7 @@ impl DatabricksV2Provider { .with_retry(|| async { let resp = self .api_client - .response_post( - Some(session_id), - "ai-gateway/mlflow/v1/chat/completions", - &payload, - ) + .response_post("ai-gateway/mlflow/v1/chat/completions", &payload) .await?; handle_status(resp).await }) @@ -290,12 +256,18 @@ impl DatabricksV2Provider { async fn stream_anthropic_messages( &self, model_config: &ModelConfig, - session_id: &str, system: &str, messages: &[Message], tools: &[Tool], ) -> Result { - let mut payload = anthropic::create_request(model_config, system, messages, tools)?; + let mut payload = anthropic::create_request( + ANTHROPIC_PROVIDER_NAME, + model_config, + system, + messages, + tools, + AnthropicFormatOptions::default(), + )?; payload["stream"] = Value::Bool(true); let mut log = start_log(model_config, &payload)?; @@ -303,11 +275,7 @@ impl DatabricksV2Provider { .with_retry(|| async { let resp = self .api_client - .response_post( - Some(session_id), - "ai-gateway/anthropic/v1/messages", - &payload, - ) + .response_post("ai-gateway/anthropic/v1/messages", &payload) .await?; handle_status(resp).await }) @@ -334,7 +302,7 @@ impl DatabricksV2Provider { } } -impl goose_providers::base::ProviderDescriptor for DatabricksV2Provider { +impl crate::base::ProviderDescriptor for DatabricksV2Provider { fn metadata() -> ProviderMetadata { ProviderMetadata::new( DATABRICKS_V2_PROVIDER_NAME, @@ -351,18 +319,6 @@ impl goose_providers::base::ProviderDescriptor for DatabricksV2Provider { } } -impl ProviderDef for DatabricksV2Provider { - type Provider = Self; - - fn from_env( - model: ModelConfig, - _extensions: Vec, - tls_config: Option, - ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model, tls_config)) - } -} - #[async_trait] impl Provider for DatabricksV2Provider { fn get_name(&self) -> &str { @@ -374,41 +330,32 @@ impl Provider for DatabricksV2Provider { } async fn refresh_credentials(&self) -> Result<(), ProviderError> { - crate::config::Config::global().invalidate_secrets_cache(); + if let Some(refresh_hook) = &self.refresh_hook { + refresh_hook(); + } *self.token_cache.lock().unwrap() = None; Ok(()) } - fn get_model_config(&self) -> ModelConfig { - self.model.clone() - } - async fn stream( &self, model_config: &ModelConfig, - session_id: &str, system: &str, messages: &[Message], tools: &[Tool], ) -> Result { match Self::route_for_model(&model_config.model_name) { DatabricksV2Route::OpenAiResponses => { - self.stream_openai_responses(model_config, session_id, system, messages, tools) + self.stream_openai_responses(model_config, system, messages, tools) .await } DatabricksV2Route::AnthropicMessages => { - self.stream_anthropic_messages(model_config, session_id, system, messages, tools) + self.stream_anthropic_messages(model_config, system, messages, tools) .await } DatabricksV2Route::MlflowChatCompletions => { - self.stream_mlflow_chat_completions( - model_config, - session_id, - system, - messages, - tools, - ) - .await + self.stream_mlflow_chat_completions(model_config, system, messages, tools) + .await } } } @@ -426,15 +373,11 @@ impl Provider for DatabricksV2Provider { path.push_str(&format!("&page_token={}", urlencoding::encode(token))); } - let response = self - .api_client - .response_get(None, &path) - .await - .map_err(|e| { - ProviderError::RequestFailed(format!( - "Failed to fetch Databricks AI Gateway endpoints: {e}" - )) - })?; + let response = self.api_client.response_get(&path).await.map_err(|e| { + ProviderError::RequestFailed(format!( + "Failed to fetch Databricks AI Gateway endpoints: {e}" + )) + })?; if !response.status().is_success() { let status = response.status(); diff --git a/crates/goose-providers/src/declarative.rs b/crates/goose-providers/src/declarative.rs new file mode 100644 index 0000000000..4062bb6927 --- /dev/null +++ b/crates/goose-providers/src/declarative.rs @@ -0,0 +1,591 @@ +#[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/src/providers/declarative/alibaba.json b/crates/goose-providers/src/declarative/definitions/alibaba.json similarity index 100% rename from crates/goose/src/providers/declarative/alibaba.json rename to crates/goose-providers/src/declarative/definitions/alibaba.json diff --git a/crates/goose/src/providers/declarative/atomic_chat.json b/crates/goose-providers/src/declarative/definitions/atomic_chat.json similarity index 100% rename from crates/goose/src/providers/declarative/atomic_chat.json rename to crates/goose-providers/src/declarative/definitions/atomic_chat.json diff --git a/crates/goose/src/providers/declarative/cerebras.json b/crates/goose-providers/src/declarative/definitions/cerebras.json similarity index 100% rename from crates/goose/src/providers/declarative/cerebras.json rename to crates/goose-providers/src/declarative/definitions/cerebras.json diff --git a/crates/goose/src/providers/declarative/deepseek.json b/crates/goose-providers/src/declarative/definitions/deepseek.json similarity index 100% rename from crates/goose/src/providers/declarative/deepseek.json rename to crates/goose-providers/src/declarative/definitions/deepseek.json diff --git a/crates/goose/src/providers/declarative/empiriolabs.json b/crates/goose-providers/src/declarative/definitions/empiriolabs.json similarity index 97% rename from crates/goose/src/providers/declarative/empiriolabs.json rename to crates/goose-providers/src/declarative/definitions/empiriolabs.json index 5357e73972..f0d6f536df 100644 --- a/crates/goose/src/providers/declarative/empiriolabs.json +++ b/crates/goose-providers/src/declarative/definitions/empiriolabs.json @@ -1,7 +1,7 @@ { "name": "empiriolabs", "engine": "openai", - "display_name": "EmpirioLabs", + "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", diff --git a/crates/goose-providers/src/declarative/definitions/fireworks.json b/crates/goose-providers/src/declarative/definitions/fireworks.json new file mode 100644 index 0000000000..85b56bb674 --- /dev/null +++ b/crates/goose-providers/src/declarative/definitions/fireworks.json @@ -0,0 +1,55 @@ +{ + "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/src/providers/declarative/futurmix.json b/crates/goose-providers/src/declarative/definitions/futurmix.json similarity index 100% rename from crates/goose/src/providers/declarative/futurmix.json rename to crates/goose-providers/src/declarative/definitions/futurmix.json diff --git a/crates/goose/src/providers/declarative/groq.json b/crates/goose-providers/src/declarative/definitions/groq.json similarity index 95% rename from crates/goose/src/providers/declarative/groq.json rename to crates/goose-providers/src/declarative/definitions/groq.json index a817f55c0b..6adef5395d 100644 --- a/crates/goose/src/providers/declarative/groq.json +++ b/crates/goose-providers/src/declarative/definitions/groq.json @@ -57,5 +57,6 @@ "max_tokens": 1024 } ], - "supports_streaming": true + "supports_streaming": true, + "preserves_thinking": false } diff --git a/crates/goose-providers/src/declarative/definitions/iflytek.json b/crates/goose-providers/src/declarative/definitions/iflytek.json new file mode 100644 index 0000000000..a0c2da0387 --- /dev/null +++ b/crates/goose-providers/src/declarative/definitions/iflytek.json @@ -0,0 +1,22 @@ +{ + "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 new file mode 100644 index 0000000000..6cffd3ba6a --- /dev/null +++ b/crates/goose-providers/src/declarative/definitions/iflytek_astron.json @@ -0,0 +1,41 @@ +{ + "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/src/providers/declarative/inception.json b/crates/goose-providers/src/declarative/definitions/inception.json similarity index 100% rename from crates/goose/src/providers/declarative/inception.json rename to crates/goose-providers/src/declarative/definitions/inception.json diff --git a/crates/goose/src/providers/declarative/llama_swap.json b/crates/goose-providers/src/declarative/definitions/llama_swap.json similarity index 100% rename from crates/goose/src/providers/declarative/llama_swap.json rename to crates/goose-providers/src/declarative/definitions/llama_swap.json diff --git a/crates/goose/src/providers/declarative/lmstudio.json b/crates/goose-providers/src/declarative/definitions/lmstudio.json similarity index 100% rename from crates/goose/src/providers/declarative/lmstudio.json rename to crates/goose-providers/src/declarative/definitions/lmstudio.json diff --git a/crates/goose/src/providers/declarative/minimax.json b/crates/goose-providers/src/declarative/definitions/minimax.json similarity index 100% rename from crates/goose/src/providers/declarative/minimax.json rename to crates/goose-providers/src/declarative/definitions/minimax.json diff --git a/crates/goose/src/providers/declarative/mistral.json b/crates/goose-providers/src/declarative/definitions/mistral.json similarity index 100% rename from crates/goose/src/providers/declarative/mistral.json rename to crates/goose-providers/src/declarative/definitions/mistral.json diff --git a/crates/goose/src/providers/declarative/moonshot.json b/crates/goose-providers/src/declarative/definitions/moonshot.json similarity index 100% rename from crates/goose/src/providers/declarative/moonshot.json rename to crates/goose-providers/src/declarative/definitions/moonshot.json diff --git a/crates/goose/src/providers/declarative/nearai.json b/crates/goose-providers/src/declarative/definitions/nearai.json similarity index 91% rename from crates/goose/src/providers/declarative/nearai.json rename to crates/goose-providers/src/declarative/definitions/nearai.json index 4386d04ded..9707d0cb07 100644 --- a/crates/goose/src/providers/declarative/nearai.json +++ b/crates/goose-providers/src/declarative/definitions/nearai.json @@ -8,6 +8,11 @@ "catalog_provider_id": "nearai", "dynamic_models": true, "models": [ + { + "name": "zai-org/GLM-5.2", + "context_limit": 1000000, + "reasoning": true + }, { "name": "zai-org/GLM-5.1-FP8", "context_limit": 202752, diff --git a/crates/goose/src/providers/declarative/novita.json b/crates/goose-providers/src/declarative/definitions/novita.json similarity index 89% rename from crates/goose/src/providers/declarative/novita.json rename to crates/goose-providers/src/declarative/definitions/novita.json index 9fc1bfad9b..056aca068c 100644 --- a/crates/goose/src/providers/declarative/novita.json +++ b/crates/goose-providers/src/declarative/definitions/novita.json @@ -17,6 +17,11 @@ "context_limit": 204800, "max_tokens": 131072 }, + { + "name": "zai-org/glm-5.2", + "context_limit": 1000000, + "max_tokens": 131072 + }, { "name": "zai-org/glm-5.1", "context_limit": 204800, diff --git a/crates/goose/src/providers/declarative/nvidia.json b/crates/goose-providers/src/declarative/definitions/nvidia.json similarity index 100% rename from crates/goose/src/providers/declarative/nvidia.json rename to crates/goose-providers/src/declarative/definitions/nvidia.json diff --git a/crates/goose/src/providers/declarative/ollama_cloud.json b/crates/goose-providers/src/declarative/definitions/ollama_cloud.json similarity index 100% rename from crates/goose/src/providers/declarative/ollama_cloud.json rename to crates/goose-providers/src/declarative/definitions/ollama_cloud.json diff --git a/crates/goose/src/providers/declarative/omlx.json b/crates/goose-providers/src/declarative/definitions/omlx.json similarity index 100% rename from crates/goose/src/providers/declarative/omlx.json rename to crates/goose-providers/src/declarative/definitions/omlx.json diff --git a/crates/goose/src/providers/declarative/opencode_go.json b/crates/goose-providers/src/declarative/definitions/opencode_go.json similarity index 100% rename from crates/goose/src/providers/declarative/opencode_go.json rename to crates/goose-providers/src/declarative/definitions/opencode_go.json diff --git a/crates/goose/src/providers/declarative/orcarouter.json b/crates/goose-providers/src/declarative/definitions/orcarouter.json similarity index 100% rename from crates/goose/src/providers/declarative/orcarouter.json rename to crates/goose-providers/src/declarative/definitions/orcarouter.json diff --git a/crates/goose/src/providers/declarative/ovhcloud.json b/crates/goose-providers/src/declarative/definitions/ovhcloud.json similarity index 100% rename from crates/goose/src/providers/declarative/ovhcloud.json rename to crates/goose-providers/src/declarative/definitions/ovhcloud.json diff --git a/crates/goose/src/providers/declarative/perplexity.json b/crates/goose-providers/src/declarative/definitions/perplexity.json similarity index 100% rename from crates/goose/src/providers/declarative/perplexity.json rename to crates/goose-providers/src/declarative/definitions/perplexity.json diff --git a/crates/goose/src/providers/declarative/routstr.json b/crates/goose-providers/src/declarative/definitions/routstr.json similarity index 100% rename from crates/goose/src/providers/declarative/routstr.json rename to crates/goose-providers/src/declarative/definitions/routstr.json diff --git a/crates/goose/src/providers/declarative/saladcloud.json b/crates/goose-providers/src/declarative/definitions/saladcloud.json similarity index 100% rename from crates/goose/src/providers/declarative/saladcloud.json rename to crates/goose-providers/src/declarative/definitions/saladcloud.json diff --git a/crates/goose/src/providers/declarative/scaleway.json b/crates/goose-providers/src/declarative/definitions/scaleway.json similarity index 100% rename from crates/goose/src/providers/declarative/scaleway.json rename to crates/goose-providers/src/declarative/definitions/scaleway.json diff --git a/crates/goose/src/providers/declarative/tanzu.json b/crates/goose-providers/src/declarative/definitions/tanzu.json similarity index 100% rename from crates/goose/src/providers/declarative/tanzu.json rename to crates/goose-providers/src/declarative/definitions/tanzu.json diff --git a/crates/goose/src/providers/declarative/tensorix.json b/crates/goose-providers/src/declarative/definitions/tensorix.json similarity index 100% rename from crates/goose/src/providers/declarative/tensorix.json rename to crates/goose-providers/src/declarative/definitions/tensorix.json diff --git a/crates/goose/src/providers/declarative/together.json b/crates/goose-providers/src/declarative/definitions/together.json similarity index 100% rename from crates/goose/src/providers/declarative/together.json rename to crates/goose-providers/src/declarative/definitions/together.json diff --git a/crates/goose/src/providers/declarative/venice.json b/crates/goose-providers/src/declarative/definitions/venice.json similarity index 100% rename from crates/goose/src/providers/declarative/venice.json rename to crates/goose-providers/src/declarative/definitions/venice.json diff --git a/crates/goose/src/providers/declarative/vercel_ai_gateway.json b/crates/goose-providers/src/declarative/definitions/vercel_ai_gateway.json similarity index 100% rename from crates/goose/src/providers/declarative/vercel_ai_gateway.json rename to crates/goose-providers/src/declarative/definitions/vercel_ai_gateway.json diff --git a/crates/goose/src/providers/declarative/zai.json b/crates/goose-providers/src/declarative/definitions/zai.json similarity index 95% rename from crates/goose/src/providers/declarative/zai.json rename to crates/goose-providers/src/declarative/definitions/zai.json index 3a699b8124..6f9b9ceb0f 100644 --- a/crates/goose/src/providers/declarative/zai.json +++ b/crates/goose-providers/src/declarative/definitions/zai.json @@ -19,6 +19,7 @@ "fast_model": "glm-4.5-air", "preserves_thinking": true, "models": [ + {"name": "glm-5.2", "context_limit": 1000000}, {"name": "glm-5.1", "context_limit": 200000}, {"name": "glm-5", "context_limit": 204800}, {"name": "glm-5-turbo", "context_limit": 200000}, diff --git a/crates/goose/src/providers/declarative/zhipu.json b/crates/goose-providers/src/declarative/definitions/zhipu.json similarity index 89% rename from crates/goose/src/providers/declarative/zhipu.json rename to crates/goose-providers/src/declarative/definitions/zhipu.json index 93d30ab498..e2e54ffd48 100644 --- a/crates/goose/src/providers/declarative/zhipu.json +++ b/crates/goose-providers/src/declarative/definitions/zhipu.json @@ -11,7 +11,7 @@ "required": false, "secret": false, "default": "https://open.bigmodel.cn/api/paas/v4", - "description": "Zhipu API base URL. Change to https://open.bigmodel.cn/api/coding/paas/v4 for Coding Plan models like GLM-5.1." + "description": "Zhipu API base URL. Change to https://open.bigmodel.cn/api/coding/paas/v4 for Coding Plan models like GLM-5.2." } ], "catalog_provider_id": "zhipuai", @@ -22,7 +22,8 @@ {"name": "glm-4.6", "context_limit": 204800}, {"name": "glm-4.7", "context_limit": 204800}, {"name": "glm-4.7-flash", "context_limit": 200000}, - {"name": "glm-5", "context_limit": 204800} + {"name": "glm-5", "context_limit": 204800}, + {"name": "glm-5.2", "context_limit": 1000000, "reasoning": true} ], "preserves_thinking": true, "supports_streaming": true diff --git a/crates/goose-providers/src/declarative/macros.rs b/crates/goose-providers/src/declarative/macros.rs new file mode 100644 index 0000000000..a2fe7ef1f8 --- /dev/null +++ b/crates/goose-providers/src/declarative/macros.rs @@ -0,0 +1,44 @@ +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/formats.rs b/crates/goose-providers/src/formats.rs deleted file mode 100644 index 4b7d8f21a8..0000000000 --- a/crates/goose-providers/src/formats.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod openai; -pub mod openai_responses; diff --git a/crates/goose/src/providers/google.rs b/crates/goose-providers/src/google.rs similarity index 72% rename from crates/goose/src/providers/google.rs rename to crates/goose-providers/src/google.rs index 6d2be4b78f..934a488ccd 100644 --- a/crates/goose/src/providers/google.rs +++ b/crates/goose-providers/src/google.rs @@ -1,19 +1,18 @@ -use super::api_client::{ApiClient, AuthMethod}; -use super::base::MessageStream; -use super::openai_compatible::{handle_status, map_http_error_to_provider_error, sanitize_url}; -use super::retry::ProviderRetry; +use crate::api_client::{ApiClient, AuthMethod}; +use crate::base::MessageStream; use crate::conversation::message::Message; -use goose_providers::errors::ProviderError; +use crate::errors::ProviderError; +use crate::openai_compatible::{handle_status, map_http_error_to_provider_error, sanitize_url}; +use crate::retry::ProviderRetry; -use crate::providers::base::{ConfigKey, Provider, ProviderDef, ProviderMetadata}; -use crate::providers::formats::google::{create_request, response_to_streaming_message}; +use crate::base::{ConfigKey, Provider, ProviderMetadata}; +use crate::formats::google::{create_request_with_thinking_budget, response_to_streaming_message}; +use crate::model::ModelConfig; +use crate::request_log::{start_log, LoggerHandleExt}; use anyhow::Result; use async_stream::try_stream; use async_trait::async_trait; -use futures::future::BoxFuture; use futures::TryStreamExt; -use goose_providers::model::ModelConfig; -use goose_providers::request_log::{start_log, LoggerHandleExt}; use rmcp::model::Tool; use serde_json::Value; use std::io; @@ -22,7 +21,7 @@ use tokio_stream::StreamExt; use tokio_util::codec::{FramedRead, LinesCodec}; use tokio_util::io::StreamReader; -pub(crate) const GOOGLE_PROVIDER_NAME: &str = "google"; +pub const GOOGLE_PROVIDER_NAME: &str = "google"; pub const GOOGLE_API_HOST: &str = "https://generativelanguage.googleapis.com"; pub const GOOGLE_DEFAULT_MODEL: &str = "gemini-2.5-pro"; pub const GOOGLE_DEFAULT_FAST_MODEL: &str = "gemini-2.5-flash"; @@ -60,59 +59,50 @@ pub const GOOGLE_DOC_URL: &str = "https://ai.google.dev/gemini-api/docs/models"; pub struct GoogleProvider { #[serde(skip)] api_client: ApiClient, - model: ModelConfig, #[serde(skip)] name: String, + #[serde(skip)] + thinking_budget: Option, } impl GoogleProvider { - pub async fn from_env( - model: ModelConfig, - tls_config: Option, + pub fn new( + host: String, + api_key: String, + tls_config: Option, + request_builder: Option, + thinking_budget: Option, ) -> Result { - let model = crate::model_config::with_configured_fast_model( - model, - GOOGLE_PROVIDER_NAME, - GOOGLE_DEFAULT_FAST_MODEL, - )?; - - let config = crate::config::Config::global(); - let api_key: String = config.get_secret("GOOGLE_API_KEY")?; - let host: String = config - .get_param("GOOGLE_HOST") - .unwrap_or_else(|_| GOOGLE_API_HOST.to_string()); - let auth = AuthMethod::ApiKey { header_name: "x-goog-api-key".to_string(), key: api_key, }; - let api_client = ApiClient::new_with_tls(host, auth, tls_config)? + let mut api_client = ApiClient::new_with_tls(host, auth, tls_config)? .with_header("Content-Type", "application/json")?; + if let Some(request_builder) = request_builder { + api_client = api_client.with_request_builder(request_builder); + } Ok(Self { api_client, - model, name: GOOGLE_PROVIDER_NAME.to_string(), + thinking_budget, }) } async fn post_stream( &self, - session_id: Option<&str>, model_name: &str, payload: &Value, ) -> Result { let path = format!("v1beta/models/{}:streamGenerateContent?alt=sse", model_name); - let response = self - .api_client - .response_post(session_id, &path, payload) - .await?; + let response = self.api_client.response_post(&path, payload).await?; handle_status(response).await } } -impl goose_providers::base::ProviderDescriptor for GoogleProvider { +impl crate::base::ProviderDescriptor for GoogleProvider { fn metadata() -> ProviderMetadata { ProviderMetadata::new( GOOGLE_PROVIDER_NAME, @@ -126,6 +116,7 @@ impl goose_providers::base::ProviderDescriptor for GoogleProvider { ConfigKey::new("GOOGLE_HOST", false, false, Some(GOOGLE_API_HOST), false), ], ) + .with_fast_model(GOOGLE_DEFAULT_FAST_MODEL) .with_setup_steps(vec![ "Go to https://aistudio.google.com and sign in with your Google account", "Click 'Get API key' on the left sidebar", @@ -135,32 +126,16 @@ impl goose_providers::base::ProviderDescriptor for GoogleProvider { } } -impl ProviderDef for GoogleProvider { - type Provider = Self; - - fn from_env( - model: ModelConfig, - _extensions: Vec, - tls_config: Option, - ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model, tls_config)) - } -} - #[async_trait] impl Provider for GoogleProvider { fn get_name(&self) -> &str { &self.name } - fn get_model_config(&self) -> ModelConfig { - self.model.clone() - } - async fn fetch_supported_models(&self) -> Result, ProviderError> { let response = self .api_client - .request(None, "v1beta/models") + .request("v1beta/models") .response_get() .await?; let status = response.status(); @@ -192,19 +167,21 @@ impl Provider for GoogleProvider { async fn stream( &self, model_config: &ModelConfig, - session_id: &str, system: &str, messages: &[Message], tools: &[Tool], ) -> Result { - let payload = create_request(model_config, system, messages, tools)?; + let payload = create_request_with_thinking_budget( + model_config, + system, + messages, + tools, + self.thinking_budget, + )?; let mut log = start_log(model_config, &payload)?; let response = self - .with_retry(|| async { - self.post_stream(Some(session_id), &model_config.model_name, &payload) - .await - }) + .with_retry(|| async { self.post_stream(&model_config.model_name, &payload).await }) .await .inspect_err(|e| { let _ = log.error(e); diff --git a/crates/goose-providers/src/json.rs b/crates/goose-providers/src/json.rs deleted file mode 100644 index c8a23418b2..0000000000 --- a/crates/goose-providers/src/json.rs +++ /dev/null @@ -1,221 +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 -} - -#[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" - ); - } -} diff --git a/crates/goose-providers/src/lib.rs b/crates/goose-providers/src/lib.rs index e450c7c08f..77e011b176 100644 --- a/crates/goose-providers/src/lib.rs +++ b/crates/goose-providers/src/lib.rs @@ -1,19 +1,21 @@ +pub mod anthropic; pub mod api_client; -pub mod base; -pub mod canonical; -pub mod conversation; -pub mod errors; -pub mod formats; -pub mod goose_mode; +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; -pub mod images; -pub mod json; -pub(crate) mod mcp_utils; -pub mod model; +#[cfg(feature = "local-inference")] +pub mod local_inference; +pub mod ollama; pub mod openai; pub mod openai_compatible; -pub mod permission; -pub mod request_log; -pub mod retry; -pub mod thinking; -pub mod utils; + +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 new file mode 100644 index 0000000000..954840b856 --- /dev/null +++ b/crates/goose-providers/src/local_inference.rs @@ -0,0 +1 @@ +pub use goose_local_inference::*; diff --git a/crates/goose-providers/src/ollama.rs b/crates/goose-providers/src/ollama.rs new file mode 100644 index 0000000000..f6ac65fcc0 --- /dev/null +++ b/crates/goose-providers/src/ollama.rs @@ -0,0 +1,764 @@ +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 index f0a915eb74..3c920ca144 100644 --- a/crates/goose-providers/src/openai.rs +++ b/crates/goose-providers/src/openai.rs @@ -1,12 +1,14 @@ 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::ProviderUsage; +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_usage, response_to_message, OpenAiFormatOptions, + 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, @@ -20,6 +22,7 @@ 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; @@ -57,9 +60,15 @@ pub const OPEN_AI_KNOWN_MODELS: &[(&str, usize)] = &[ ("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); @@ -125,7 +134,6 @@ pub struct OpenAiProvider { base_path: String, organization: Option, project: Option, - model: ModelConfig, custom_headers: Option>, supports_streaming: bool, name: String, @@ -133,6 +141,8 @@ pub struct OpenAiProvider { dynamic_models: Option, skip_canonical_filtering: bool, preserve_thinking_context: bool, + #[serde(skip)] + n_ctx_cache: Arc>>>, } /// Builder for [`OpenAiProvider`]. @@ -145,7 +155,6 @@ pub struct OpenAiProviderBuilder { base_path: String, organization: Option, project: Option, - model: ModelConfig, custom_headers: Option>, supports_streaming: bool, name: String, @@ -156,13 +165,12 @@ pub struct OpenAiProviderBuilder { } impl OpenAiProviderBuilder { - pub fn new(api_client: ApiClient, model: ModelConfig) -> Self { + pub fn new(api_client: ApiClient) -> Self { Self { api_client, base_path: OPEN_AI_DEFAULT_BASE_PATH.to_string(), organization: None, project: None, - model, custom_headers: None, supports_streaming: true, name: OPEN_AI_PROVIDER_NAME.to_string(), @@ -178,6 +186,19 @@ impl OpenAiProviderBuilder { 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 @@ -193,11 +214,6 @@ impl OpenAiProviderBuilder { self } - pub fn model(mut self, model: ModelConfig) -> Self { - self.model = model; - self - } - pub fn custom_headers(mut self, custom_headers: Option>) -> Self { self.custom_headers = custom_headers; self @@ -239,7 +255,6 @@ impl OpenAiProviderBuilder { base_path: self.base_path, organization: self.organization, project: self.project, - model: self.model, custom_headers: self.custom_headers, supports_streaming: self.supports_streaming, name: self.name, @@ -247,19 +262,19 @@ impl OpenAiProviderBuilder { 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, model: ModelConfig) -> Self { + pub fn new(api_client: ApiClient) -> Self { Self { api_client, base_path: OPEN_AI_DEFAULT_BASE_PATH.to_string(), organization: None, project: None, - model, custom_headers: None, supports_streaming: true, name: OPEN_AI_PROVIDER_NAME.to_string(), @@ -267,6 +282,7 @@ impl OpenAiProvider { dynamic_models: None, skip_canonical_filtering: false, preserve_thinking_context: false, + n_ctx_cache: Arc::new(Mutex::new(HashMap::new())), } } @@ -390,36 +406,10 @@ impl OpenAiProvider { } } - /// Fill the model's context limit from the API when it isn't already set. - /// - /// An existing value may be an explicit GOOSE_CONTEXT_LIMIT, an ACP/server - /// per-session override, or a GOOSE_PREDEFINED_MODELS entry, none of which we - /// should overwrite. llama.cpp and Ollama report the real allocated window via - /// the non-standard meta.n_ctx field; reading it 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 /v1/models can't stall provider - /// construction (the shared ApiClient uses OPENAI_TIMEOUT, up to 600s). - pub async fn probe_context_limit_if_unset(&mut self) { - if self.model.context_limit.is_some() { - return; - } - const N_CTX_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); - let model_name = self.model.model_name.clone(); - if let Ok(Some(n_ctx)) = - tokio::time::timeout(N_CTX_PROBE_TIMEOUT, self.fetch_n_ctx_from_api(&model_name)).await - { - self.model.context_limit = Some(n_ctx); - } - } - 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(None, &models_path) - .response_get() - .await?; + 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(); @@ -454,7 +444,7 @@ impl OpenAiProvider { Self::map_base_path(&self.base_path, "models", OPEN_AI_DEFAULT_MODELS_PATH); let response = self .api_client - .request(None, &models_path) + .request(&models_path) .response_get() .await .ok()?; @@ -546,8 +536,41 @@ impl Provider for OpenAiProvider { self.skip_canonical_filtering } - fn get_model_config(&self) -> ModelConfig { - self.model.clone() + /// 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> { @@ -575,7 +598,6 @@ impl Provider for OpenAiProvider { async fn stream( &self, model_config: &ModelConfig, - session_id: &str, system: &str, messages: &[Message], tools: &[Tool], @@ -592,7 +614,6 @@ impl Provider for OpenAiProvider { let resp = self .api_client .response_post( - Some(session_id), &Self::map_base_path( &self.base_path, "responses", @@ -625,7 +646,11 @@ impl Provider for OpenAiProvider { let message = responses_api_to_message(&responses_api_response)?; let usage_data = get_responses_usage(&responses_api_response); - let usage = ProviderUsage::new(model_config.model_name.clone(), usage_data); + 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(), @@ -653,7 +678,7 @@ impl Provider for OpenAiProvider { .with_retry(|| async { let resp = self .api_client - .response_post(Some(session_id), &self.base_path, &payload) + .response_post(&self.base_path, &payload) .await?; handle_status(resp).await }) @@ -673,8 +698,12 @@ impl Provider for OpenAiProvider { ProviderError::RequestFailed(format!("Failed to parse message: {}", e)) })?; - let usage_data = get_usage(json.get("usage").unwrap_or(&serde_json::Value::Null)); - let usage = ProviderUsage::new(model_config.model_name.clone(), usage_data); + 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(), @@ -687,6 +716,97 @@ impl Provider for OpenAiProvider { } } +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| { @@ -698,12 +818,52 @@ pub fn parse_custom_headers(s: String) -> HashMap { .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( @@ -715,7 +875,6 @@ mod tests { base_path: "v1/chat/completions".to_string(), organization: None, project: None, - model: ModelConfig::new_or_fail("test-model"), custom_headers: None, supports_streaming: true, name: name.to_string(), @@ -723,6 +882,7 @@ mod tests { dynamic_models: None, skip_canonical_filtering: false, preserve_thinking_context: false, + n_ctx_cache: Arc::new(Mutex::new(HashMap::new())), } } @@ -950,6 +1110,60 @@ mod tests { ); } + 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!({ @@ -970,4 +1184,37 @@ mod tests { }); 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-providers/src/openai_compatible.rs b/crates/goose-providers/src/openai_compatible.rs index cc872f13c9..78f96aed92 100644 --- a/crates/goose-providers/src/openai_compatible.rs +++ b/crates/goose-providers/src/openai_compatible.rs @@ -1,4 +1,4 @@ -use crate::conversation::token_usage::ProviderUsage; +use crate::conversation::token_usage::{CostSource, ProviderUsage}; use crate::images::ImageFormat; use anyhow::Error; use async_stream::try_stream; @@ -18,7 +18,7 @@ use super::retry::ProviderRetry; use crate::conversation::message::Message; use crate::errors::ProviderError; use crate::formats::openai::{ - create_request, get_usage, response_to_message, response_to_streaming_message, + create_request, get_cost, get_usage, response_to_message, response_to_streaming_message, }; use crate::formats::openai_responses::responses_api_to_streaming_message; use crate::model::ModelConfig; @@ -29,23 +29,16 @@ pub struct OpenAiCompatibleProvider { name: String, /// Client targeted at the base URL (e.g. `https://api.x.ai/v1`) api_client: ApiClient, - model: ModelConfig, /// Path prefix prepended to `chat/completions` (e.g. `"deployments/{name}/"` for Azure). completions_prefix: String, supports_streaming: bool, } impl OpenAiCompatibleProvider { - pub fn new( - name: String, - api_client: ApiClient, - model: ModelConfig, - completions_prefix: String, - ) -> Self { + pub fn new(name: String, api_client: ApiClient, completions_prefix: String) -> Self { Self { name, api_client, - model, completions_prefix, supports_streaming: true, } @@ -82,14 +75,10 @@ impl Provider for OpenAiCompatibleProvider { &self.name } - fn get_model_config(&self) -> ModelConfig { - self.model.clone() - } - async fn fetch_supported_models(&self) -> Result, ProviderError> { let response = self .api_client - .response_get(None, "models") + .response_get("models") .await .map_err(|e| ProviderError::RequestFailed(e.to_string()))?; let json = handle_response_openai_compat(response).await?; @@ -116,7 +105,6 @@ impl Provider for OpenAiCompatibleProvider { async fn stream( &self, model_config: &ModelConfig, - session_id: &str, system: &str, messages: &[Message], tools: &[Tool], @@ -135,7 +123,7 @@ impl Provider for OpenAiCompatibleProvider { .with_retry(|| async { let resp = self .api_client - .response_post(Some(session_id), &completions_path, &payload) + .response_post(&completions_path, &payload) .await?; handle_status(resp).await }) @@ -155,8 +143,12 @@ impl Provider for OpenAiCompatibleProvider { ProviderError::RequestFailed(format!("Failed to parse message: {}", e)) })?; - let usage_data = get_usage(json.get("usage").unwrap_or(&serde_json::Value::Null)); - let usage = ProviderUsage::new(model_config.model_name.clone(), usage_data); + 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(), @@ -312,13 +304,13 @@ mod tests { None, ) .unwrap(), - ModelConfig::new_or_fail("test-model"), String::new(), ) .with_supports_streaming(false); + let model = ModelConfig::new("test-model"); let payload = provider - .build_request(&provider.model, "", &[], &[], provider.supports_streaming) + .build_request(&model, "", &[], &[], provider.supports_streaming) .unwrap(); assert_eq!(payload.get("stream"), None); diff --git a/crates/goose/src/providers/snowflake.rs b/crates/goose-providers/src/snowflake.rs similarity index 78% rename from crates/goose/src/providers/snowflake.rs rename to crates/goose-providers/src/snowflake.rs index be7fba93ce..1ae0537d29 100644 --- a/crates/goose/src/providers/snowflake.rs +++ b/crates/goose-providers/src/snowflake.rs @@ -1,23 +1,21 @@ +use crate::conversation::token_usage::ProviderUsage; +use crate::images::ImageFormat; use anyhow::Result; use async_trait::async_trait; -use goose_providers::conversation::token_usage::ProviderUsage; -use goose_providers::images::ImageFormat; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use super::api_client::{ApiClient, AuthMethod}; -use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; -use super::formats::snowflake::{create_request, get_usage, response_to_message}; -use super::openai_compatible::{map_http_error_to_provider_error, sanitize_url}; -use super::retry::ProviderRetry; -use super::utils::get_model; -use crate::config::ConfigError; +use crate::api_client::{ApiClient, AuthMethod, RequestBuilderDecorator, TlsConfig}; +use crate::base::{ConfigKey, MessageStream, Provider, ProviderMetadata}; use crate::conversation::message::Message; -use goose_providers::errors::ProviderError; +use crate::errors::ProviderError; +use crate::formats::snowflake::{create_request, get_usage, response_to_message}; +use crate::openai_compatible::{map_http_error_to_provider_error, sanitize_url}; +use crate::retry::ProviderRetry; +use crate::utils::get_model; -use futures::future::BoxFuture; -use goose_providers::model::ModelConfig; -use goose_providers::request_log::{start_log, LoggerHandleExt}; +use crate::model::ModelConfig; +use crate::request_log::{start_log, LoggerHandleExt}; use rmcp::model::Tool; const SNOWFLAKE_PROVIDER_NAME: &str = "snowflake"; @@ -52,31 +50,18 @@ impl SnowflakeAuth { pub struct SnowflakeProvider { #[serde(skip)] api_client: ApiClient, - model: ModelConfig, image_format: ImageFormat, #[serde(skip)] name: String, } impl SnowflakeProvider { - pub async fn from_env( - model: ModelConfig, - tls_config: Option, + pub fn new( + mut host: String, + token: String, + tls_config: Option, + request_builder: Option, ) -> Result { - let config = crate::config::Config::global(); - let mut host: Result = config.get_param("SNOWFLAKE_HOST"); - if host.is_err() { - host = config.get_secret("SNOWFLAKE_HOST") - } - if host.is_err() { - return Err(ConfigError::NotFound( - "Did not find SNOWFLAKE_HOST in either config file or keyring".to_string(), - ) - .into()); - } - - let mut host = host?; - // Convert host to lowercase host = host.to_lowercase(); @@ -85,19 +70,6 @@ impl SnowflakeProvider { host = format!("{}.snowflakecomputing.com", host); } - let mut token: Result = config.get_param("SNOWFLAKE_TOKEN"); - - if token.is_err() { - token = config.get_secret("SNOWFLAKE_TOKEN") - } - - if token.is_err() { - return Err(ConfigError::NotFound( - "Did not find SNOWFLAKE_TOKEN in either config file or keyring".to_string(), - ) - .into()); - } - // Ensure host has https:// prefix let base_url = if !host.starts_with("https://") && !host.starts_with("http://") { format!("https://{}", host) @@ -105,26 +77,24 @@ impl SnowflakeProvider { host }; - let auth = AuthMethod::BearerToken(token?); - let api_client = ApiClient::new_with_tls(base_url, auth, tls_config)? - .with_header("User-Agent", "goose")?; + let auth = AuthMethod::BearerToken(token); + let mut api_client = ApiClient::new_with_tls(base_url, auth, tls_config)?; + if let Some(request_builder) = request_builder { + api_client = api_client.with_request_builder(request_builder); + } + let api_client = api_client.with_header("User-Agent", "goose")?; Ok(Self { api_client, - model, image_format: ImageFormat::OpenAi, name: SNOWFLAKE_PROVIDER_NAME.to_string(), }) } - async fn post( - &self, - session_id: Option<&str>, - payload: &Value, - ) -> Result { + async fn post(&self, payload: &Value) -> Result { let response = self .api_client - .response_post(session_id, "api/v2/cortex/inference:complete", payload) + .response_post("api/v2/cortex/inference:complete", payload) .await?; let status = response.status(); @@ -303,7 +273,7 @@ impl SnowflakeProvider { } } -impl goose_providers::base::ProviderDescriptor for SnowflakeProvider { +impl crate::base::ProviderDescriptor for SnowflakeProvider { fn metadata() -> ProviderMetadata { ProviderMetadata::new( SNOWFLAKE_PROVIDER_NAME, @@ -320,28 +290,12 @@ impl goose_providers::base::ProviderDescriptor for SnowflakeProvider { } } -impl ProviderDef for SnowflakeProvider { - type Provider = Self; - - fn from_env( - model: ModelConfig, - _extensions: Vec, - tls_config: Option, - ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model, tls_config)) - } -} - #[async_trait] impl Provider for SnowflakeProvider { fn get_name(&self) -> &str { &self.name } - fn get_model_config(&self) -> ModelConfig { - self.model.clone() - } - async fn fetch_supported_models(&self) -> Result, ProviderError> { Ok(SNOWFLAKE_KNOWN_MODELS .iter() @@ -352,24 +306,18 @@ impl Provider for SnowflakeProvider { async fn stream( &self, model_config: &ModelConfig, - session_id: &str, system: &str, messages: &[Message], tools: &[Tool], ) -> Result { - let session_id = if session_id.is_empty() { - None - } else { - Some(session_id) - }; let payload = create_request(model_config, system, messages, tools)?; - let mut log = start_log(&self.model, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { let payload_clone = payload.clone(); - self.post(session_id, &payload_clone).await + self.post(&payload_clone).await }) .await?; @@ -380,7 +328,7 @@ impl Provider for SnowflakeProvider { log.write(&response, Some(&usage))?; let provider_usage = ProviderUsage::new(response_model, usage); - Ok(super::base::stream_from_single_message( + Ok(crate::base::stream_from_single_message( message, provider_usage, )) diff --git a/crates/goose-providers/src/utils.rs b/crates/goose-providers/src/utils.rs deleted file mode 100644 index 3768ad6a30..0000000000 --- a/crates/goose-providers/src/utils.rs +++ /dev/null @@ -1,14 +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() -} diff --git a/crates/goose-sdk-types/Cargo.toml b/crates/goose-sdk-types/Cargo.toml index 0414365d04..afda65dd86 100644 --- a/crates/goose-sdk-types/Cargo.toml +++ b/crates/goose-sdk-types/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "goose-sdk-types" -version.workspace = true +version = "0.1.0-alpha.0" edition.workspace = true rust-version.workspace = true authors.workspace = true diff --git a/crates/goose-sdk-types/src/custom_notifications.rs b/crates/goose-sdk-types/src/custom_notifications.rs index 3170e93ccb..868e3185de 100644 --- a/crates/goose-sdk-types/src/custom_notifications.rs +++ b/crates/goose-sdk-types/src/custom_notifications.rs @@ -25,12 +25,14 @@ pub struct GooseSessionNotification { "propertyName": "sessionUpdate", "mapping": { "usage_update": "#/$defs/SessionUsageUpdate", - "status_message": "#/$defs/StatusMessageUpdate" + "status_message": "#/$defs/StatusMessageUpdate", + "message_usage": "#/$defs/MessageUsageUpdate" } }))] pub enum GooseSessionUpdate { UsageUpdate(SessionUsageUpdate), StatusMessage(StatusMessageUpdate), + MessageUsage(MessageUsageUpdate), } impl Default for GooseSessionUpdate { @@ -51,6 +53,55 @@ pub struct SessionUsageUpdate { 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)] @@ -126,4 +177,50 @@ mod tests { }) ); } + + #[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.rs b/crates/goose-sdk-types/src/custom_requests.rs index 48e1bc10b2..a4502ca255 100644 --- a/crates/goose-sdk-types/src/custom_requests.rs +++ b/crates/goose-sdk-types/src/custom_requests.rs @@ -1,4 +1,4 @@ -use agent_client_protocol::schema::{ContentBlock, McpServer, SessionInfo}; +use agent_client_protocol::schema::v1::{AvailableCommand, ContentBlock, McpServer, SessionInfo}; use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -6,6 +6,8 @@ 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. @@ -49,13 +51,28 @@ 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 { - /// Array of tool info objects with `name`, `description`, `parameters`, and optional `permission`. - pub tools: Vec, + pub tools: Vec, } /// Read a resource from an extension. @@ -101,6 +118,58 @@ 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)] @@ -165,6 +234,95 @@ pub struct SteerSessionResponse { 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)] @@ -186,6 +344,9 @@ pub enum GooseExtension { 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, @@ -195,6 +356,9 @@ pub enum GooseExtension { 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, @@ -208,6 +372,9 @@ pub enum GooseExtension { 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>, }, } @@ -219,6 +386,7 @@ impl Default for GooseExtension { display_name: None, timeout: None, bundled: None, + available_tools: None, } } } @@ -334,6 +502,52 @@ 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 { @@ -382,6 +596,12 @@ 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")] @@ -552,11 +772,42 @@ pub struct ExportSessionResponse { pub data: String, } -/// Import a session from a JSON string. +/// Import a session from a JSON string or share link. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/unstable/session/import", response = ImportSessionResponse)] +#[serde(rename_all = "camelCase")] pub struct ImportSessionRequest { - pub data: String, + 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, } /// Import session response โ€” metadata about the newly created session. @@ -686,6 +937,107 @@ 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 { @@ -1109,6 +1461,58 @@ 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)] @@ -1380,6 +1784,363 @@ 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 {} @@ -1408,6 +2169,7 @@ 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. @@ -1471,3 +2233,32 @@ 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-types/src/custom_requests/recipe.rs b/crates/goose-sdk-types/src/custom_requests/recipe.rs index 1dda402908..101fa230df 100644 --- a/crates/goose-sdk-types/src/custom_requests/recipe.rs +++ b/crates/goose-sdk-types/src/custom_requests/recipe.rs @@ -171,6 +171,9 @@ pub enum RecipeExtensionDto { 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, @@ -180,6 +183,9 @@ pub enum RecipeExtensionDto { 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, @@ -198,6 +204,9 @@ pub enum RecipeExtensionDto { 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, @@ -216,6 +225,9 @@ pub enum RecipeExtensionDto { 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>, }, } @@ -227,6 +239,7 @@ impl Default for RecipeExtensionDto { display_name: None, timeout: None, bundled: None, + available_tools: None, } } } diff --git a/crates/goose-sdk-types/src/custom_requests/schedule.rs b/crates/goose-sdk-types/src/custom_requests/schedule.rs new file mode 100644 index 0000000000..37457a7bf6 --- /dev/null +++ b/crates/goose-sdk-types/src/custom_requests/schedule.rs @@ -0,0 +1,169 @@ +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 index 5b50213069..0358354605 100644 --- a/crates/goose-sdk-types/src/lib.rs +++ b/crates/goose-sdk-types/src/lib.rs @@ -1,8 +1,7 @@ -//! Shared types for the Goose SDK. +//! Shared Goose ACP wire types. //! -//! These wire types are used by both the ACP client/server path and the -//! in-process uniffi bindings, keeping a single source of truth for Goose's -//! custom `_goose/*` JSON-RPC methods. +//! 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 index 4d0904c04a..0916a2813c 100644 --- a/crates/goose-sdk/.gitignore +++ b/crates/goose-sdk/.gitignore @@ -1,2 +1,6 @@ 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 4b17285e63..48cdfb7cab 100644 --- a/crates/goose-sdk/Cargo.toml +++ b/crates/goose-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "goose-sdk" -version.workspace = true +version = "0.1.0-alpha.0" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -19,18 +19,31 @@ required-features = ["uniffi"] [features] default = [] -uniffi = ["dep:uniffi", "dep:thiserror"] +uniffi = [ + "dep:uniffi", + "dep:thiserror", + "dep:anyhow", + "dep:goose-providers", + "dep:futures", + "dep:serde_json", + "dep:tokio", +] [dependencies] -goose-sdk-types = { path = "../goose-sdk-types" } +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.31", features = ["cli"], optional = 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 } [dev-dependencies] -tokio = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "process", "io-std", "io-util"] } 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 index 3dfb839e37..e10329d358 100644 --- a/crates/goose-sdk/README.md +++ b/crates/goose-sdk/README.md @@ -4,13 +4,23 @@ 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 `aaif_goose` / `aaif.goose`). The published surface is -currently a `ping` -> `pong` stub in `src/bindings.rs` โ€” the scaffold for the -real implementation. +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/ping.py -just kotlin # build bindings + run examples/uniffi/Ping.kt +just python # build bindings + run examples/uniffi/provider.py +just kotlin # build bindings + run examples/uniffi/Provider.kt ``` -Both print `pong: aaif.io`. +## 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 9ffcdc51dd..a7421ca883 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::{ - ContentBlock, InitializeRequest, ProtocolVersion, RequestPermissionOutcome, - RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome, - SessionNotification, SessionUpdate, +use agent_client_protocol::schema::v1::{ + ContentBlock, InitializeRequest, 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 new file mode 100644 index 0000000000..1d22074437 --- /dev/null +++ b/crates/goose-sdk/examples/deepseek.json @@ -0,0 +1,30 @@ +{ + "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/Ping.kt b/crates/goose-sdk/examples/uniffi/Ping.kt deleted file mode 100644 index 0f043ee003..0000000000 --- a/crates/goose-sdk/examples/uniffi/Ping.kt +++ /dev/null @@ -1,9 +0,0 @@ -package aaif.example - -import aaif.goose.Client - -fun main() { - val client = Client() - val pong = client.ping("aaif.io") - println(pong.message) -} diff --git a/crates/goose-sdk/examples/uniffi/Provider.kt b/crates/goose-sdk/examples/uniffi/Provider.kt new file mode 100644 index 0000000000..6e23e2f044 --- /dev/null +++ b/crates/goose-sdk/examples/uniffi/Provider.kt @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000000..9b426e6a89 --- /dev/null +++ b/crates/goose-sdk/examples/uniffi/README.md @@ -0,0 +1,53 @@ +# 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/ping.py b/crates/goose-sdk/examples/uniffi/ping.py deleted file mode 100644 index 9503b04808..0000000000 --- a/crates/goose-sdk/examples/uniffi/ping.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Minimal Goose SDK demo: ping the SDK and print the pong.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -HERE = Path(__file__).resolve().parent -sys.path.insert(0, str(HERE.parent.parent / "generated")) - -from aaif_goose import Client # noqa: E402 - - -def main() -> None: - client = Client() - pong = client.ping("aaif.io") - print(pong.message) - - -if __name__ == "__main__": - main() diff --git a/crates/goose-sdk/examples/uniffi/provider.py b/crates/goose-sdk/examples/uniffi/provider.py new file mode 100755 index 0000000000..0112f0b4bc --- /dev/null +++ b/crates/goose-sdk/examples/uniffi/provider.py @@ -0,0 +1,38 @@ +#!/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 index 4d7acf0151..407b48ef00 100644 --- a/crates/goose-sdk/justfile +++ b/crates/goose-sdk/justfile @@ -2,27 +2,37 @@ set shell := ["bash", "-cu"] set working-directory := '../..' lib_ext := if os() == "macos" { "dylib" } else if os() == "windows" { "dll" } else { "so" } -lib_dir := "./target/debug" -lib_path := lib_dir / "libgoose_sdk." + lib_ext -bindgen := "./target/debug/goose-uniffi-bindgen" +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: +_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 - {{bindgen}} generate --library {{lib_path}} --config {{config}} --language {{lang}} --no-format --out-dir {{gen_dir}} 2>/dev/null - cp {{lib_path}} {{gen_dir}}/ + {{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={{lib_dir}} LD_LIBRARY_PATH={{lib_dir}} \ - python3 {{examples_dir}}/ping.py + 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 \ @@ -30,9 +40,118 @@ kotlin: (_generate "kotlin") 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}}/aaif/goose/aaif_goose.kt \ - {{examples_dir}}/Ping.kt \ - -include-runtime -d {{examples_dir}}/ping.jar 2>/dev/null - java -Djna.library.path={{lib_dir}} \ + {{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}}/ping.jar:{{examples_dir}}/jna.jar aaif.example.PingKt + -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 new file mode 100644 index 0000000000..492d4d4fcf --- /dev/null +++ b/crates/goose-sdk/python/README.md @@ -0,0 +1,15 @@ +# 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 new file mode 100644 index 0000000000..c12f5b9b30 --- /dev/null +++ b/crates/goose-sdk/python/pyproject.toml @@ -0,0 +1,34 @@ +[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 new file mode 100644 index 0000000000..5166d0ee97 --- /dev/null +++ b/crates/goose-sdk/python/setup.py @@ -0,0 +1,15 @@ +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/bindings.rs b/crates/goose-sdk/src/bindings.rs index a601a744c0..b44dd6601c 100644 --- a/crates/goose-sdk/src/bindings.rs +++ b/crates/goose-sdk/src/bindings.rs @@ -1,14 +1,18 @@ //! In-process uniffi bindings for the Goose SDK. //! -//! This is the published API surface exposed to Python and Kotlin. Right now it -//! is a minimal `ping` -> `pong` round-trip that proves the uniffi -//! infrastructure end to end without depending on the `goose` core crate. -//! -//! To build the real SDK, add `goose` (and whatever else you need) as -//! dependencies and replace the [`Client`] methods below with the actual -//! agent surface. +//! 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; +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)] @@ -17,36 +21,180 @@ pub enum GooseError { Generic(String), } -/// A reply to a [`Client::ping`] call. -#[derive(Debug, Clone, uniffi::Record)] -pub struct Pong { - /// Echo of the message that was pinged. - pub message: String, +impl From for GooseError { + fn from(error: anyhow::Error) -> Self { + Self::Generic(error.to_string()) + } } -/// The top-level entry point for the Goose SDK. -/// -/// This is the object that consuming languages instantiate. Today it only knows -/// how to answer a ping; extend it with the real agent API. +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 Client {} +pub struct DeclarativeProvider { + provider: Box, + runtime: Arc, +} #[uniffi::export] -impl Client { +impl DeclarativeProvider { + /// Construct a declarative provider using the process environment to resolve + /// configured API key environment variables. #[uniffi::constructor] - pub fn new() -> Arc { - Arc::new(Self {}) + 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), + })) } - /// Round-trip a message through the SDK. Returns a [`Pong`] echoing the - /// supplied `message`, prefixed with `pong: `. - pub fn ping(&self, message: String) -> Result { - if message.is_empty() { - return Err(GooseError::Generic("message must not be empty".into())); - } - Ok(Pong { - message: format!("pong: {message}"), - }) + 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, + })) } } @@ -55,15 +203,29 @@ mod tests { use super::*; #[test] - fn ping_returns_pong() { - let client = Client::new(); - let pong = client.ping("aaif.io".into()).expect("ping should succeed"); - assert_eq!(pong.message, "pong: aaif.io"); + 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 empty_ping_errors() { - let client = Client::new(); - assert!(client.ping(String::new()).is_err()); + 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/src/lib.rs b/crates/goose-sdk/src/lib.rs index bab0fa2a52..4742ea2de8 100644 --- a/crates/goose-sdk/src/lib.rs +++ b/crates/goose-sdk/src/lib.rs @@ -5,17 +5,15 @@ //! that talks to `goose acp` over stdio. //! //! With `--features uniffi` the crate additionally compiles as a -//! `cdylib`/`staticlib` and exposes a small in-process API to Python and Kotlin -//! via [uniffi-rs](https://github.com/mozilla/uniffi-rs). -//! -//! The published uniffi surface is intentionally a single `ping` -> `pong` -//! round-trip. It exists as a working scaffold for adding the real Goose SDK -//! API: replace [`bindings`] with the actual implementation. +//! `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!("aaif_goose"); +uniffi::setup_scaffolding!("goose"); #[cfg(feature = "uniffi")] pub mod bindings; diff --git a/crates/goose-sdk/uniffi.toml b/crates/goose-sdk/uniffi.toml index 7c78ef7121..aef1f592c9 100644 --- a/crates/goose-sdk/uniffi.toml +++ b/crates/goose-sdk/uniffi.toml @@ -1,2 +1,2 @@ [bindings.kotlin] -package_name = "aaif.goose" +package_name = "io.aaif.goose" diff --git a/crates/goose-server/ALLOWLIST.md b/crates/goose-server/ALLOWLIST.md deleted file mode 100644 index 5634adfdb6..0000000000 --- a/crates/goose-server/ALLOWLIST.md +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index dd4304798a..0000000000 --- a/crates/goose-server/Cargo.toml +++ /dev/null @@ -1,115 +0,0 @@ -[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"] -mlx = ["goose/mlx", "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", - "goose-providers/rustls-tls", -] -native-tls = [ - "reqwest/native-tls", - "tokio-tungstenite/native-tls", - "axum-server/tls-openssl", - "dep:openssl", - "goose/native-tls", - "goose-mcp/native-tls", - "goose-providers/native-tls", -] - -[dependencies] -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 } -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"] } -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 deleted file mode 100644 index 23a0fa399a..0000000000 --- a/crates/goose-server/build.rs +++ /dev/null @@ -1,4 +0,0 @@ -// 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-server/src/auth.rs b/crates/goose-server/src/auth.rs deleted file mode 100644 index 690e1cfa40..0000000000 --- a/crates/goose-server/src/auth.rs +++ /dev/null @@ -1,33 +0,0 @@ -use axum::{ - extract::{Request, State}, - http::StatusCode, - middleware::Next, - response::Response, -}; -pub use goose::acp::transport::auth::check_acp_token; -use goose::acp::transport::auth::token_matches; - -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) - } -} diff --git a/crates/goose-server/src/bin/generate_schema.rs b/crates/goose-server/src/bin/generate_schema.rs deleted file mode 100644 index ec5bd33aad..0000000000 --- a/crates/goose-server/src/bin/generate_schema.rs +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 0a255daced..0000000000 --- a/crates/goose-server/src/commands/agent.rs +++ /dev/null @@ -1,165 +0,0 @@ -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"))?; - - 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(), - scheduler: Some(app_state.scheduler()), - })); - - 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 deleted file mode 100644 index f17bc55db8..0000000000 --- a/crates/goose-server/src/commands/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod agent; diff --git a/crates/goose-server/src/configuration.rs b/crates/goose-server/src/configuration.rs deleted file mode 100644 index c6f4583671..0000000000 --- a/crates/goose-server/src/configuration.rs +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index 5f38f85f1c..0000000000 --- a/crates/goose-server/src/error.rs +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 2323f28774..0000000000 --- a/crates/goose-server/src/lib.rs +++ /dev/null @@ -1,20 +0,0 @@ -#[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 deleted file mode 100644 index 499cd11845..0000000000 --- a/crates/goose-server/src/logging.rs +++ /dev/null @@ -1,19 +0,0 @@ -use anyhow::Result; -use goose::providers::utils::init_goose_request_log; -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<()> { - init_goose_request_log()?; - 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 deleted file mode 100644 index bbb12fe948..0000000000 --- a/crates/goose-server/src/main.rs +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index 823feb29d5..0000000000 --- a/crates/goose-server/src/openapi.rs +++ /dev/null @@ -1,719 +0,0 @@ -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::token_usage::Usage; -use goose::conversation::Conversation; -use goose::download_manager::{DownloadProgress, DownloadStatus}; -use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata, ProviderType}; -use goose::session::{Session, SessionType, SystemInfo}; -use goose_providers::model::ModelConfig; -use goose_providers::permission::Permission; -use goose_providers::permission::PrincipalType; -use goose_providers::thinking::ThinkingEffort; -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::list_provider_secrets, - super::routes::config_management::delete_provider_secret, - 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::get_session, - super::routes::session::update_session_name, - 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::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::ProviderSecretsResponse, - super::routes::config_management::ProviderSecret, - super::routes::config_management::ProviderSecretStorage, - super::routes::config_management::ProviderSecretStatus, - 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::ShareSessionNostrRequest, - super::routes::session::ShareSessionNostrResponse, - super::routes::session::ImportSessionNostrRequest, - 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, - Usage, - 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_providers::goose_mode::GooseMode, - 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::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::HfModelVariant, - 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 deleted file mode 100644 index 1301f41c40..0000000000 --- a/crates/goose-server/src/routes/action_required.rs +++ /dev/null @@ -1,100 +0,0 @@ -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 deleted file mode 100644 index 06cf116525..0000000000 --- a/crates/goose-server/src/routes/agent.rs +++ /dev/null @@ -1,1540 +0,0 @@ -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::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 has_extension_overrides = extension_overrides.is_some(); - let mut extensions_to_use = - resolve_extensions_for_new_session(recipe_extensions, extension_overrides); - if recipe_extensions.is_none() && !has_extension_overrides { - extensions_to_use.extend(goose::plugins::mcp_servers::enabled_plugin_mcp_servers( - Some(&PathBuf::from(&working_dir)), - )); - } - - 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) = - goose::model_config::model_config_from_user_config(provider, 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, - })?; - - if !state.has_extension_loading_task(&payload.session_id).await { - let session_for_task = session.clone(); - let agent_for_task = agent.clone(); - let session_id_for_task = payload.session_id.clone(); - let task = tokio::spawn(async move { - agent_for_task - .load_extensions_from_session(&session_for_task) - .await - }); - state - .set_extension_loading_task(session_id_for_task, task) - .await; - } - - 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 = match state.take_extension_loading_task(&payload.session_id).await { - Ok(Some(results)) => { - tracing::debug!( - "Using background extension loading results for session {}", - payload.session_id - ); - state - .remove_extension_loading_task(&payload.session_id) - .await; - results - } - Ok(None) => { - tracing::debug!( - "Extension loading task for session {} was already consumed", - payload.session_id - ); - vec![] - } - Err(e) => { - state - .remove_extension_loading_task(&payload.session_id) - .await; - tracing::warn!( - "Background extension loading failed for session {}, retrying synchronously: {}", - payload.session_id, - e - ); - 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 = - goose::model_config::model_config_from_user_config(&payload.provider, &model) - .map_err(|e| { - ( - StatusCode::BAD_REQUEST, - format!("Invalid model config: {}", e), - ) - })? - .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(); - - ensure_extensions_loaded(&state, &request.session_id).await?; - - 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 { - ensure_extensions_loaded(&state, &request.session_id).await?; - - 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) -> Result<(), ErrorResponse> { - match state.take_extension_loading_task(session_id).await { - Ok(Some(_)) => { - tracing::debug!( - "Awaited background extension loading for session {} before serving request", - session_id - ); - state.remove_extension_loading_task(session_id).await; - Ok(()) - } - Ok(None) => Ok(()), - Err(e) => { - state.remove_extension_loading_task(session_id).await; - tracing::warn!( - "Background extension loading failed for session {}, retrying synchronously: {}", - session_id, - e - ); - let session = state - .session_manager() - .get_session(session_id, false) - .await - .map_err(|err| ErrorResponse { - message: format!( - "Failed to get session after extension loading failed: {}", - err - ), - status: StatusCode::NOT_FOUND, - })?; - let agent = state - .get_agent(session_id.to_string()) - .await - .map_err(|err| { - ErrorResponse::internal(format!( - "Failed to get agent after extension loading failed: {}", - err - )) - })?; - agent.load_extensions_from_session(&session).await; - Ok(()) - } - } -} - -#[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 - .map_err(|err| err.status)?; - - 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 deleted file mode 100644 index 6cae9dad1b..0000000000 --- a/crates/goose-server/src/routes/config_management.rs +++ /dev/null @@ -1,1800 +0,0 @@ -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 chrono::{DateTime, TimeZone, Utc}; -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::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::huggingface_auth; -use goose::providers::providers as get_providers; -use goose::{ - agents::execute_commands, agents::ExtensionConfig, config::permission::PermissionLevel, - slash_commands::recipe_slash_command, -}; -use goose_providers::model::ModelConfig; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use serde_yaml; -use std::{ - collections::{HashMap, HashSet}, - 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, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ProviderSecretStorage { - SecretStore, - ProviderCache, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ProviderSecretStatus { - Valid, - Expired, - Unknown, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct ProviderSecret { - pub id: String, - pub provider: String, - pub provider_display_name: String, - pub name: String, - pub storage: ProviderSecretStorage, - pub expires_at: Option>, - pub status: ProviderSecretStatus, - pub configured: bool, - pub has_secret: bool, - pub can_delete: bool, - pub can_configure: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub configure_provider: Option, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct ProviderSecretsResponse { - pub secrets: Vec, -} - -#[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) -} - -const SECRET_STORE_ID_PREFIX: &str = "secret_store:"; -const PROVIDER_CACHE_ID_PREFIX: &str = "provider_cache:"; - -fn provider_secret_status(expires_at: Option>) -> ProviderSecretStatus { - match expires_at { - Some(expires_at) if expires_at <= Utc::now() => ProviderSecretStatus::Expired, - Some(_) => ProviderSecretStatus::Valid, - None => ProviderSecretStatus::Unknown, - } -} - -fn parse_expiry_value(value: &Value) -> Option> { - match value { - Value::String(value) => DateTime::parse_from_rfc3339(value) - .ok() - .map(|dt| dt.with_timezone(&Utc)), - Value::Number(value) => value - .as_i64() - .and_then(|timestamp| Utc.timestamp_opt(timestamp, 0).single()), - _ => None, - } -} - -fn find_expires_at(value: &Value) -> Option> { - match value { - Value::Object(map) => { - if map - .get("refresh_token") - .and_then(Value::as_str) - .is_some_and(|token| !token.is_empty()) - { - return None; - } - if let Some(expires_at) = map.get("expires_at").and_then(parse_expiry_value) { - return Some(expires_at); - } - if let Some(expires_at) = map.get("expires_on").and_then(parse_expiry_value) { - return Some(expires_at); - } - map.values().find_map(find_expires_at) - } - Value::Array(values) => values.iter().find_map(find_expires_at), - _ => None, - } -} - -#[derive(Clone, Copy)] -struct ProviderCacheSecretDefinition { - provider: &'static str, - name: &'static str, - path: &'static str, - is_directory: bool, -} - -const PROVIDER_CACHE_SECRET_DEFINITIONS: &[ProviderCacheSecretDefinition] = &[ - ProviderCacheSecretDefinition { - provider: "gemini_oauth", - name: "OAuth token", - path: "gemini_oauth/tokens.json", - is_directory: false, - }, - ProviderCacheSecretDefinition { - provider: "chatgpt_codex", - name: "OAuth token", - path: "chatgpt_codex/tokens.json", - is_directory: false, - }, - ProviderCacheSecretDefinition { - provider: "kimi_code", - name: "OAuth token", - path: "kimicode/token.json", - is_directory: false, - }, - ProviderCacheSecretDefinition { - provider: "github_copilot", - name: "OAuth token", - path: "githubcopilot", - is_directory: true, - }, - ProviderCacheSecretDefinition { - provider: "xai_oauth", - name: "OAuth token", - path: "xai_oauth/tokens.json", - is_directory: false, - }, - ProviderCacheSecretDefinition { - provider: "databricks", - name: "OAuth token", - path: "databricks/oauth", - is_directory: true, - }, - ProviderCacheSecretDefinition { - provider: "databricks_v2", - name: "OAuth token", - path: "databricks/oauth", - is_directory: true, - }, -]; - -fn provider_cache_definitions_for_display() -> Vec { - let mut seen_paths = HashSet::new(); - PROVIDER_CACHE_SECRET_DEFINITIONS - .iter() - .copied() - .filter(|definition| seen_paths.insert(definition.path)) - .collect() -} - -fn provider_cache_definition(provider: &str) -> Option { - PROVIDER_CACHE_SECRET_DEFINITIONS - .iter() - .copied() - .find(|definition| definition.provider == provider) -} - -fn provider_cache_providers_sharing_cache(provider: &str) -> Vec<&'static str> { - let Some(definition) = provider_cache_definition(provider) else { - return Vec::new(); - }; - - PROVIDER_CACHE_SECRET_DEFINITIONS - .iter() - .filter(|other| other.path == definition.path) - .map(|definition| definition.provider) - .collect() -} - -fn read_json_file(path: &std::path::Path) -> Option { - std::fs::read_to_string(path) - .ok() - .and_then(|contents| serde_json::from_str(&contents).ok()) -} - -fn collect_json_expiries(path: &std::path::Path, is_directory: bool) -> Vec> { - if !is_directory { - return read_json_file(path) - .and_then(|value| find_expires_at(&value)) - .into_iter() - .collect(); - } - - let mut expiries = Vec::new(); - let mut stack = vec![path.to_path_buf()]; - - while let Some(current) = stack.pop() { - let Ok(entries) = std::fs::read_dir(current) else { - continue; - }; - - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - stack.push(path); - continue; - } - if path.extension().and_then(|ext| ext.to_str()) != Some("json") { - continue; - } - if let Some(expires_at) = - read_json_file(&path).and_then(|value| find_expires_at(&value)) - { - expiries.push(expires_at); - } - } - } - - expiries -} - -fn provider_cache_exists(path: &std::path::Path, is_directory: bool) -> bool { - if !is_directory { - return path.is_file(); - } - - let Ok(entries) = std::fs::read_dir(path) else { - return false; - }; - - entries.flatten().any(|entry| { - let path = entry.path(); - path.is_file() || provider_cache_exists(&path, true) - }) -} - -fn provider_cache_expiry(definition: ProviderCacheSecretDefinition) -> Option> { - let path = Paths::in_config_dir(definition.path); - let expiries = collect_json_expiries(&path, definition.is_directory); - expiries.into_iter().min() -} - -fn build_provider_cache_secret( - definition: ProviderCacheSecretDefinition, - display_names: &HashMap, -) -> Option { - let path = Paths::in_config_dir(definition.path); - if !provider_cache_exists(&path, definition.is_directory) { - return None; - } - - let expires_at = provider_cache_expiry(definition); - Some(ProviderSecret { - id: format!("{}{}", PROVIDER_CACHE_ID_PREFIX, definition.provider), - provider: definition.provider.to_string(), - provider_display_name: display_names - .get(definition.provider) - .cloned() - .unwrap_or_else(|| definition.provider.to_string()), - name: definition.name.to_string(), - storage: ProviderSecretStorage::ProviderCache, - expires_at, - status: provider_secret_status(expires_at), - configured: true, - has_secret: true, - can_delete: true, - can_configure: false, - configure_provider: None, - }) -} - -fn build_huggingface_oauth_secret( - token: Option, -) -> ProviderSecret { - let expires_at = token.as_ref().and_then(|token| token.expires_at); - let has_secret = token.is_some(); - - ProviderSecret { - id: format!( - "{}{}", - PROVIDER_CACHE_ID_PREFIX, - huggingface_auth::HUGGINGFACE_PROVIDER_NAME - ), - provider: huggingface_auth::HUGGINGFACE_PROVIDER_NAME.to_string(), - provider_display_name: huggingface_auth::HUGGINGFACE_DISPLAY_NAME.to_string(), - name: huggingface_auth::HUGGINGFACE_OAUTH_TOKEN_NAME.to_string(), - storage: ProviderSecretStorage::ProviderCache, - expires_at, - status: provider_secret_status(expires_at), - configured: has_secret, - has_secret, - can_delete: has_secret, - can_configure: true, - configure_provider: Some(huggingface_auth::HUGGINGFACE_PROVIDER_NAME.to_string()), - } -} - -fn build_secret_store_secrets( - stored_secrets: &HashMap, - providers: &[(ProviderMetadata, ProviderType)], -) -> Vec { - let mut secrets = Vec::new(); - - for (metadata, _) in providers { - for config_key in metadata.config_keys.iter().filter(|key| key.secret) { - if !stored_secrets.contains_key(&config_key.name) { - continue; - } - secrets.push(ProviderSecret { - id: format!( - "{}{}:{}", - SECRET_STORE_ID_PREFIX, metadata.name, config_key.name - ), - provider: metadata.name.clone(), - provider_display_name: metadata.display_name.clone(), - name: config_key.name.clone(), - storage: ProviderSecretStorage::SecretStore, - expires_at: None, - status: ProviderSecretStatus::Unknown, - configured: true, - has_secret: true, - can_delete: true, - can_configure: false, - configure_provider: None, - }); - } - } - - secrets -} - -fn is_known_provider_secret( - providers: &[(ProviderMetadata, ProviderType)], - provider: &str, - key: &str, -) -> bool { - providers - .iter() - .filter(|(metadata, _)| metadata.name == provider) - .flat_map(|(metadata, _)| metadata.config_keys.iter()) - .any(|config_key| config_key.secret && config_key.name == key) -} - -fn unconfigure_provider(config: &Config, provider_name: &str) -> Result<(), ConfigError> { - if let Some(mut entry) = goose::config::get_provider_entry(config, provider_name) { - entry.configured = false; - goose::config::set_provider_entry(config, provider_name, &entry)?; - } - - let configured_marker = format!("{}_configured", provider_name); - config.delete(&configured_marker)?; - Ok(()) -} - -fn mark_provider_configured(config: &Config, provider_name: &str) -> Result<(), ConfigError> { - 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) - { - 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(()) -} - -fn parse_secret_store_id(id: &str) -> Option<(&str, &str)> { - let rest = id.strip_prefix(SECRET_STORE_ID_PREFIX)?; - let (provider, key) = rest.split_once(':')?; - Some((provider, key)) -} - -fn parse_provider_cache_id(id: &str) -> Option<&str> { - id.strip_prefix(PROVIDER_CACHE_ID_PREFIX) -} - -fn is_valid_provider_name(provider_name: &str) -> bool { - !provider_name.is_empty() - && provider_name - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') -} - -fn should_unconfigure_after_secret_delete( - provider: &str, - key: &str, - has_usable_huggingface_oauth_token: impl FnOnce() -> bool, -) -> bool { - provider == huggingface_auth::HUGGINGFACE_PROVIDER_NAME - && key == huggingface_auth::HUGGINGFACE_TOKEN_SECRET_KEY - && !has_usable_huggingface_oauth_token() -} - -#[utoipa::path( - get, - path = "/config/provider-secrets", - responses( - (status = 200, description = "Provider secrets retrieved successfully", body = ProviderSecretsResponse), - (status = 500, description = "Internal server error") - ) -)] -pub async fn list_provider_secrets() -> Result, ErrorResponse> { - let config = Config::global(); - let stored_secrets = config.all_secrets()?; - let providers = get_providers().await; - let display_names: HashMap = providers - .iter() - .map(|(metadata, _)| (metadata.name.clone(), metadata.display_name.clone())) - .collect(); - - let mut secrets = build_secret_store_secrets(&stored_secrets, &providers); - - for definition in provider_cache_definitions_for_display() { - if let Some(secret) = build_provider_cache_secret(definition, &display_names) { - if !secrets.iter().any(|existing| existing.id == secret.id) { - secrets.push(secret); - } - } - } - - let huggingface_secret = build_huggingface_oauth_secret(huggingface_auth::load_oauth_token()); - if let Some(existing) = secrets - .iter_mut() - .find(|existing| existing.id == huggingface_secret.id) - { - *existing = huggingface_secret; - } else { - secrets.push(huggingface_secret); - } - - secrets.sort_by(|a, b| { - a.provider_display_name - .cmp(&b.provider_display_name) - .then_with(|| a.name.cmp(&b.name)) - }); - - Ok(Json(ProviderSecretsResponse { secrets })) -} - -#[utoipa::path( - delete, - path = "/config/provider-secrets/{id}", - params( - ("id" = String, Path, description = "Provider secret identifier") - ), - responses( - (status = 200, description = "Provider secret deleted successfully", body = String), - (status = 400, description = "Invalid provider secret identifier"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn delete_provider_secret(Path(id): Path) -> Result, ErrorResponse> { - let config = Config::global(); - - if let Some((provider, key)) = parse_secret_store_id(&id) { - let providers = get_providers().await; - if !is_known_provider_secret(&providers, provider, key) { - return Err(ErrorResponse::bad_request(format!( - "Invalid provider secret id: '{}'", - id - ))); - } - - config.delete_secret(key)?; - if should_unconfigure_after_secret_delete(provider, key, || { - huggingface_auth::has_configured_token().unwrap_or(false) - }) { - unconfigure_provider(config, provider)?; - } - return Ok(Json(format!("Deleted provider secret {}", id))); - } - - if let Some(provider) = parse_provider_cache_id(&id) { - if provider == huggingface_auth::HUGGINGFACE_PROVIDER_NAME { - huggingface_auth::clear_oauth_token()?; - unconfigure_provider(config, provider)?; - return Ok(Json(format!("Deleted provider secret {}", id))); - } - - let cache_definition = provider_cache_definition(provider); - - if !is_valid_provider_name(provider) || cache_definition.is_none() { - return Err(ErrorResponse::bad_request(format!( - "Invalid provider name: '{}'", - provider - ))); - } - goose::providers::cleanup_provider(provider).await?; - for shared_provider in provider_cache_providers_sharing_cache(provider) { - unconfigure_provider(config, shared_provider)?; - } - return Ok(Json(format!("Deleted provider secret {}", id))); - } - - Err(ErrorResponse::bad_request(format!( - "Invalid provider secret id: '{}'", - id - ))) -} - -#[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 = - goose::model_config::model_config_from_user_config(&name, &metadata.default_model)?; - 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 = goose::model_config::model_config_from_user_config(name, model)?; - 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::providers::create; - - if !is_valid_provider_name(&provider_name) { - return Err(ErrorResponse::bad_request(format!( - "Invalid provider name: '{}'", - provider_name - ))); - } - - if provider_name == huggingface_auth::HUGGINGFACE_PROVIDER_NAME { - huggingface_auth::configure_oauth().await.map_err(|e| { - ErrorResponse::bad_request(format!( - "OAuth configuration failed for provider '{}': {}", - provider_name, e - )) - })?; - mark_provider_configured(goose::config::Config::global(), &provider_name)?; - return Ok(Json("OAuth configuration completed".to_string())); - } - - let temp_model = goose::model_config::model_config_from_user_config(&provider_name, "temp") - .map_err(|e| { - ErrorResponse::bad_request(format!("Failed to create temporary model config: {}", e)) - })?; - - // 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_provider_configured(goose::config::Config::global(), &provider_name)?; - - 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/provider-secrets", get(list_provider_secrets)) - .route( - "/config/provider-secrets/{id}", - delete(delete_provider_secret), - ) - .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 { - use super::*; - use goose::config::ProviderEntry; - use goose::providers::base::ConfigKey; - use serde_json::json; - - fn new_test_config() -> Config { - let unique = format!( - "goose-server-config-test-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - ); - let config_path = std::env::temp_dir().join(format!("{unique}-config.yaml")); - let secrets_path = std::env::temp_dir().join(format!("{unique}-secrets.yaml")); - Config::new_with_file_secrets(config_path, secrets_path).unwrap() - } - - #[test] - fn secret_store_listing_only_includes_provider_secret_keys() { - let metadata = ProviderMetadata::new( - "openai", - "OpenAI", - "OpenAI provider", - "gpt-4o", - vec![], - "https://example.com", - vec![ - ConfigKey::new("OPENAI_API_KEY", true, true, None, true), - ConfigKey::new("OPENAI_HOST", false, false, None, false), - ], - ); - let providers = vec![(metadata, ProviderType::Builtin)]; - let stored_secrets = HashMap::from([ - ( - "OPENAI_API_KEY".to_string(), - Value::String("secret-value".to_string()), - ), - ( - "UNRELATED_SECRET".to_string(), - Value::String("other-secret".to_string()), - ), - ( - "OPENAI_HOST".to_string(), - Value::String("https://api.openai.com".to_string()), - ), - ]); - - let secrets = build_secret_store_secrets(&stored_secrets, &providers); - - assert_eq!(secrets.len(), 1); - assert_eq!(secrets[0].id, "secret_store:openai:OPENAI_API_KEY"); - assert_eq!(secrets[0].provider_display_name, "OpenAI"); - assert_eq!(secrets[0].name, "OPENAI_API_KEY"); - assert_eq!(secrets[0].storage, ProviderSecretStorage::SecretStore); - assert_eq!(secrets[0].status, ProviderSecretStatus::Unknown); - } - - #[test] - fn provider_secret_delete_validation_requires_provider_secret_key() { - let metadata = ProviderMetadata::new( - "openai", - "OpenAI", - "OpenAI provider", - "gpt-4o", - vec![], - "https://example.com", - vec![ - ConfigKey::new("OPENAI_API_KEY", true, true, None, true), - ConfigKey::new("OPENAI_HOST", false, false, None, false), - ], - ); - let providers = vec![(metadata, ProviderType::Builtin)]; - - assert!(is_known_provider_secret( - &providers, - "openai", - "OPENAI_API_KEY" - )); - assert!(!is_known_provider_secret( - &providers, - "openai", - "OPENAI_HOST" - )); - assert!(!is_known_provider_secret( - &providers, - "openai", - "UNRELATED_SECRET" - )); - assert!(!is_known_provider_secret( - &providers, - "anthropic", - "OPENAI_API_KEY" - )); - } - - #[test] - fn expiry_extraction_handles_nested_rfc3339_values() { - let expires_at = Utc::now() + chrono::Duration::hours(1); - let value = json!({ - "project_id": "project", - "token": { - "access_token": "secret", - "expires_at": expires_at.to_rfc3339(), - } - }); - - let parsed = find_expires_at(&value).expect("expected expiry"); - - assert_eq!(parsed.timestamp(), expires_at.timestamp()); - assert_eq!( - provider_secret_status(Some(parsed)), - ProviderSecretStatus::Valid - ); - } - - #[test] - fn expiry_extraction_ignores_refreshable_access_tokens() { - let expires_at = Utc::now() - chrono::Duration::hours(1); - let value = json!({ - "access_token": "access", - "refresh_token": "refresh", - "expires_at": expires_at.to_rfc3339(), - }); - - assert_eq!(find_expires_at(&value), None); - } - - #[test] - fn expiry_extraction_handles_expired_unix_timestamps() { - let value = json!({ - "info": { - "expires_at": 1 - } - }); - - let parsed = find_expires_at(&value).expect("expected expiry"); - - assert_eq!(parsed.timestamp(), 1); - assert_eq!( - provider_secret_status(Some(parsed)), - ProviderSecretStatus::Expired - ); - } - - #[test] - fn provider_secret_ids_parse_expected_prefixes() { - assert_eq!( - parse_secret_store_id("secret_store:openai:OPENAI_API_KEY"), - Some(("openai", "OPENAI_API_KEY")) - ); - assert_eq!( - parse_provider_cache_id("provider_cache:gemini_oauth"), - Some("gemini_oauth") - ); - assert_eq!(parse_secret_store_id("provider_cache:openai"), None); - assert_eq!(parse_provider_cache_id("secret_store:openai:key"), None); - } - - #[test] - fn shared_databricks_cache_is_displayed_once() { - let databricks_definitions: Vec<_> = provider_cache_definitions_for_display() - .into_iter() - .filter(|definition| definition.path == "databricks/oauth") - .collect(); - - assert_eq!(databricks_definitions.len(), 1); - assert_eq!(databricks_definitions[0].provider, "databricks"); - } - - #[test] - fn shared_databricks_cache_unconfigures_both_providers() { - assert_eq!( - provider_cache_providers_sharing_cache("databricks"), - vec!["databricks", "databricks_v2"] - ); - assert_eq!( - provider_cache_providers_sharing_cache("databricks_v2"), - vec!["databricks", "databricks_v2"] - ); - } - - #[test] - fn unconfigure_provider_clears_structured_entry() { - let config = new_test_config(); - goose::config::set_provider_entry( - &config, - "huggingface", - &ProviderEntry { - enabled: true, - model: "Qwen/Qwen3-Coder-480B-A35B-Instruct".to_string(), - configured: true, - }, - ) - .unwrap(); - - unconfigure_provider(&config, "huggingface").unwrap(); - - let entry = goose::config::get_provider_entry(&config, "huggingface").unwrap(); - assert!(entry.enabled); - assert_eq!(entry.model, "Qwen/Qwen3-Coder-480B-A35B-Instruct"); - assert!(!entry.configured); - } - - #[test] - fn unconfigure_provider_deletes_legacy_configured_marker() { - let config = new_test_config(); - config.set_param("huggingface_configured", true).unwrap(); - - unconfigure_provider(&config, "huggingface").unwrap(); - - assert!(config.get_param::("huggingface_configured").is_err()); - } - - #[test] - fn deleting_huggingface_token_unconfigures_without_oauth() { - assert!(should_unconfigure_after_secret_delete( - "huggingface", - "HF_TOKEN", - || false - )); - } - - #[test] - fn deleting_huggingface_token_keeps_configured_with_oauth() { - assert!(!should_unconfigure_after_secret_delete( - "huggingface", - "HF_TOKEN", - || true - )); - } - - #[test] - fn deleting_other_provider_secret_does_not_unconfigure_huggingface() { - assert!(!should_unconfigure_after_secret_delete( - "openai", - "OPENAI_API_KEY", - || false - )); - } - - #[test] - fn huggingface_oauth_secret_is_permanent_without_token() { - let secret = build_huggingface_oauth_secret(None); - - assert_eq!(secret.id, "provider_cache:huggingface"); - assert_eq!(secret.provider_display_name, "Hugging Face"); - assert_eq!(secret.name, "OAuth token"); - assert_eq!(secret.storage, ProviderSecretStorage::ProviderCache); - assert_eq!(secret.status, ProviderSecretStatus::Unknown); - assert!(!secret.configured); - assert!(!secret.has_secret); - assert!(!secret.can_delete); - assert!(secret.can_configure); - assert_eq!(secret.configure_provider.as_deref(), Some("huggingface")); - } - - #[test] - fn huggingface_oauth_secret_reports_cached_token_metadata() { - let expires_at = Utc::now() + chrono::Duration::hours(1); - let secret = build_huggingface_oauth_secret(Some(huggingface_auth::HuggingFaceTokenData { - access_token: "hidden".to_string(), - refresh_token: None, - expires_at: Some(expires_at), - })); - - assert_eq!( - secret.expires_at.map(|value| value.timestamp()), - Some(expires_at.timestamp()) - ); - assert_eq!(secret.status, ProviderSecretStatus::Valid); - assert!(secret.configured); - assert!(secret.has_secret); - assert!(secret.can_delete); - } -} diff --git a/crates/goose-server/src/routes/dictation.rs b/crates/goose-server/src/routes/dictation.rs deleted file mode 100644 index e7c8111451..0000000000 --- a/crates/goose-server/src/routes/dictation.rs +++ /dev/null @@ -1,374 +0,0 @@ -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 deleted file mode 100644 index 9d3de5a373..0000000000 --- a/crates/goose-server/src/routes/errors.rs +++ /dev/null @@ -1,136 +0,0 @@ -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; -use goose::config::ConfigError; -use goose_providers::errors::ProviderError; -use goose_providers::model::ConfigError as ModelConfigError; -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 deleted file mode 100644 index f974df9fa9..0000000000 --- a/crates/goose-server/src/routes/features.rs +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index e49299ae4b..0000000000 --- a/crates/goose-server/src/routes/gateway.rs +++ /dev/null @@ -1,225 +0,0 @@ -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 deleted file mode 100644 index 39e84bb463..0000000000 --- a/crates/goose-server/src/routes/local_inference.rs +++ /dev/null @@ -1,885 +0,0 @@ -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, DownloadStatus}; -use goose::providers::huggingface_auth; -use goose::providers::local_inference::hf_models::{self, HfModelInfo, HfModelVariant}; -use goose::providers::local_inference::{ - available_inference_memory_bytes, builtin_chat_template_names, - hf_models::{ - register_resolved_model, resolve_local_model_selection, resolve_local_model_spec, - resolve_model_spec, HfGgufFile, - }, - local_model_registry::{ - default_settings_for_model, featured_mmproj_spec, get_registry, model_id_from_repo, - LocalModelEntry, LocalModelStorage, ModelDownloadStatus as RegistryDownloadStatus, - ModelSettings, 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) { - 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; - } - // Fall through to resolve for backfill - } - } - - 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(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - - if !entries_to_add.is_empty() { - registry.sync_with_featured(entries_to_add); - } - - // Backfill mmproj data for all registry models and collect any - // needed mmproj downloads for models already on disk. - 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(); - } - - // 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 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 == 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_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(()) -} - -#[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, - pub downloaded_variants: 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_local_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_local_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 gguf_variants: Vec<_> = variants - .iter() - .filter(|variant| variant.backend_id == "llamacpp") - .map( - |variant| goose::providers::local_inference::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(|_| ErrorResponse::internal("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(Json(RepoVariantsResponse { - variants, - recommended_index, - available_memory_bytes: available_memory, - downloaded_quants, - downloaded_variants, - })) -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct DownloadModelRequest { - /// Model spec/download id like "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M" or "google/gemma-4-31B-it" - pub spec: String, - /// Optional backend id for callers selecting a concrete variant row. - pub backend_id: Option, - /// Optional backend-specific variant id, such as a GGUF quantization or MLX dtype. - pub variant_id: Option, -} - -#[derive(Clone)] -struct LocalModelSelection { - repo_id: String, - backend_id: String, - variant_id: Option, -} - -fn explicit_model_selection( - req: &DownloadModelRequest, -) -> anyhow::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: &DownloadModelRequest, - selection: Option<&LocalModelSelection>, -) -> anyhow::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::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: &DownloadModelRequest, - selection: Option<&LocalModelSelection>, -) -> anyhow::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::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![], - }) -} - -#[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 selection = explicit_model_selection(&req) - .map_err(|e| ErrorResponse::bad_request(format!("Invalid spec: {}", e)))?; - let model_id = local_model_id_from_request(&req, selection.as_ref()) - .await - .map_err(|e| ErrorResponse::bad_request(format!("Invalid spec: {}", e)))?; - 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, - }) - .map_err(|e| ErrorResponse::internal(format!("Download failed: {}", e)))?; - if !download_reserved { - return Ok((StatusCode::ACCEPTED, Json(model_id))); - } - - if let Err(error) = register_pending_download_model(&model_id, &req, selection.as_ref()) { - mark_download_failed(&model_id, &error); - return Err(ErrorResponse::internal(format!( - "Failed to register download: {}", - error - ))); - } - - 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((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 mut registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - if registry.get_model(&model_id).is_none() { - return Err(ErrorResponse::not_found("Model not found")); - } - registry - .delete_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) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn progress_for(model_id: &str, status: DownloadStatus) -> DownloadProgress { - DownloadProgress { - model_id: format!("{}-model", model_id), - status, - bytes_downloaded: 0, - total_bytes: 0, - progress_percent: 0.0, - speed_bps: None, - eta_seconds: None, - error: None, - task_exited: true, - } - } - - #[test] - fn model_download_completed_requires_completed_progress() { - let model_id = "test-completed-registration-gate"; - let manager = get_download_manager(); - manager.set_progress(progress_for(model_id, DownloadStatus::Completed)); - - assert!(model_download_completed(model_id)); - - manager.clear_completed(&format!("{}-model", model_id)); - } - - #[test] - fn model_download_completed_rejects_cancelled_progress() { - let model_id = "test-cancelled-registration-gate"; - let manager = get_download_manager(); - manager.set_progress(progress_for(model_id, DownloadStatus::Cancelled)); - - assert!(!model_download_completed(model_id)); - - manager.clear_completed(&format!("{}-model", model_id)); - } -} diff --git a/crates/goose-server/src/routes/mcp_app_proxy.rs b/crates/goose-server/src/routes/mcp_app_proxy.rs deleted file mode 100644 index 2a4a7502cf..0000000000 --- a/crates/goose-server/src/routes/mcp_app_proxy.rs +++ /dev/null @@ -1,295 +0,0 @@ -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 deleted file mode 100644 index 2489c8d98f..0000000000 --- a/crates/goose-server/src/routes/mcp_ui_proxy.rs +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 00777d80ac..0000000000 --- a/crates/goose-server/src/routes/mod.rs +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index b807dc5910..0000000000 --- a/crates/goose-server/src/routes/prompts.rs +++ /dev/null @@ -1,132 +0,0 @@ -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