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-cli.yml b/.github/workflows/build-cli.yml index 0e3f898864..f3b677c132 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -86,12 +86,12 @@ jobs: - platform: windows architecture: x86_64 target-suffix: pc-windows-msvc - build-on: windows-latest + build-on: windows-2022 variant: cuda steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.ref }} @@ -299,7 +299,7 @@ jobs: echo "ARTIFACT_ZIP=target/${TARGET}/release/goose-${TARGET}${VARIANT_SUFFIX}.zip" >> $GITHUB_ENV - name: Upload CLI artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: goose-${{ matrix.architecture }}-${{ matrix.target-suffix }}${{ matrix.variant != 'standard' && matrix.variant != 'musl' && format('-{0}', matrix.variant) || '' }} path: | diff --git a/.github/workflows/build-notify.yml b/.github/workflows/build-notify.yml index 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 3923c6575b..c2a26bf352 100644 --- a/.github/workflows/bundle-desktop-intel.yml +++ b/.github/workflows/bundle-desktop-intel.yml @@ -49,7 +49,7 @@ jobs: run: df -h - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Only pass ref if it's explicitly set, otherwise let checkout action use its default behavior ref: ${{ inputs.ref != '' && inputs.ref || '' }} @@ -73,11 +73,11 @@ jobs: key: intel-macos-deployment-target-12 - - name: Build 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: | @@ -168,7 +176,7 @@ jobs: df -h - name: Upload Desktop artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-darwin-x64 path: ui/desktop/out/Goose-darwin-x64/Goose_intel_mac.zip diff --git a/.github/workflows/bundle-desktop-linux.yml b/.github/workflows/bundle-desktop-linux.yml index d3fc279087..ad3b84f194 100644 --- a/.github/workflows/bundle-desktop-linux.yml +++ b/.github/workflows/bundle-desktop-linux.yml @@ -39,7 +39,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.ref || inputs.branch }} @@ -104,6 +104,11 @@ jobs: sudo apt-get install -y libasound2 fi + - name: Configure RPM packaging + run: | + echo '%_build_id_links none' > ~/.rpmmacros + test "$(rpm --eval '%{_build_id_links}')" = "none" + - name: Setup Flatpak Runtimes run: | flatpak remote-add --if-not-exists --user flathub https://dl.flathub.org/repo/flathub.flatpakrepo @@ -119,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 @@ -132,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 @@ -181,6 +192,14 @@ jobs: done fi + for package in out/make/rpm/x64/*.rpm; do + package_contents="$(rpm -qlp "$package")" + if grep -qE '^/usr/lib/\.build-id(/|$)' <<< "$package_contents"; then + echo "::error file=$package::RPM contains build-id links" + exit 1 + fi + done + echo "Build completed. Checking output..." ls -la out/ find out/ -name "*.deb" -o -name "*.rpm" -o -name "*.flatpak" | head -10 @@ -198,7 +217,7 @@ jobs: - name: Upload .deb package if: matrix.variant == 'standard' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-linux-x64-deb path: ui/desktop/out/make/deb/x64/*.deb @@ -206,7 +225,7 @@ jobs: - name: Upload .deb package (Vulkan) if: matrix.variant == 'vulkan' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-linux-x64-vulkan-deb path: ui/desktop/out/make/deb/x64/*-vulkan.deb @@ -214,7 +233,7 @@ jobs: - name: Upload .rpm package if: matrix.variant == 'standard' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-linux-x64-rpm path: ui/desktop/out/make/rpm/x64/*.rpm @@ -222,7 +241,7 @@ jobs: - name: Upload .rpm package (Vulkan) if: matrix.variant == 'vulkan' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-linux-x64-vulkan-rpm path: ui/desktop/out/make/rpm/x64/*-vulkan.rpm @@ -230,7 +249,7 @@ jobs: - name: Upload .flatpak package if: matrix.variant == 'standard' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-linux-x64-flatpak path: ui/desktop/out/make/flatpak/x86_64/*.flatpak @@ -238,7 +257,7 @@ jobs: - name: Upload .flatpak package (Vulkan) if: matrix.variant == 'vulkan' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-linux-x64-vulkan-flatpak path: ui/desktop/out/make/flatpak/x86_64/*-vulkan.flatpak diff --git a/.github/workflows/bundle-desktop-windows.yml b/.github/workflows/bundle-desktop-windows.yml index ae6be9b8a5..6ceb29b82f 100644 --- a/.github/workflows/bundle-desktop-windows.yml +++ b/.github/workflows/bundle-desktop-windows.yml @@ -44,16 +44,16 @@ permissions: jobs: build-desktop-windows: name: Build Desktop (Windows) - runs-on: windows-latest + runs-on: ${{ inputs.windows_variant == 'cuda' && 'windows-2022' || 'windows-latest' }} steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.ref != '' && inputs.ref || '' }} - name: Set up Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24.10.0 @@ -61,7 +61,7 @@ jobs: run: npm install -g pnpm@10.30.3 - name: Cache node_modules - uses: actions/cache@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 @@ -195,7 +201,7 @@ jobs: ls -la ./dist-windows/resources/bin/ - name: Upload unsigned distribution - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: windows-unsigned${{ inputs.windows_variant == 'cuda' && '-cuda' || '' }} path: ui/desktop/dist-windows/ @@ -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" @@ -255,7 +261,7 @@ jobs: 7z a -tzip "${ZIP_NAME}.zip" dist-windows/ - name: Upload signed Windows build - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-win32-x64${{ inputs.windows_variant == 'cuda' && '-cuda' || '' }} path: Goose-win32-x64${{ inputs.windows_variant == 'cuda' && '-cuda' || '' }}.zip @@ -284,7 +290,7 @@ jobs: 7z a -tzip "${ZIP_NAME}.zip" dist-windows/ - name: Upload Windows build - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-win32-x64${{ inputs.windows_variant == 'cuda' && '-cuda' || '' }} path: Goose-win32-x64${{ inputs.windows_variant == 'cuda' && '-cuda' || '' }}.zip diff --git a/.github/workflows/bundle-desktop.yml b/.github/workflows/bundle-desktop.yml index 0cb3d0b019..f431a34bc7 100644 --- a/.github/workflows/bundle-desktop.yml +++ b/.github/workflows/bundle-desktop.yml @@ -77,7 +77,7 @@ jobs: run: df -h - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Only pass ref if it's explicitly set, otherwise let checkout action use its default behavior ref: ${{ inputs.ref != '' && inputs.ref || '' }} @@ -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: | @@ -201,7 +211,7 @@ jobs: - name: Upload Desktop artifact id: upload-app-bundle - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-darwin-arm64 path: ui/desktop/out/Goose-darwin-arm64/Goose.zip diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index d8f8727bff..f93599880c 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -33,7 +33,7 @@ jobs: version: ${{ steps.set-version.outputs.version }} steps: # checkout code so we can read the Cargo.toml - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Generate a canary version id: set-version run: | @@ -60,14 +60,14 @@ jobs: runs-on: ubuntu-latest needs: [build-cli] steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: download_cli.sh 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 ee7cecce5a..0e917b04e8 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -22,8 +22,8 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7.0.0 # https://github.com/EmbarkStudios/cargo-deny-action v2.0.15 - - uses: EmbarkStudios/cargo-deny-action@a531616d8ce3b9177443e48a1159bc945a099823 + - uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe with: command: check advisories diff --git a/.github/workflows/cargo-machete.yml b/.github/workflows/cargo-machete.yml index f9641958b8..58e64f2e53 100644 --- a/.github/workflows/cargo-machete.yml +++ b/.github/workflows/cargo-machete.yml @@ -20,5 +20,5 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 - uses: bnjbvr/cargo-machete@ac30a525c0a8d163a92d727b3ff079ee3f6ecb08 # v0.9.2 diff --git a/.github/workflows/check-release-pr.yaml b/.github/workflows/check-release-pr.yaml index 2601b18ad7..f900317017 100644 --- a/.github/workflows/check-release-pr.yaml +++ b/.github/workflows/check-release-pr.yaml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest if: startsWith(github.head_ref, 'release/') steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.head_ref }} fetch-depth: 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34201a59c1..1216340871 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: code: ${{ steps.filter.outputs.code }} steps: - name: Checkout Code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check for file changes uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v3 @@ -39,9 +39,9 @@ jobs: if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' steps: - name: Checkout Code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + 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 @@ -53,9 +53,9 @@ jobs: if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' steps: - name: Checkout Code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + 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: | @@ -82,7 +82,7 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - name: Checkout Code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Cache Cargo artifacts uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 @@ -106,7 +106,7 @@ jobs: needs: changes if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Read MSRV from Cargo.toml id: msrv @@ -119,7 +119,7 @@ jobs: echo "msrv=$msrv" >> "$GITHUB_OUTPUT" echo "MSRV: $msrv" - - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 with: toolchain: ${{ steps.msrv.outputs.msrv }} @@ -143,9 +143,9 @@ jobs: needs: changes if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - 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 @@ -165,9 +165,9 @@ jobs: if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' steps: - name: Checkout Code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + 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,17 +183,18 @@ jobs: cd ui/desktop && pnpm install --frozen-lockfile cd ../sdk && pnpm install --frozen-lockfile - - name: Check OpenAPI Schema is Up-to-Date - run: | - source ./bin/activate-hermit - hermit uninstall rustup - just check-openapi-schema - - name: Check ACP Schema is Up-to-Date run: | source ./bin/activate-hermit just check-acp-schema + - name: Test ACP Client SDK + run: | + source ./bin/activate-hermit + cd ui/sdk + pnpm test + pnpm run typecheck:test + desktop-lint: name: Test and Lint Electron Desktop App runs-on: macos-latest @@ -201,7 +202,7 @@ jobs: if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' steps: - name: Checkout Code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # Temporarily disabled due to GitHub Actions bug on macOS runners # https://github.com/actions/runner-images/issues/13341 diff --git a/.github/workflows/close-release-pr-on-tag.yaml b/.github/workflows/close-release-pr-on-tag.yaml index 5eb8d16e5c..05e0f2d418 100644 --- a/.github/workflows/close-release-pr-on-tag.yaml +++ b/.github/workflows/close-release-pr-on-tag.yaml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Extract version from tag id: version diff --git a/.github/workflows/code-review.yml b/.github/workflows/code-review.yml index 014ec55efa..57f642396e 100644 --- a/.github/workflows/code-review.yml +++ b/.github/workflows/code-review.yml @@ -64,7 +64,7 @@ jobs: OIDC_TOKEN: ${{ steps.oidc.outputs.token }} - name: Upload review inputs - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: code-review-inputs path: /tmp/code-review/ @@ -84,7 +84,7 @@ jobs: steps: - name: Checkout base branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install tools run: | @@ -119,7 +119,7 @@ jobs: - name: Upload review output if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: code-review-output path: | diff --git a/.github/workflows/create-release-branch.yaml b/.github/workflows/create-release-branch.yaml index 75b4c4a541..bca8171ebf 100644 --- a/.github/workflows/create-release-branch.yaml +++ b/.github/workflows/create-release-branch.yaml @@ -18,13 +18,13 @@ jobs: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'version-bump/') steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 0 - - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 - - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Extract version and base branch env: diff --git a/.github/workflows/create-version-bump-pr.yaml b/.github/workflows/create-version-bump-pr.yaml index 7707a138fb..0e4d070ded 100644 --- a/.github/workflows/create-version-bump-pr.yaml +++ b/.github/workflows/create-version-bump-pr.yaml @@ -25,64 +25,77 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: main - fetch-depth: 0 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: main + fetch-depth: 0 - - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 - - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - - name: install dependencies - run: | - sudo apt update -y - sudo apt install -y libdbus-1-dev gnome-keyring libxcb1-dev + - name: install dependencies + run: | + sudo apt update -y + sudo apt install -y libdbus-1-dev gnome-keyring libxcb1-dev - - name: Compute version and create bump branch - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} - OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - XAI_API_KEY: ${{ secrets.XAI_API_KEY }} - TETRATE_API_KEY: ${{ secrets.TETRATE_API_KEY }} - run: | - VERSION=$(just get-next-minor-version) - echo "version=$VERSION" >> $GITHUB_ENV - echo "Version: $VERSION (minor bump on main)" + - name: Compute version and create bump branch + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + TETRATE_API_KEY: ${{ secrets.TETRATE_API_KEY }} + run: | + VERSION=$(just get-next-minor-version) + echo "version=$VERSION" >> $GITHUB_ENV + echo "Version: $VERSION (minor bump on main)" - git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" + git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" - BRANCH_NAME="version-bump/$VERSION" - git switch -c "$BRANCH_NAME" + BRANCH_NAME="version-bump/$VERSION" + git switch -C "$BRANCH_NAME" - just prepare-release $VERSION + just prepare-release $VERSION - echo "branch_name=$BRANCH_NAME" >> $GITHUB_ENV + echo "branch_name=$BRANCH_NAME" >> $GITHUB_ENV - - name: Push bump branch - run: | - git push origin "${{ env.branch_name }}" + - name: Push bump branch + run: | + git push --force-with-lease origin "${{ env.branch_name }}" - - name: Create Pull Request - run: | - PR_BODY=$(cat <<'EOF' - Bumps version to **${{ env.version }}**. + - name: Create Pull Request if needed + run: | + EXISTING_PR_URL=$(gh pr list \ + --base main \ + --head "${{ env.branch_name }}" \ + --state open \ + --json url \ + --jq '.[0].url // empty') - **Please follow these steps:** + if [ -n "$EXISTING_PR_URL" ]; then + echo "Existing PR found: $EXISTING_PR_URL" + echo "pr_url=$EXISTING_PR_URL" >> $GITHUB_ENV + exit 0 + fi - 1. Close and reopen this PR to trigger CI checks. Reason: workflows don't run on PRs opened by `GITHUB_TOKEN` ([docs](https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow)). - 2. Review and resolve any merge conflicts. - 3. Approve and merge this PR. - 4. The `release/${{ env.version }}` PR will be created automatically. - EOF - ) - PR_URL=$(gh pr create \ - -B main \ - -H "${{ env.branch_name }}" \ - --title "chore(release): bump version to ${{ env.version }} (minor)" \ - --body "$PR_BODY") - echo "pr_url=$PR_URL" >> $GITHUB_ENV - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_BODY=$(cat <<'EOF' + Bumps version to **${{ env.version }}**. + + **Please follow these steps:** + + 1. Approve workflows for this PR to trigger CI checks. + 2. Review and resolve any merge conflicts. + 3. Approve and merge this PR. + 4. The `release/${{ env.version }}` PR will be created automatically. + EOF + ) + PR_URL=$(gh pr create \ + -B main \ + -H "${{ env.branch_name }}" \ + --title "chore(release): bump version to ${{ env.version }} (minor)" \ + --body "$PR_BODY") + echo "pr_url=$PR_URL" >> $GITHUB_ENV + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/deploy-docs-and-extensions.yml b/.github/workflows/deploy-docs-and-extensions.yml index b93b5e7f70..d5f4bf2952 100644 --- a/.github/workflows/deploy-docs-and-extensions.yml +++ b/.github/workflows/deploy-docs-and-extensions.yml @@ -24,15 +24,15 @@ jobs: steps: - name: Checkout the branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: 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') }} @@ -58,10 +58,10 @@ jobs: uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Upload GitHub Pages artifact - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: documentation/build - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/docs-update-cli-ref.yml b/.github/workflows/docs-update-cli-ref.yml index 3df35a340e..9ac5cfca7d 100644 --- a/.github/workflows/docs-update-cli-ref.yml +++ b/.github/workflows/docs-update-cli-ref.yml @@ -43,7 +43,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + 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 @@ -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 @@ -73,7 +73,7 @@ jobs: python-version: '3.11' - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '20' @@ -191,7 +191,7 @@ jobs: - name: Upload automation outputs if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cli-docs-update-${{ steps.versions.outputs.old_version }}-to-${{ steps.versions.outputs.new_version }} path: | diff --git a/.github/workflows/docs-update-recipe-ref.yml b/.github/workflows/docs-update-recipe-ref.yml deleted file mode 100644 index a8c09cb0e8..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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 # Fetch all history for version comparison - fetch-tags: true # Fetch all tags so we can checkout version tags - - - name: Fetch upstream tags (for forks) - if: github.repository != 'aaif-goose/goose' - run: | - # Add upstream remote and fetch tags (only needed when testing in forks) - git remote add upstream https://github.com/aaif-goose/goose.git || git remote set-url upstream https://github.com/aaif-goose/goose.git - git fetch upstream --tags --force - echo "โœ… Fetched tags from upstream (fork mode)" - echo "Total tags available: $(git tag | wc -l)" - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y jq ripgrep - - - name: Set up Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 - with: - node-version: '20' - - - name: Install goose CLI - run: | - mkdir -p /home/runner/.local/bin - curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh \ - | CONFIGURE=false GOOSE_BIN_DIR=/home/runner/.local/bin bash - echo "/home/runner/.local/bin" >> $GITHUB_PATH - goose --version - - - name: Configure goose for CI - env: - GOOSE_PROVIDER: ${{ vars.GOOSE_PROVIDER || 'openai' }} - GOOSE_MODEL: ${{ vars.GOOSE_MODEL || 'gpt-4o' }} - run: | - mkdir -p ~/.config/goose - cat < ~/.config/goose/config.yaml - GOOSE_PROVIDER: $GOOSE_PROVIDER - GOOSE_MODEL: $GOOSE_MODEL - keyring: false - EOF - echo "โœ… Created goose config:" - cat ~/.config/goose/config.yaml - - - name: Determine versions to compare - id: versions - env: - GH_TOKEN: ${{ github.token }} - INPUT_OLD_VERSION: ${{ github.event.inputs.old_version }} - INPUT_NEW_VERSION: ${{ github.event.inputs.new_version }} - EVENT_NAME: ${{ github.event_name }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - run: | - get_previous_release() { - gh release list --limit 2 --json tagName --jq '.[].tagName' | sed -n '2p' - } - - if [ -n "$INPUT_OLD_VERSION" ]; then - OLD_VERSION="$INPUT_OLD_VERSION" - else - OLD_VERSION=$(get_previous_release) - fi - - if [ -n "$INPUT_NEW_VERSION" ]; then - NEW_VERSION="$INPUT_NEW_VERSION" - elif [ "$EVENT_NAME" = "release" ]; then - NEW_VERSION="$RELEASE_TAG" - else - NEW_VERSION="HEAD" # For testing unreleased changes - fi - - if [ -z "$OLD_VERSION" ] || [ -z "$NEW_VERSION" ]; then - echo "Error: Could not determine versions to compare" - exit 1 - fi - - echo "old_version=$OLD_VERSION" >> $GITHUB_OUTPUT - echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "OLD_VERSION=$OLD_VERSION" >> $GITHUB_ENV - echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV - - echo "โœ… Comparing $OLD_VERSION โ†’ $NEW_VERSION" - - - name: Extract and compare schemas - id: extract - timeout-minutes: 15 - working-directory: documentation/automation/recipe-schema-tracking - env: - GOOSE_REPO: ${{ github.workspace }} - run: | - set -o pipefail # Ensure pipeline failures are caught - - mkdir -p output - ./scripts/run-pipeline.sh "$OLD_VERSION" "$NEW_VERSION" 2>&1 | tee output/pipeline.log - - HAS_CHANGES=$(jq -r '.has_changes' output/validation-changes.json) - echo "has_changes=$HAS_CHANGES" >> $GITHUB_OUTPUT - - if [ "$HAS_CHANGES" = "false" ]; then - echo "โœ… No changes detected" - else - echo "โœ… Changes detected" - fi - - - name: Update recipe-reference.md (AI synthesis) - if: steps.extract.outputs.has_changes == 'true' - timeout-minutes: 10 - working-directory: documentation/automation/recipe-schema-tracking/output - env: - RECIPE_REF_PATH: ${{ github.workspace }}/documentation/docs/guides/recipes/recipe-reference.md - run: | - echo "๐Ÿ” Environment diagnostics:" - echo " GOOSE_PROVIDER: $GOOSE_PROVIDER" - echo " GOOSE_MODEL: $GOOSE_MODEL" - echo " OPENAI_API_KEY: ${OPENAI_API_KEY:0:8}..." # Show first 8 chars only - echo " RECIPE_REF_PATH: $RECIPE_REF_PATH" - echo " HOME: $HOME" - echo " PATH: $PATH" - echo "" - echo "๐Ÿ“ Goose config file:" - cat ~/.config/goose/config.yaml || echo "Config file not found!" - echo "" - echo "๐Ÿ“ Current directory:" - pwd - ls -la - echo "" - echo "๐Ÿค– Step 1: Running validation changes synthesis..." - goose run --recipe ../recipes/synthesize-validation-changes.yaml - - echo "" - echo "๐Ÿค– Step 2: Applying changes to recipe-reference.md..." - goose run --recipe ../recipes/update-recipe-reference.yaml - - - name: Upload automation outputs - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: recipe-docs-update-${{ steps.versions.outputs.old_version }}-to-${{ steps.versions.outputs.new_version }} - path: | - documentation/automation/recipe-schema-tracking/output/*.json - documentation/automation/recipe-schema-tracking/output/*.md - documentation/automation/recipe-schema-tracking/output/*.log - retention-days: 30 - - - name: Create Pull Request - if: | - steps.extract.outputs.has_changes == 'true' && - (github.event.inputs.dry_run != 'true' || github.event_name == 'release') - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 - with: - branch: docs/auto-recipe-reference-${{ steps.versions.outputs.new_version }} - delete-branch: true - - commit-message: | - docs: Update recipe reference for ${{ steps.versions.outputs.new_version }} - - Automated update based on schema changes between ${{ steps.versions.outputs.old_version }} and ${{ steps.versions.outputs.new_version }}. - - title: "docs: Update Recipe Reference Guide for ${{ steps.versions.outputs.new_version }}" - body: | - ## Summary - - This PR updates the Recipe Reference Guide based on schema and validation changes detected between **${{ steps.versions.outputs.old_version }}** and **${{ steps.versions.outputs.new_version }}**. - - ### Type of Change - - [x] Documentation - - ### AI Assistance - - [x] This PR was created or reviewed with AI assistance - - #### ๐Ÿค– Automation Details - - - **Workflow**: `docs-update-recipe-ref.yml` - - **Triggered by**: ${{ github.event_name }} - - **Previous version**: ${{ steps.versions.outputs.old_version }} - - **New version**: ${{ steps.versions.outputs.new_version }} - - #### ๐Ÿ“‹ Changes Detected - - Review the workflow artifacts for detailed change analysis: - - `validation-changes.json` - Structured diff of changes - - `validation-changes.md` - Human-readable change documentation - - `update-summary.md` - Summary of documentation updates applied - - Download artifacts from the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). - - ### โœ… Review Checklist - - - [ ] Verify all schema changes are accurately documented - - [ ] Check that examples are updated correctly - - [ ] Ensure validation rules are clearly explained - - [ ] Confirm no unintended changes were made - - [ ] Do changes require additional updates in this or other recipe topics? - - ### ๐Ÿ”— Related - - - Release: ${{ github.event.release.html_url || 'N/A' }} - - --- - - *This PR was automatically generated by the Recipe Documentation Automation workflow.* - - labels: | - documentation - automated - recipe-reference - - - name: Workflow summary - if: always() - env: - OLD_VERSION: ${{ steps.versions.outputs.old_version }} - NEW_VERSION: ${{ steps.versions.outputs.new_version }} - HAS_CHANGES: ${{ steps.extract.outputs.has_changes }} - DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} - run: | - echo "## ๐Ÿ“Š Recipe Documentation Update Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Version Comparison**: $OLD_VERSION โ†’ $NEW_VERSION" >> $GITHUB_STEP_SUMMARY - echo "**Changes Detected**: $HAS_CHANGES" >> $GITHUB_STEP_SUMMARY - echo "**Dry Run Mode**: $DRY_RUN" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ "$HAS_CHANGES" = "true" ]; then - echo "### โœ… Documentation Updated" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "The Recipe Reference Guide has been updated to reflect changes in $NEW_VERSION." >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ "$DRY_RUN" = "true" ]; then - echo "**Note**: Running in dry-run mode - no PR was created. Review the artifacts to see the generated changes." >> $GITHUB_STEP_SUMMARY - else - echo "A pull request has been created with the documentation updates." >> $GITHUB_STEP_SUMMARY - fi - else - echo "### โ„น๏ธ No Changes Needed" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "No recipe schema or validation changes were detected between $OLD_VERSION and $NEW_VERSION." >> $GITHUB_STEP_SUMMARY - fi - - echo "" >> $GITHUB_STEP_SUMMARY - echo "### ๐Ÿ“ฆ Artifacts" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Download the workflow artifacts to review:" >> $GITHUB_STEP_SUMMARY - echo "- Extracted schemas and validation structures" >> $GITHUB_STEP_SUMMARY - echo "- Change detection results" >> $GITHUB_STEP_SUMMARY - echo "- Human-readable change documentation" >> $GITHUB_STEP_SUMMARY - echo "- Documentation update summary" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/goose-issue-solver.yml b/.github/workflows/goose-issue-solver.yml index af5caea73b..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: @@ -150,7 +150,7 @@ jobs: -d '{"content":"eyes"}' - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: main fetch-depth: 1 diff --git a/.github/workflows/goose-pr-reviewer.yml b/.github/workflows/goose-pr-reviewer.yml index 4e0e9df455..0d2a272d0c 100644 --- a/.github/workflows/goose-pr-reviewer.yml +++ b/.github/workflows/goose-pr-reviewer.yml @@ -241,7 +241,7 @@ jobs: -d '{"content":"eyes"}' - name: Checkout PR - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: refs/pull/${{ github.event.issue.number }}/head fetch-depth: 1 diff --git a/.github/workflows/goose-release-notes.yml b/.github/workflows/goose-release-notes.yml index bb0d552af8..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') || @@ -113,7 +115,7 @@ jobs: fi - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 fetch-tags: true diff --git a/.github/workflows/minor-release.yaml b/.github/workflows/minor-release.yaml index 539dc33543..e4e32d63bb 100644 --- a/.github/workflows/minor-release.yaml +++ b/.github/workflows/minor-release.yaml @@ -6,12 +6,61 @@ permissions: on: schedule: - - cron: '0 0 * * 2' + - cron: "0 0 * * 2" workflow_dispatch: + push: + branches: + - main jobs: + check-version-bump-pr: + if: github.repository == 'aaif-goose/goose' && github.event_name == 'push' + runs-on: ubuntu-latest + outputs: + should_run: ${{ steps.check.outputs.should_run }} + steps: + - name: Check for conflicted version bump PR + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + for attempt in 1 2 3 4 5 6; do + CONFLICTING_PR_URLS=$(gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --base main \ + --state open \ + --search 'head:version-bump/' \ + --json mergeStateStatus,url \ + --jq '.[] | select(.mergeStateStatus == "DIRTY") | .url') + + UNKNOWN_COUNT=$(gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --base main \ + --state open \ + --search 'head:version-bump/' \ + --json mergeStateStatus \ + --jq '[.[] | select(.mergeStateStatus == "UNKNOWN")] | length') + + if [ -n "$CONFLICTING_PR_URLS" ] || [ "$UNKNOWN_COUNT" = "0" ]; then + break + fi + + echo "Waiting for GitHub to compute PR mergeability (attempt $attempt)..." + sleep 10 + done + + if [ -n "$CONFLICTING_PR_URLS" ]; then + echo "Found conflicted version bump PR(s):" + echo "$CONFLICTING_PR_URLS" + echo "should_run=true" >> "$GITHUB_OUTPUT" + else + echo "No conflicted version bump PR found." + echo "should_run=false" >> "$GITHUB_OUTPUT" + fi + release: - if: github.repository == 'aaif-goose/goose' + needs: check-version-bump-pr + if: always() && github.repository == 'aaif-goose/goose' && (github.event_name != 'push' || needs.check-version-bump-pr.outputs.should_run == 'true') uses: ./.github/workflows/create-version-bump-pr.yaml secrets: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/.github/workflows/patch-release.yaml b/.github/workflows/patch-release.yaml index 8b0b375cb2..b9936551ca 100644 --- a/.github/workflows/patch-release.yaml +++ b/.github/workflows/patch-release.yaml @@ -19,13 +19,13 @@ jobs: TARGET_BRANCH: ${{ inputs.target_branch }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.target_branch }} fetch-depth: 0 - - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 - - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: install dependencies run: | diff --git a/.github/workflows/pr-comment-build-cli.yml b/.github/workflows/pr-comment-build-cli.yml index da280f16a8..70aaa24cc8 100644 --- a/.github/workflows/pr-comment-build-cli.yml +++ b/.github/workflows/pr-comment-build-cli.yml @@ -92,7 +92,7 @@ jobs: - name: Checkout code if: steps.security_check.outputs.authorized == 'true' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Get PR head SHA with gh id: set_head_sha diff --git a/.github/workflows/pr-comment-bundle-intel.yml b/.github/workflows/pr-comment-bundle-intel.yml index ed88f6571a..6b8fba33c3 100644 --- a/.github/workflows/pr-comment-bundle-intel.yml +++ b/.github/workflows/pr-comment-bundle-intel.yml @@ -45,7 +45,7 @@ jobs: allowed_contexts: pull_request - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Get PR head SHA with gh id: set_head_sha diff --git a/.github/workflows/pr-comment-bundle-windows.yml b/.github/workflows/pr-comment-bundle-windows.yml index 69ac315c3a..b6a7840059 100644 --- a/.github/workflows/pr-comment-bundle-windows.yml +++ b/.github/workflows/pr-comment-bundle-windows.yml @@ -48,7 +48,7 @@ jobs: allowed_contexts: pull_request - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Get PR head SHA with gh id: set_head_sha diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml index 814658875f..ae83ad2afe 100644 --- a/.github/workflows/pr-smoke-test.yml +++ b/.github/workflows/pr-smoke-test.yml @@ -30,7 +30,7 @@ jobs: code: ${{ steps.filter.outputs.code }} steps: - name: Checkout Code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.inputs.branch || github.ref }} fetch-depth: 0 @@ -51,11 +51,11 @@ jobs: if: needs.changes.outputs.code == 'true' || github.event_name == 'workflow_dispatch' steps: - name: Checkout Code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 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,29 +67,22 @@ 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: goose-binary path: target/debug/goose retention-days: 1 - - name: Upload goosed binary - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: goosed-binary - path: target/debug/goosed - retention-days: 1 - smoke-tests: name: Smoke Tests runs-on: ubuntu-latest needs: build-binary steps: - name: Checkout Code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.inputs.branch || github.ref }} @@ -103,7 +96,7 @@ jobs: run: chmod +x target/debug/goose - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '22' @@ -142,7 +135,7 @@ jobs: python-version: '3.12' - name: Install uv - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Run MCP Tests env: @@ -177,7 +170,7 @@ jobs: needs: build-binary steps: - name: Checkout Code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.inputs.branch || github.ref }} @@ -220,7 +213,7 @@ jobs: needs: build-binary steps: - name: Checkout Code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.inputs.branch || github.ref }} @@ -239,7 +232,7 @@ jobs: python-version: '3.12' - name: Install uv - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Run Compaction Tests env: @@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.event.inputs.branch || github.ref }} - - - name: Download Binary - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: goosed-binary - path: target/debug - - - name: Make Binary Executable - run: chmod +x target/debug/goosed - - - name: Install Node.js Dependencies - run: source ../../bin/activate-hermit && pnpm install --frozen-lockfile - working-directory: ui/desktop - - - name: Run Integration Tests - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - GOOSED_BINARY: ../../target/debug/goosed - GOOSE_PROVIDER: anthropic - GOOSE_MODEL: claude-sonnet-4-5-20250929 - SHELL: /bin/bash - SKIP_BUILD: 1 - run: | - echo 'export PATH=/some/fake/path:$PATH' >> $HOME/.bash_profile - source ../../bin/activate-hermit && pnpm run test:integration:goosed - working-directory: ui/desktop diff --git a/.github/workflows/pr-website-preview.yml b/.github/workflows/pr-website-preview.yml index b7db3e4d65..56f7bd2aae 100644 --- a/.github/workflows/pr-website-preview.yml +++ b/.github/workflows/pr-website-preview.yml @@ -20,12 +20,16 @@ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Node.js for docs build - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 20 cache: npm @@ -47,17 +51,16 @@ jobs: run: ./scripts/verify-build.sh - name: Setup Node.js for Wrangler - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + 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 c25a4228f5..5bd90040c6 100644 --- a/.github/workflows/publish-ask-ai-bot.yml +++ b/.github/workflows/publish-ask-ai-bot.yml @@ -19,13 +19,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + 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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -33,7 +33,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.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 0c08ed6eef..c15177fa4a 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -23,13 +23,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + 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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -37,7 +37,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.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 3fdac7136a..1653b1dc30 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -22,17 +22,17 @@ jobs: needs: [build-cli] steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24.10.0' registry-url: 'https://registry.npmjs.org' always-auth: true - name: Setup pnpm - uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6.0.5 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: version: 10.30.3 @@ -100,10 +100,7 @@ jobs: cd ui/sdk pnpm run build:ts - cd ../text - pnpm run build - - - name: Compatibility check (TUI client โ†” goose binary) + - name: Compatibility check (SDK client โ†” goose binary) env: GOOSE_BINARY: ${{ github.workspace }}/ui/goose-binary/goose-binary-linux-x64/bin/goose run: | @@ -111,7 +108,7 @@ jobs: pnpm run check:compat - name: Upload built packages - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: npm-packages path: ui/ @@ -135,7 +132,7 @@ jobs: echo "" echo "### npm Packages" cd ui - for pkg in sdk text goose-binary/*/; do + for pkg in sdk goose-binary/*/; do if [ -f "$pkg/package.json" ]; then name=$(jq -r '.name' "$pkg/package.json") version=$(jq -r '.version' "$pkg/package.json") @@ -160,14 +157,14 @@ jobs: run: find ui/goose-binary -name 'goose' -type f -exec chmod +x {} + - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24.10.0' registry-url: 'https://registry.npmjs.org' always-auth: true - name: Setup pnpm - uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6.0.5 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: version: 10.30.3 @@ -177,7 +174,7 @@ jobs: # Publish each package individually so one failure doesn't abort the rest. # Skips packages whose version is already published (409/403). failed=0 - for pkg in sdk goose-binary/*/ text; do + for pkg in sdk goose-binary/*/; do if [ -f "$pkg/package.json" ]; then name=$(jq -r '.name' "$pkg/package.json") version=$(jq -r '.version' "$pkg/package.json") @@ -207,7 +204,7 @@ jobs: { echo "## ๐Ÿš€ Published Packages" echo "" - for pkg in sdk text goose-binary/*/; do + for pkg in sdk goose-binary/*/; do if [ -f "$pkg/package.json" ]; then name=$(jq -r '.name' "$pkg/package.json") version=$(jq -r '.version' "$pkg/package.json") diff --git a/.github/workflows/python-sdk-wheels.yml b/.github/workflows/python-sdk-wheels.yml 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/quarantine.yml b/.github/workflows/quarantine.yml index 940d17b903..9f99702d14 100644 --- a/.github/workflows/quarantine.yml +++ b/.github/workflows/quarantine.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check PR Author run: | # Get PR author diff --git a/.github/workflows/rebuild-skills-marketplace.yml b/.github/workflows/rebuild-skills-marketplace.yml index db0ad4c27c..5aee55c5c0 100644 --- a/.github/workflows/rebuild-skills-marketplace.yml +++ b/.github/workflows/rebuild-skills-marketplace.yml @@ -45,15 +45,15 @@ jobs: fi - name: Checkout the branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: 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') }} @@ -78,10 +78,10 @@ jobs: uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Upload GitHub Pages artifact - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: documentation/build - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/recipe-security-scanner.yml b/.github/workflows/recipe-security-scanner.yml index ab5ccecca9..edcb4dfb7e 100644 --- a/.github/workflows/recipe-security-scanner.yml +++ b/.github/workflows/recipe-security-scanner.yml @@ -30,14 +30,14 @@ jobs: # We checkout the base branch (trusted) for building the scanner image, # and only fetch recipe files from the PR for scanning. - name: Checkout base branch (trusted code) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.pull_request.base.sha }} fetch-depth: 0 path: trusted - name: Fetch PR recipe files only - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 @@ -119,7 +119,7 @@ jobs: - name: Set up Docker Buildx if: steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true' - uses: docker/setup-buildx-action@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' @@ -243,7 +243,7 @@ jobs: - name: Upload scan artifacts if: always() && steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: security-scan path: ${{ runner.temp }}/security-scan/** diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab4f88116e..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 @@ -34,8 +41,8 @@ jobs: runs-on: ubuntu-latest needs: [build-cli] steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: download_cli.sh path: download_cli.sh @@ -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,13 +116,25 @@ jobs: id-token: write # Required for Sigstore OIDC signing attestations: write # Required for SLSA build provenance attestations steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Download all artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: merge-multiple: true + - name: Generate macOS update manifest + if: ${{ env.ENABLE_MAC_NATIVE_AUTO_UPDATE == 'true' }} + run: node ui/desktop/scripts/generate-mac-update-manifest.js --version "${GITHUB_REF_NAME}" --directory . + + - name: Attest macOS update manifest + if: ${{ env.ENABLE_MAC_NATIVE_AUTO_UPDATE == 'true' }} + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: latest-mac.yml + - name: Attest build provenance - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-path: | goose-*.tar.bz2 @@ -163,3 +183,11 @@ jobs: allowUpdates: true omitBody: true omitPrereleaseDuringUpdate: true + + - name: Upload macOS update manifest + if: ${{ env.ENABLE_MAC_NATIVE_AUTO_UPDATE == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${GITHUB_REF_NAME}" latest-mac.yml --clobber + gh release upload stable latest-mac.yml --clobber diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 56eb8cec8a..7e2c50683f 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -34,12 +34,12 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif @@ -64,7 +64,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: SARIF file path: results.sarif 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/test-finder.yml b/.github/workflows/test-finder.yml index 0116e76d9b..c00476d77d 100644 --- a/.github/workflows/test-finder.yml +++ b/.github/workflows/test-finder.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/update-hacktoberfest-leaderboard.yml b/.github/workflows/update-hacktoberfest-leaderboard.yml index f452b8891e..8c9989a79c 100644 --- a/.github/workflows/update-hacktoberfest-leaderboard.yml +++ b/.github/workflows/update-hacktoberfest-leaderboard.yml @@ -16,7 +16,7 @@ jobs: issues: write steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Update Leaderboard uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/.github/workflows/update-health-dashboard.yml b/.github/workflows/update-health-dashboard.yml index 5dfd73d350..36f06dbe10 100644 --- a/.github/workflows/update-health-dashboard.yml +++ b/.github/workflows/update-health-dashboard.yml @@ -223,7 +223,7 @@ jobs: console.log(`Successfully updated discussion #${discussionNumber}`); - name: 'Upload metrics artifact' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: health-metrics path: health-metrics.json diff --git a/.github/workflows/update-release-pr.yaml b/.github/workflows/update-release-pr.yaml index 987243f704..0c5e54a877 100644 --- a/.github/workflows/update-release-pr.yaml +++ b/.github/workflows/update-release-pr.yaml @@ -17,12 +17,12 @@ jobs: runs-on: ubuntu-latest if: startsWith(github.event.pull_request.head.ref, 'release/') && github.event.pull_request.user.login == 'github-actions[bot]' steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 - - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 - - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Extract version from branch name env: 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 286ecce432..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,28 +33,21 @@ cargo clippy --all-targets -- -D warnings ### UI ```bash -just generate-openapi # after server changes just run-ui # start desktop +cd ui/desktop && pnpm run typecheck cd ui/desktop && pnpm test # test UI ``` -### Git -```bash -git commit -s # required for DCO sign-off -``` - ## Structure ``` crates/ โ”œโ”€โ”€ goose # core logic โ”œโ”€โ”€ goose-acp-macros # ACP proc macros โ”œโ”€โ”€ goose-cli # CLI entry -โ”œโ”€โ”€ goose-server # backend (binary: goosed) โ”œโ”€โ”€ goose-mcp # MCP extensions โ”œโ”€โ”€ goose-test # test utilities โ””โ”€โ”€ goose-test-support # test helpers -evals/open-model-gym/ # benchmarking / evals ui/desktop/ # Electron app ``` @@ -70,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 @@ -80,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 @@ -112,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 @@ -121,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 e916759515..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. @@ -173,41 +174,38 @@ The app opens a window and displays first-time setup. After completing setup, go Make GUI changes in `ui/desktop`. -### Regenerating the OpenAPI schema +#### Troubleshooting: blank screen on `just run-ui` -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: +If the app opens to a blank window (logs show `Cannot read properties of null (reading 'useRef')`), your `node_modules` is out of date and is loading two copies of React. Delete it and reinstall: ``` -just generate-openapi +rm -rf ui/desktop/node_modules +cd ui && pnpm install ``` -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/`. +See #8757. ### 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 @@ -350,16 +348,6 @@ This project follows the [Conventional Commits](https://www.conventionalcommits. [hermit]: https://cashapp.github.io/hermit/ [just]: https://github.com/casey/just?tab=readme-ov-file#installation -## Developer Certificate of Origin - -This project requires a [Developer Certificate of Origin](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin) sign-offs on all commits. This is a statement indicating that you are allowed to make the contribution and that the project has the right to distribute it under its license. When you are ready to commit, use the `--signoff` or `-s` flag to attach the sign-off to your commit. - -``` -git commit --signoff ... -# OR -git commit -s ... -``` - ## Other Ways to Contribute There are numerous ways to be an open source contributor and contribute to goose. We're here to help you on your way! Here are some suggestions to get started. If you have any questions or need help, feel free to reach out to us on [Discord](https://discord.gg/goose-oss). diff --git a/CUSTOM_DISTROS.md b/CUSTOM_DISTROS.md index 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 a962076cdd..befb0adfcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,50 +33,65 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cipher", "cpufeatures 0.2.17", ] [[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", @@ -94,7 +109,7 @@ version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "getrandom 0.3.4", "once_cell", "serde", @@ -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,26 +217,26 @@ 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 = "ar_archive_writer" +name = "approx" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" dependencies = [ - "object", + "num-traits", ] [[package]] -name = "arbitrary" -version = "1.4.2" +name = "ar_archive_writer" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" dependencies = [ - "derive_arbitrary", + "object", ] [[package]] @@ -243,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", ] @@ -258,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" @@ -268,6 +283,59 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "askama" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738" +dependencies = [ + "askama_parser", + "basic-toml", + "glob", + "memchr", + "proc-macro2", + "quote", + "rustc-hash 2.1.3", + "serde", + "serde_derive", + "syn 2.0.118", +] + +[[package]] +name = "askama_macros" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" +dependencies = [ + "rustc-hash 2.1.3", + "serde", + "serde_derive", + "unicode-ident", + "winnow 1.0.3", +] + [[package]] name = "asn1-rs" version = "0.7.2" @@ -292,7 +360,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -304,7 +372,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -325,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]] @@ -340,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" @@ -365,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" @@ -376,7 +527,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -439,9 +590,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-config" -version = "1.8.17" +version = "1.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "517aa062d8bd9015ee23d6daa5e1c1372328412fdae4e6c4c1be9b69c6ad37a2" +checksum = "e33f815b73a3899c03b380d543532e5865f230dce9678d108dc10732a8682275" dependencies = [ "aws-credential-types", "aws-runtime", @@ -449,17 +600,17 @@ 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", "fastrand", "hex", - "http 1.4.1", + "http 1.4.2", "sha1", "time", "tokio", @@ -482,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", @@ -493,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", @@ -521,7 +673,7 @@ dependencies = [ "bytes", "bytes-utils", "fastrand", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "percent-encoding", "pin-project-lite", @@ -531,18 +683,19 @@ dependencies = [ [[package]] name = "aws-sdk-bedrockruntime" -version = "1.131.0" +version = "1.135.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e494025b4c578bfefd025aada69c51ab1db6b7589f61cb78ae681f3115269209" +checksum = "e74b780f2f36912bae71b4f4f8ed9a0a88832b4681a1add3caf5ca25dbc8ab2d" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "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", @@ -550,7 +703,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "http-body-util", "regex-lite", "tracing", @@ -558,17 +711,18 @@ dependencies = [ [[package]] name = "aws-sdk-sagemakerruntime" -version = "1.102.0" +version = "1.105.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb969c5c411b21a4bca5b52e9f5492af20e927fd4c410f748a3996462eb295c5" +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", @@ -576,23 +730,24 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-sso" -version = "1.99.0" +version = "1.102.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f4055e6099b2ec264abdc0d9bbfffce306c1601809275c861594779a0b04b45" +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", @@ -600,23 +755,24 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-ssooidc" -version = "1.101.0" +version = "1.104.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02f009ba0284c5d696425fd7b4dcc5b189f5726f4041b7a5794daecb3a68d598" +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", @@ -624,23 +780,24 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-sts" -version = "1.104.0" +version = "1.107.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aa6622798e19e6a76b690562085dd4771c736cd48343464a53ab4ae2f2c9f84" +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", @@ -649,20 +806,20 @@ dependencies = [ "aws-types", "fastrand", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sigv4" -version = "1.4.4" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7083fb918b38474ac65ffbf8a69fc8792d36879f4ac5f1667b43aec61efe9a5" +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", @@ -670,7 +827,7 @@ dependencies = [ "hex", "hmac 0.13.0", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "percent-encoding", "sha2 0.11.0", "time", @@ -679,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", @@ -690,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", @@ -712,7 +869,28 @@ dependencies = [ "bytes-utils", "futures-core", "futures-util", - "http 1.4.1", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "percent-encoding", @@ -723,12 +901,12 @@ dependencies = [ [[package]] name = "aws-smithy-json" -version = "0.62.6" +version = "0.62.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "517089205f18ab4adc5a3e02888cb139bbbbb2e168eac9f396216925d1fbeaf5" +checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa" dependencies = [ "aws-smithy-runtime-api", - "aws-smithy-schema", + "aws-smithy-schema 0.1.0", "aws-smithy-types", ] @@ -741,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" @@ -753,20 +940,20 @@ 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", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -778,16 +965,16 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc117c179ecf39a62a0a3f49f600e9ac26a7ad7dd172177999f83933af776c32" +checksum = "22ed1ebe6e0a95ea84570225f5a8208dec4b8f77e61a9b0d6f51773fcb4612f0" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api-macros", "aws-smithy-types", "bytes", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "pin-project-lite", "tokio", "tracing", @@ -796,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]] @@ -813,21 +1000,32 @@ checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", - "http 1.4.1", + "http 1.4.2", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.2", ] [[package]] name = "aws-smithy-types" -version = "1.4.8" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "056b66dbce2f81cc0c1e2b05bb402eb58f8a3530479d650efadd5bbae9a4050b" +checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8" dependencies = [ "base64-simd", "bytes", "bytes-utils", "futures-core", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -860,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", @@ -878,7 +1076,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper", @@ -900,6 +1098,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -910,7 +1109,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "mime", @@ -918,6 +1117,7 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -928,7 +1128,7 @@ checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -941,7 +1141,7 @@ dependencies = [ "bytes", "either", "fs-err", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "hyper", "hyper-util", @@ -969,7 +1169,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", - "cfg-if", + "cfg-if 1.0.4", "libc", "miniz_oxide", "object", @@ -1023,6 +1223,15 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bat" version = "0.26.1" @@ -1054,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", @@ -1094,7 +1303,7 @@ 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", @@ -1103,9 +1312,9 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash 2.1.2", - "shlex", - "syn 2.0.117", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.118", ] [[package]] @@ -1134,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", @@ -1154,7 +1363,7 @@ dependencies = [ "biome_rowan", "biome_text_edit", "biome_text_size", - "bitflags 2.11.1", + "bitflags 2.13.0", "bpaf", "serde", "termcolor", @@ -1193,7 +1402,7 @@ dependencies = [ "biome_deserialize", "biome_diagnostics", "biome_rowan", - "cfg-if", + "cfg-if 1.0.4", "countme", "drop_bomb", "indexmap 1.9.3", @@ -1228,7 +1437,7 @@ dependencies = [ "biome_json_syntax", "biome_rowan", "biome_text_size", - "cfg-if", + "cfg-if 1.0.4", "smallvec", "tracing", "unicode-width 0.1.14", @@ -1247,8 +1456,8 @@ dependencies = [ "biome_js_unicode_table", "biome_parser", "biome_rowan", - "bitflags 2.11.1", - "cfg-if", + "bitflags 2.13.0", + "cfg-if 1.0.4", "drop_bomb", "indexmap 1.9.3", "rustc-hash 1.1.0", @@ -1332,7 +1541,7 @@ dependencies = [ "biome_console", "biome_diagnostics", "biome_rowan", - "bitflags 2.11.1", + "bitflags 2.13.0", "drop_bomb", ] @@ -1413,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", ] @@ -1437,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", @@ -1465,7 +1695,7 @@ dependencies = [ "arrayref", "arrayvec", "cc", - "cfg-if", + "cfg-if 1.0.4", "constant_time_eq", "cpufeatures 0.3.0", ] @@ -1487,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", ] @@ -1512,6 +1742,44 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bon" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +dependencies = [ + "darling 0.23.0", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.118", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -1525,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]] @@ -1545,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", @@ -1561,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", @@ -1580,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]] @@ -1621,7 +1889,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1638,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", @@ -1695,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", @@ -1725,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", @@ -1758,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", @@ -1769,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", @@ -1795,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", @@ -1823,7 +2094,31 @@ 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.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", ] [[package]] @@ -1846,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", + "shlex 2.0.1", ] [[package]] @@ -1876,6 +2171,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + [[package]] name = "cfg-if" version = "1.0.4" @@ -1894,18 +2195,18 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cipher", "cpufeatures 0.2.17", ] [[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", + "cfg-if 1.0.4", "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -1925,9 +2226,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1993,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", ] @@ -2019,7 +2320,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2030,9 +2331,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clap_mangen" -version = "0.2.33" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" +checksum = "d82842b45bf9f6a3be090dd860095ac30728042c08e0d6261ca7259b5d850f07" dependencies = [ "clap", "roff", @@ -2040,9 +2341,9 @@ 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", "indicatif", @@ -2067,7 +2368,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d9334f725b46fb9bed8580b9b47a932587e044fadb344ed7fa98774b067ac1a" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "windows 0.56.0", ] @@ -2082,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" @@ -2122,6 +2423,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "combine" version = "4.6.7" @@ -2149,7 +2459,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" dependencies = [ "castaway", - "cfg-if", + "cfg-if 1.0.4", "itoa", "ryu", "static_assertions", @@ -2157,12 +2467,12 @@ 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", + "cfg-if 1.0.4", "itoa", "rustversion", "ryu", @@ -2199,23 +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.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", @@ -2235,6 +2533,21 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + +[[package]] +name = "const_panic" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" +dependencies = [ + "typewit", +] + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -2340,6 +2653,15 @@ dependencies = [ "libm", ] +[[package]] +name = "countio" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9702aee5d1d744c01d82f6915644f950f898e014903385464c773b96fefdecb" +dependencies = [ + "futures-io", +] + [[package]] name = "countme" version = "3.0.1" @@ -2385,7 +2707,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -2413,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", @@ -2432,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" @@ -2492,6 +2814,27 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "0.2.9" @@ -2499,9 +2842,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] +[[package]] +name = "ctor" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" +dependencies = [ + "ctor-proc-macro", + "dtor 0.1.1", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + [[package]] name = "ctutils" version = "0.4.2" @@ -2541,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", @@ -2556,7 +2915,7 @@ version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", @@ -2574,9 +2933,15 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] +[[package]] +name = "daachorse" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f55d7153ba3b507595872a3874803f07a8a81d1e888abed8e5db7da0597d6e2" + [[package]] name = "darling" version = "0.20.11" @@ -2587,6 +2952,16 @@ dependencies = [ "darling_macro 0.20.11", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + [[package]] name = "darling" version = "0.23.0" @@ -2608,7 +2983,21 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", ] [[package]] @@ -2621,7 +3010,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2632,7 +3021,18 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.118", ] [[package]] @@ -2643,7 +3043,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2661,21 +3061,7 @@ version = "5.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" dependencies = [ - "cfg-if", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "dashmap" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" -dependencies = [ - "cfg-if", - "crossbeam-utils", + "cfg-if 1.0.4", "hashbrown 0.14.5", "lock_api", "once_cell", @@ -2696,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", @@ -2744,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" @@ -2802,7 +3219,7 @@ dependencies = [ "deno_error", "deno_media_type", "deno_path_util", - "http 1.4.1", + "http 1.4.2", "indexmap 2.14.0", "log", "once_cell", @@ -2909,7 +3326,7 @@ checksum = "1c28ede88783f14cd8aae46ca89f230c226b40e4a81ab06fa52ed72af84beb2f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2976,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", ] @@ -3129,7 +3546,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3138,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" @@ -3171,7 +3576,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3181,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]] @@ -3203,7 +3608,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.118", "unicode-xid", ] @@ -3231,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", @@ -3246,7 +3651,7 @@ dependencies = [ "diplomat_core", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3266,16 +3671,7 @@ dependencies = [ "serde", "smallvec", "strck", - "syn 2.0.117", -] - -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys 0.4.1", + "syn 2.0.118", ] [[package]] @@ -3284,19 +3680,7 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys 0.5.0", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.4.6", - "windows-sys 0.48.0", + "dirs-sys", ] [[package]] @@ -3307,7 +3691,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.2", + "redox_users", "windows-sys 0.61.2", ] @@ -3317,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]] @@ -3387,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", @@ -3404,13 +3788,28 @@ checksum = "9bda8e21c04aca2ae33ffc2fd8c23134f3cac46db123ba97bd9d3f3b8a4a85e1" [[package]] name = "dtor" -version = "1.0.3" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2137ce22f50d4c43ce098daf41c904cc700de1ce8bc2daf53ed4e702180a464" +checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d738e43aa64edab57c983d56de890d65fea7dc05605490c74451ce721dfd84b" dependencies = [ "linktime-proc-macro", ] +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dunce" version = "1.0.5" @@ -3546,7 +3945,7 @@ version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -3564,7 +3963,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3584,7 +3983,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3626,6 +4025,9 @@ name = "esaxx-rs" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" +dependencies = [ + "cc", +] [[package]] name = "etcetera" @@ -3633,7 +4035,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "home", "windows-sys 0.48.0", ] @@ -3644,7 +4046,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "windows-sys 0.61.2", ] @@ -3659,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" @@ -3702,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" @@ -3745,7 +4168,7 @@ version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", ] @@ -3801,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", @@ -3866,7 +4289,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3916,14 +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 = "3.3.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" dependencies = [ "autocfg", "tokio", @@ -4054,7 +4477,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4086,6 +4509,15 @@ dependencies = [ "slab", ] +[[package]] +name = "gearhash" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cf82cf76cd16485e56295a1377c775ce708c9f1a0be6b029076d60a245d213" +dependencies = [ + "cfg-if 0.1.10", +] + [[package]] name = "gemm" version = "0.18.2" @@ -4221,7 +4653,7 @@ dependencies = [ "num-traits", "once_cell", "paste", - "pulp 0.22.2", + "pulp 0.22.3", "raw-cpuid", "rayon", "seq-macro", @@ -4351,10 +4783,10 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -4364,7 +4796,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "libc", "r-efi 5.3.0", @@ -4374,16 +4806,16 @@ 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", + "cfg-if 1.0.4", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", + "wasm-bindgen", ] [[package]] @@ -4412,6 +4844,26 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "git-version" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad568aa3db0fcbc81f2f116137f263d7304f512a1209b35b85150d3ef88ad19" +dependencies = [ + "git-version-macro", +] + +[[package]] +name = "git-version-macro" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "glob" version = "0.3.3" @@ -4443,22 +4895,35 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + [[package]] name = "goose" -version = "1.37.0" +version = "1.42.0" dependencies = [ "agent-client-protocol", + "agent-client-protocol-http", "agent-client-protocol-schema", - "ahash", "anyhow", "arboard", "async-stream", "async-trait", "aws-config", + "aws-lc-rs", "aws-sdk-bedrockruntime", "aws-sdk-sagemakerruntime", "aws-smithy-types", "axum", + "axum-server", "base64 0.22.1", "blake3", "byteorder", @@ -4468,26 +4933,27 @@ dependencies = [ "candle-transformers", "chrono", "clap", - "ctor", - "dashmap 6.2.1", - "dirs 5.0.1", + "ctor 0.2.9", + "dirs", "dotenvy", - "dtor", - "encoding_rs", + "dtor 1.0.5", "env-lock", "etcetera 0.11.0", "fs-err", "fs2", "futures", + "gethostname", "goose-acp-macros", + "goose-download-manager", "goose-mcp", - "goose-sdk", + "goose-providers", + "goose-sdk-types", "goose-test-support", - "http 1.4.1", - "http-body-util", + "http 1.4.2", "icu_calendar", "icu_locale", "ignore", + "image 0.24.9", "include_dir", "indexmap 2.14.0", "indoc", @@ -4496,9 +4962,7 @@ dependencies = [ "jsonwebtoken", "keyring", "libc", - "llama-cpp-2", - "llama-cpp-sys-2", - "lru", + "lru 0.18.0", "minijinja", "mockall", "nanoid", @@ -4506,29 +4970,29 @@ dependencies = [ "nostr-sdk", "oauth2", "once_cell", + "openssl", "opentelemetry 0.32.0", "opentelemetry-appender-tracing", "opentelemetry-otlp 0.32.0", "opentelemetry-stdout", - "opentelemetry_sdk 0.32.0", + "opentelemetry_sdk 0.32.1", "pastey", "pctx_code_mode", "pem", - "pkcs1", - "pkcs8", "process-wrap", "pulldown-cmark", - "rand 0.8.6", + "rand 0.10.2", "rayon", + "rcgen", "regex", "reqwest 0.13.4", "rmcp", "rubato", "rustls", "schemars 1.2.1", - "sec1", "serde", "serde_json", + "serde_path_to_error", "serde_urlencoded", "serde_yaml", "serial_test", @@ -4537,19 +5001,21 @@ dependencies = [ "shellexpand", "smithy-transport-reqwest", "sqlx", - "strum 0.27.2", + "strum 0.28.0", + "subtle", "symphonia", "sys-info", "tempfile", "test-case", - "thiserror 1.0.69", + "thiserror 2.0.18", "tiktoken-rs", - "tokenizers 0.21.4", + "tokenizers 0.23.1", "tokio", "tokio-cron-scheduler", "tokio-stream", "tokio-util", - "tower-http", + "tower", + "tower-http 0.7.0", "tracing", "tracing-appender", "tracing-futures", @@ -4572,28 +5038,28 @@ dependencies = [ "uuid", "v_htmlescape", "webbrowser", - "which 8.0.2", + "which 8.0.4", "winapi", "wiremock", - "zip 8.6.0", ] [[package]] name = "goose-acp-macros" -version = "1.37.0" +version = "1.42.0" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "goose-cli" -version = "1.37.0" +version = "1.42.0" dependencies = [ "anstream", "anyhow", "async-trait", "axum", + "axum-server", "base64 0.22.1", "bat", "bzip2", @@ -4611,9 +5077,10 @@ dependencies = [ "futures", "goose", "goose-mcp", + "goose-providers", "indicatif", "open", - "rand 0.8.6", + "rand 0.10.2", "regex", "reqwest 0.13.4", "rmcp", @@ -4622,9 +5089,9 @@ dependencies = [ "serde_json", "serde_yaml", "sha2 0.11.0", - "shlex", + "shlex 2.0.1", "sigstore-verify", - "strum 0.27.2", + "strum 0.28.0", "tar", "tempfile", "test-case", @@ -4639,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.37.0" +version = "1.42.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -4669,69 +5185,100 @@ dependencies = [ "url", ] +[[package]] +name = "goose-provider-types" +version = "0.1.0-alpha.0" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "base64 0.22.1", + "chrono", + "env-lock", + "futures", + "once_cell", + "rand 0.10.2", + "regex", + "reqwest 0.13.4", + "rmcp", + "serde", + "serde_json", + "strum 0.28.0", + "tempfile", + "test-case", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "unicode-normalization", + "utoipa 4.2.3", + "uuid", +] + +[[package]] +name = "goose-providers" +version = "0.1.0-alpha.0" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "chrono", + "env-lock", + "futures", + "goose-local-inference", + "goose-provider-types", + "include_dir", + "pem", + "pkcs1", + "pkcs8", + "reqwest 0.13.4", + "rmcp", + "sec1", + "serde", + "serde_json", + "tempfile", + "test-case", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "urlencoding", + "utoipa 4.2.3", + "wiremock", +] + [[package]] name = "goose-sdk" -version = "1.37.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", + "uniffi", +] + +[[package]] +name = "goose-sdk-types" +version = "0.1.0-alpha.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", "schemars 1.2.1", "serde", "serde_json", - "tokio", - "tokio-util", -] - -[[package]] -name = "goose-server" -version = "1.37.0" -dependencies = [ - "anyhow", - "aws-lc-rs", - "axum", - "axum-server", - "base64 0.22.1", - "bytes", - "chrono", - "clap", - "config", - "fs2", - "futures", - "goose", - "goose-mcp", - "hex", - "http 1.4.1", - "openssl", - "pem", - "rand 0.8.6", - "rcgen", - "reqwest 0.13.4", - "rmcp", - "rustls", - "serde", - "serde_json", - "serde_path_to_error", - "serde_yaml", - "socket2", - "subtle", - "thiserror 1.0.69", - "tokio", - "tokio-stream", - "tokio-tungstenite 0.29.0", - "tokio-util", - "tower", - "tower-http", - "tracing", - "tracing-subscriber", - "url", - "utoipa 4.2.3", - "uuid", - "winreg", ] [[package]] name = "goose-test" -version = "1.37.0" +version = "1.42.0" dependencies = [ "clap", "serde_json", @@ -4739,7 +5286,7 @@ dependencies = [ [[package]] name = "goose-test-support" -version = "1.37.0" +version = "1.42.0" dependencies = [ "axum", "env-lock", @@ -4771,16 +5318,16 @@ 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", "fnv", "futures-core", "futures-sink", - "http 1.4.1", + "http 1.4.2", "indexmap 2.14.0", "slab", "tokio", @@ -4795,7 +5342,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "bytemuck", - "cfg-if", + "cfg-if 1.0.4", "crunchy", "num-traits", "rand 0.9.4", @@ -4805,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", @@ -4874,6 +5421,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "heapify" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e" + [[package]] name = "heck" version = "0.5.0" @@ -4901,6 +5454,63 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hex-conservative" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hf-hub" +version = "1.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f89305dc8fe34e165eaf0eb12b6e294e12381d9df9a431bcc52a5809bab4319" +dependencies = [ + "base64 0.22.1", + "bon", + "bytes", + "futures", + "globset", + "hf-xet", + "hyper", + "pathdiff", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "tokio-retry", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "hf-xet" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "430b33fa84f92796d4d263070b6c0d3ca219df7b9a0e1853ee431029b1612bcd" +dependencies = [ + "async-trait", + "bytes", + "http 1.4.2", + "more-asserts", + "serde", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", + "xet-client", + "xet-core-structures", + "xet-data", + "xet-runtime", +] + [[package]] name = "hipstr" version = "0.6.0" @@ -4950,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", ] @@ -4990,9 +5600,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -5016,7 +5626,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.1", + "http 1.4.2", ] [[package]] @@ -5027,7 +5637,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "pin-project-lite", ] @@ -5045,26 +5655,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "hybrid-array" -version = "0.4.12" +name = "humantime" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +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", "futures-channel", "futures-core", "h2", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "httparse", "httpdate", @@ -5081,14 +5697,14 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.1", + "http 1.4.2", "hyper", "hyper-util", "rustls", "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] @@ -5130,7 +5746,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "hyper", "ipnet", @@ -5199,7 +5815,7 @@ checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", - "yoke 0.8.2", + "yoke 0.8.3", "zerofrom", "zerovec", ] @@ -5290,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" @@ -5337,9 +5947,9 @@ checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" [[package]] name = "ignore" -version = "0.4.25" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +checksum = "fe112b004901c62c2faa11f4f75e9864e0cc5af8da71c9115d184a3aa888749f" dependencies = [ "crossbeam-deque", "globset", @@ -5453,9 +6063,9 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.18.4" +version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ "console", "portable-atomic", @@ -5485,9 +6095,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.47.2" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ "once_cell", "similar", @@ -5500,7 +6110,7 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "wasm-bindgen", "web-sys", @@ -5530,7 +6140,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5581,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", @@ -5596,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" @@ -5626,7 +6237,7 @@ version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "combine", "jni-macros", "jni-sys", @@ -5647,7 +6258,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5666,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", ] @@ -5690,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", + "cfg-if 1.0.4", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -5709,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" @@ -5787,6 +6397,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "konst" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f660d5f887e3562f9ab6f4a14988795b694099d66b4f5dedc02d197ba9becb1d" +dependencies = [ + "const_panic", + "konst_proc_macros", + "typewit", +] + +[[package]] +name = "konst_proc_macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" + [[package]] name = "lazy-regex" version = "3.6.0" @@ -5807,7 +6434,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5819,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" @@ -5859,7 +6480,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "windows-link", ] @@ -5869,7 +6490,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "windows-link", ] @@ -5881,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]] @@ -5904,9 +6525,9 @@ dependencies = [ [[package]] name = "linktime-proc-macro" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44cd706ff0d503ee32b2071166510ca27e281228de10cd3aa8d35ff94560f81" +checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" [[package]] name = "linux-keyutils" @@ -5914,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", ] @@ -5981,30 +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.40.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fdcbab5b237a03984f83b1394dc534e0b1960675c7f3ec4d04dccc9032b56d" +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", - "nom_locate", - "rand 0.10.1", + "rand 0.10.2", "rangemap", "sha2 0.10.9", "stringprep", @@ -6019,12 +6639,36 @@ version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lz4_flex" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "mach-sys" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48460c2e82a3a0de197152fdf8d2c2d5e43adc501501553e439bf2156e6f87c7" +dependencies = [ + "fastrand", +] + [[package]] name = "macro_rules_attribute" version = "0.2.2" @@ -6071,21 +6715,21 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "digest 0.10.7", ] [[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", @@ -6112,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", @@ -6121,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" @@ -6139,14 +6789,24 @@ 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", ] +[[package]] +name = "minijinja-contrib" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85342f6fac0be8ccd5bd00d9066be538f34f393f577b75d81b17c8398a6b43bb" +dependencies = [ + "minijinja", + "serde", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -6165,22 +6825,22 @@ 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", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] [[package]] name = "mockall" -version = "0.13.1" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +checksum = "1a6ceddfe3ce334925e96bf420fdb2dcee5bed6c632a168ece622676dadeaf8a" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "downcast", "fragile", "mockall_derive", @@ -6190,14 +6850,14 @@ dependencies = [ [[package]] name = "mockall_derive" -version = "0.13.1" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +checksum = "9cfe16fbe8a314aeec0b861ac24e60b1e123e97634bab045475b9d6a18416fd8" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6225,9 +6885,15 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] +[[package]] +name = "more-asserts" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fafa6961cabd9c63bcd77a45d7e3b7f3b552b70417831fb0f56db717e72407e" + [[package]] name = "moxcms" version = "0.8.1" @@ -6297,8 +6963,8 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.1", - "cfg-if", + "bitflags 2.13.0", + "cfg-if 1.0.4", "cfg_aliases", "libc", ] @@ -6313,7 +6979,7 @@ dependencies = [ "async-trait", "boxed_error", "capacity_builder", - "dashmap 5.5.3", + "dashmap", "deno_error", "deno_maybe_sync", "deno_media_type", @@ -6353,22 +7019,11 @@ dependencies = [ "memchr", ] -[[package]] -name = "nom_locate" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" -dependencies = [ - "bytecount", - "memchr", - "nom 8.0.0", -] - [[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", @@ -6394,7 +7049,7 @@ version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" dependencies = [ - "lru", + "lru 0.16.4", "nostr", "tokio", ] @@ -6418,7 +7073,7 @@ dependencies = [ "async-wsocket", "atomic-destructor", "hex", - "lru", + "lru 0.16.4", "negentropy", "nostr", "nostr-database", @@ -6441,6 +7096,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -6466,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", @@ -6522,7 +7186,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6536,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" @@ -6591,6 +7254,28 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "oauth2" version = "5.0.0" @@ -6600,7 +7285,7 @@ dependencies = [ "base64 0.22.1", "chrono", "getrandom 0.2.17", - "http 1.4.1", + "http 1.4.2", "rand 0.8.6", "reqwest 0.12.28", "serde", @@ -6635,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", @@ -6647,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", ] @@ -6658,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", @@ -6677,20 +7362,30 @@ 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", "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", ] @@ -6701,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", @@ -6709,6 +7404,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.37.3" @@ -6739,13 +7443,19 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oneshot" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" + [[package]] name = "onig" version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "libc", "once_cell", "onig_sys", @@ -6769,23 +7479,22 @@ 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", - "cfg-if", + "bitflags 2.13.0", + "cfg-if 1.0.4", "foreign-types 0.3.2", "libc", "openssl-macros", @@ -6800,7 +7509,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6811,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", @@ -6879,7 +7588,7 @@ checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" dependencies = [ "async-trait", "bytes", - "http 1.4.1", + "http 1.4.2", "opentelemetry 0.31.0", "reqwest 0.12.28", ] @@ -6892,7 +7601,7 @@ checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" dependencies = [ "async-trait", "bytes", - "http 1.4.1", + "http 1.4.2", "opentelemetry 0.32.0", "reqwest 0.13.4", ] @@ -6903,7 +7612,7 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f" dependencies = [ - "http 1.4.1", + "http 1.4.2", "opentelemetry 0.31.0", "opentelemetry-http 0.31.0", "opentelemetry-proto 0.31.0", @@ -6922,11 +7631,11 @@ version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" dependencies = [ - "http 1.4.1", + "http 1.4.2", "opentelemetry 0.32.0", "opentelemetry-http 0.32.0", "opentelemetry-proto 0.32.0", - "opentelemetry_sdk 0.32.0", + "opentelemetry_sdk 0.32.1", "prost", "reqwest 0.13.4", "thiserror 2.0.18", @@ -6952,7 +7661,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" dependencies = [ "opentelemetry 0.32.0", - "opentelemetry_sdk 0.32.0", + "opentelemetry_sdk 0.32.1", "prost", ] @@ -6964,7 +7673,7 @@ checksum = "a1b1c6a247d79091f0062a5f4bd058589525cf987a8d4c169440d9c1be72f0ad" dependencies = [ "chrono", "opentelemetry 0.32.0", - "opentelemetry_sdk 0.32.0", + "opentelemetry_sdk 0.32.1", ] [[package]] @@ -6984,9 +7693,9 @@ dependencies = [ [[package]] name = "opentelemetry_sdk" -version = "0.32.0" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368afaed344110f40b179bb8fbe54bc52d98f9bd2b281799ef32487c2650c956" +checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" dependencies = [ "futures-channel", "futures-executor", @@ -7005,6 +7714,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +dependencies = [ + "memchr", +] + [[package]] name = "outref" version = "0.5.2" @@ -7066,7 +7784,7 @@ version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "redox_syscall 0.5.18", "smallvec", @@ -7198,7 +7916,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "camino", - "http 1.4.1", + "http 1.4.2", "indexmap 2.14.0", "keyring", "opentelemetry-otlp 0.31.1", @@ -7207,7 +7925,7 @@ dependencies = [ "rmcp", "serde", "serde_json", - "shlex", + "shlex 1.3.0", "thiserror 2.0.18", "tokio", "tonic", @@ -7307,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", @@ -7317,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", @@ -7327,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]] @@ -7387,7 +8104,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7425,7 +8142,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7440,6 +8157,17 @@ 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" @@ -7475,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", ] @@ -7505,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" @@ -7607,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]] @@ -7619,6 +8361,15 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -7668,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", @@ -7678,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]] @@ -7721,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", ] @@ -7733,7 +8484,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96b86df24f0a7ddd5e4b95c94fc9ed8a98f1ca94d3b01bdce2824097e7835907" dependencies = [ "bytemuck", - "cfg-if", + "cfg-if 1.0.4", "libm", "num-complex", "reborrow", @@ -7742,12 +8493,12 @@ 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", + "cfg-if 1.0.4", "libm", "num-complex", "paste", @@ -7759,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" @@ -7797,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", @@ -7826,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", @@ -7848,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", ] @@ -7920,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", ] @@ -7983,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" @@ -7995,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]] @@ -8037,6 +8798,7 @@ checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" dependencies = [ "aws-lc-rs", "pem", + "ring", "rustls-pki-types", "time", "x509-parser", @@ -8049,33 +8811,31 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" +[[package]] +name = "redb" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba239c1c1693315d3cc0e601db3b3965543afbf48c41730fdca2f069f510f4a" +dependencies = [ + "libc", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.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", -] - -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", + "bitflags 2.13.0", ] [[package]] @@ -8106,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", @@ -8125,9 +8888,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -8154,9 +8917,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -8169,7 +8932,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper", @@ -8192,13 +8955,13 @@ dependencies = [ "tokio-native-tls", "tokio-rustls", "tower", - "tower-http", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] @@ -8216,7 +8979,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper", @@ -8243,7 +9006,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", - "tower-http", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", @@ -8252,6 +9015,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "reqwest-middleware" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" +dependencies = [ + "anyhow", + "async-trait", + "http 1.4.2", + "reqwest 0.13.4", + "thiserror 2.0.18", + "tower-service", +] + [[package]] name = "resb" version = "0.1.2" @@ -8288,7 +9065,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.4", "getrandom 0.2.17", "libc", "untrusted 0.9.0", @@ -8297,16 +9074,16 @@ 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", "bytes", "chrono", "futures", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper", @@ -8315,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", @@ -8334,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]] @@ -8395,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" @@ -8423,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", @@ -8436,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", @@ -8445,9 +9222,9 @@ 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", "once_cell", @@ -8460,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", @@ -8472,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", @@ -8521,18 +9298,18 @@ 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", - "cfg-if", + "bitflags 2.13.0", + "cfg-if 1.0.4", "clipboard-win", "home", "libc", @@ -8558,6 +9335,105 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" +[[package]] +name = "safe-transmute" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944826ff8fa8093089aba3acb4ef44b9446a99a16f3bf4e74af3f77d340ab7d" + +[[package]] +name = "safemlx" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f501c4f9748dfe9ccc905e7e13ae88873d96a00735268c69258e158da07a777f" +dependencies = [ + "bytemuck", + "dyn-clone", + "half", + "itertools 0.14.0", + "libc", + "mach-sys", + "num-complex", + "num-traits", + "num_enum", + "parking_lot", + "paste", + "safemlx-internal-macros", + "safemlx-macros", + "safemlx-sys", + "safetensors 0.6.2", + "smallvec", + "strum 0.27.2", + "thiserror 2.0.18", +] + +[[package]] +name = "safemlx-internal-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9708b1312176963a4ea6412859e6655e56a83fe6ab936142715aa0e9fd662fe5" +dependencies = [ + "darling 0.21.3", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "safemlx-lm" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f450654386009003b745adfdae7af2c7f44ae749d75621a2a0b07607945e9f" +dependencies = [ + "anyhow", + "clap", + "idna_adapter", + "minijinja", + "safemlx", + "safemlx-lm-utils", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokenizers 0.22.2", +] + +[[package]] +name = "safemlx-lm-utils" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583d39124dae0b4a93ac7528d16d543f5e717c21365e6b034a8a74d8e1b4eb3a" +dependencies = [ + "minijinja", + "minijinja-contrib", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokenizers 0.22.2", +] + +[[package]] +name = "safemlx-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d549c5fdbe69b904ef84c5cf352add203de1093b3c636e8133d94c24cfc33ce0" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "safemlx-sys" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "552285b007dab687890f5c49382e52e69a3bb1359bb2c8f2c2743743509af071" +dependencies = [ + "cc", + "cmake", +] + [[package]] name = "safetensors" version = "0.4.5" @@ -8570,15 +9446,27 @@ dependencies = [ [[package]] name = "safetensors" -version = "0.7.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5" +checksum = "172dd94c5a87b5c79f945c863da53b2ebc7ccef4eca24ac63cca66a41aab2178" dependencies = [ - "hashbrown 0.16.1", "serde", "serde_json", ] +[[package]] +name = "safetensors" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b079b829cb27a1c3c374341345ed2e8b2c0c839034522cee576c140bd7f846" +dependencies = [ + "hashbrown 0.16.1", + "libc", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "salsa20" version = "0.10.2" @@ -8597,15 +9485,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - [[package]] name = "schannel" version = "0.1.29" @@ -8663,7 +9542,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8675,7 +9554,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8690,6 +9569,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "scrypt" version = "0.11.0" @@ -8702,12 +9601,6 @@ dependencies = [ "sha2 0.10.9", ] -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - [[package]] name = "sec1" version = "0.7.3" @@ -8748,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", @@ -8761,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", @@ -8783,6 +9676,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "seq-macro" @@ -8827,7 +9724,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8838,7 +9735,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8886,6 +9783,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -8923,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", @@ -8943,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]] @@ -8968,25 +9876,24 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" dependencies = [ "once_cell", "parking_lot", - "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8995,7 +9902,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -9006,9 +9913,10 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.2.17", "digest 0.10.7", + "sha2-asm", ] [[package]] @@ -9017,11 +9925,20 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.3.0", "digest 0.11.3", ] +[[package]] +name = "sha2-asm" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b845214d6175804686b2bd482bcffe96651bb2d1200742b712003504a2dac1ab" +dependencies = [ + "cc", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -9043,7 +9960,9 @@ version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" dependencies = [ - "dirs 6.0.0", + "bstr", + "dirs", + "os_str_bytes", ] [[package]] @@ -9052,6 +9971,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -9074,16 +9999,15 @@ dependencies = [ [[package]] name = "sigstore-bundle" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a86d28a25d9e6107e02ca60fc60dfeb7cdc2b98251950d6fcd055f2afa31acb" +checksum = "18b51b097b7e9d898efad51e1031c855ce4008b4866d0c38ac8b0fac5745a84c" dependencies = [ "base64 0.22.1", "hex", "serde", "serde_json", "sigstore-crypto", - "sigstore-merkle", "sigstore-rekor", "sigstore-tsa", "sigstore-types", @@ -9092,9 +10016,9 @@ dependencies = [ [[package]] name = "sigstore-crypto" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdc66f1480e176533425cc880bc5f8a8e0d88ae1fe2701e98de4fb68984b71c" +checksum = "1210ea5dd0dd07b1466449a6c5c79000f8ca10ac30b4e8f64031c3bed4c5c662" dependencies = [ "aws-lc-rs", "base64 0.22.1", @@ -9114,9 +10038,9 @@ dependencies = [ [[package]] name = "sigstore-merkle" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4dbea5d8d5f293dfc2ed6abd06dc26a5ef017af126e53e3e36a6b2f7b7dac8c" +checksum = "9ae98f8a270885497dbe84e6dae10fa29066df0388f62980923728e38d844716" dependencies = [ "base64 0.22.1", "hex", @@ -9127,9 +10051,9 @@ dependencies = [ [[package]] name = "sigstore-rekor" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbe0bf948de89bed9b3b5c9d62940264a4da60db3518006b952b107d0e71926f" +checksum = "4e1c153f4f89f7570d0f625f660cadcb1b2fba72583f378d2d2b308c752ac45f" dependencies = [ "base64 0.22.1", "hex", @@ -9145,9 +10069,9 @@ dependencies = [ [[package]] name = "sigstore-trust-root" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54fdf65b1207f96d4b1e5b44028fe3e078d40a50f6316715f6ad97ae21b0bfeb" +checksum = "ded6afc37295a10dba6569e033dd15f85f04f00980bfcf2c64009bc8ed279985" dependencies = [ "base64 0.22.1", "hex", @@ -9156,6 +10080,7 @@ dependencies = [ "serde", "serde_json", "sigstore-crypto", + "sigstore-tuf", "sigstore-types", "thiserror 2.0.18", "x509-cert", @@ -9163,9 +10088,9 @@ dependencies = [ [[package]] name = "sigstore-tsa" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "860eb6a645e0a22fa316031b06e9e87f9310c21915743bfa83901472dd3e7146" +checksum = "a88505b71600a791e52bed3777ed9cfeefd4b03dee6575d38c5542ae80e2bbdc" dependencies = [ "aws-lc-rs", "base64 0.22.1", @@ -9188,10 +10113,32 @@ dependencies = [ ] [[package]] -name = "sigstore-types" -version = "0.8.0" +name = "sigstore-tuf" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fb0334b69834c1f29757e57ade7e9bdbc42f5383cc22d003256d4d2df47af13" +checksum = "dfc7cced094bd306ef561312cfffa3ea05ac7eea4a62cfa81680c07a10addbc0" +dependencies = [ + "globset", + "hex", + "jiff", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.10.9", + "sigstore-crypto", + "sigstore-types", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "sigstore-types" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f034e86c3b8d91b32608c83f4a18939eed32c6ca08d17d2f52b88be365aac6" dependencies = [ "base64 0.22.1", "hex", @@ -9203,9 +10150,9 @@ dependencies = [ [[package]] name = "sigstore-verify" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c23f74b41da93e92dc1de793d5450b662fce5c8cea887daa46002a793d78bca9" +checksum = "f4720f300d093ecac6dcd75d4498fd1b2d9133600e679837bb9d944a4229eda7" dependencies = [ "base64 0.22.1", "cms", @@ -9295,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", ] @@ -9315,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" @@ -9327,16 +10274,16 @@ checksum = "566dc85be03a09c384f77a122188d9af000e1f1bd23551b346a9b555838da7e1" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", - "http 1.4.1", + "http 1.4.2", "parking_lot", "reqwest 0.13.4", ] [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -9353,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", @@ -9458,7 +10405,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9481,7 +10428,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.117", + "syn 2.0.118", "tokio", "url", ] @@ -9494,7 +10441,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.11.1", + "bitflags 2.13.0", "byteorder", "bytes", "chrono", @@ -9526,7 +10473,7 @@ dependencies = [ "stringprep", "thiserror 2.0.18", "tracing", - "whoami", + "whoami 1.6.1", ] [[package]] @@ -9537,7 +10484,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.11.1", + "bitflags 2.13.0", "byteorder", "chrono", "crc", @@ -9564,7 +10511,7 @@ dependencies = [ "stringprep", "thiserror 2.0.18", "tracing", - "whoami", + "whoami 1.6.1", ] [[package]] @@ -9594,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", @@ -9618,7 +10565,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.4", "libc", "psm", "windows-sys 0.61.2", @@ -9630,6 +10577,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "statrs" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" +dependencies = [ + "approx", + "num-traits", +] + [[package]] name = "std_prelude" version = "0.2.12" @@ -9659,7 +10616,7 @@ checksum = "ae36a4951ca7bd1cfd991c241584a9824a70f6aff1e7d4f693fb3f2465e4030e" dependencies = [ "quote", "swc_macros_common", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9712,7 +10669,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9724,7 +10681,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9742,7 +10699,7 @@ dependencies = [ "allocator-api2", "bumpalo", "hashbrown 0.14.5", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", ] [[package]] @@ -9771,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", @@ -9806,7 +10763,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9815,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", @@ -9841,7 +10798,7 @@ dependencies = [ "num-bigint", "once_cell", "regex", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "ryu-js", "serde", "swc_allocator", @@ -9861,7 +10818,7 @@ checksum = "e276dc62c0a2625a560397827989c82a93fd545fcf6f7faec0935a82cc4ddbb8" dependencies = [ "proc-macro2", "swc_macros_common", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9870,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", @@ -9894,7 +10851,7 @@ checksum = "fbcababb48f0d46587a0a854b2c577eb3a56fa99687de558338021e93cd2c8f5" dependencies = [ "anyhow", "pathdiff", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "serde", "swc_atoms", "swc_common", @@ -9907,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", @@ -9933,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", @@ -9966,7 +10923,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9976,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", @@ -9997,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", @@ -10018,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", @@ -10039,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", @@ -10071,7 +11028,7 @@ checksum = "c16ce73424a6316e95e09065ba6a207eba7765496fed113702278b7711d4b632" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -10082,7 +11039,7 @@ checksum = "aae1efbaa74943dc5ad2a2fb16cbd78b77d7e4d63188f3c5b4df2b4dcd2faaae" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -10097,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", @@ -10290,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", @@ -10307,7 +11264,7 @@ checksum = "54b8f0a9004d6aafa6a588602a1119e6cdaacec9921aa1605383e6e7d6258fd6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -10327,7 +11284,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -10375,7 +11332,7 @@ checksum = "181f22127402abcf8ee5c83ccd5b408933fec36a6095cf82cda545634692657e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -10384,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", @@ -10392,13 +11349,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.62.2", +] + [[package]] name = "system-configuration" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -10436,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", @@ -10490,7 +11461,7 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a46bb5364467da040298c573c8a95dbf9a512efc039630409a03126e3703e90" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "memchr", "mio", @@ -10505,7 +11476,7 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3f27d9a8a177e57545481faec87acb45c6e854ed1e5a3658ad186c106f38ed" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "windows-sys 0.61.2", ] @@ -10531,10 +11502,10 @@ version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -10545,7 +11516,7 @@ checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "test-case-core", ] @@ -10569,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" @@ -10601,7 +11566,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -10612,7 +11577,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -10627,7 +11592,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -10657,9 +11622,9 @@ dependencies = [ [[package]] name = "tiktoken-rs" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4a168cfc1d8ed65bf17a6ee0843ad9a68f863c63c0fb2fa7eab67838782ee" +checksum = "027853bbf8c7763b77c5c595f1c271c7d536ced7d6f83452911b944621e57fc2" dependencies = [ "anyhow", "base64 0.22.1", @@ -10667,17 +11632,16 @@ dependencies = [ "fancy-regex 0.17.0", "lazy_static", "regex", - "rustc-hash 1.1.0", + "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", @@ -10687,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", @@ -10757,18 +11721,18 @@ checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "tokenizers" -version = "0.21.4" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a620b996116a59e184c2fa2dfd8251ea34a36d0a514758c6f966386bd2e03476" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" dependencies = [ "ahash", "aho-corasick", - "compact_str 0.9.0", + "compact_str 0.9.1", "dary_heap", "derive_builder", "esaxx-rs", @@ -10795,13 +11759,13 @@ dependencies = [ [[package]] name = "tokenizers" -version = "0.22.2" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +checksum = "44e5bea67576e04b6ff8564c5d9e09c2ef0cf476502245f2f120e497769d3112" dependencies = [ "ahash", - "aho-corasick", - "compact_str 0.9.0", + "compact_str 0.9.1", + "daachorse", "dary_heap", "derive_builder", "esaxx-rs", @@ -10867,7 +11831,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -10891,6 +11855,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-retry" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a129d95275ebf4c493ec53bf0f8cd95f5ac161bc4f381700809a54f595d4470" +dependencies = [ + "pin-project-lite", + "rand 0.10.2", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -10903,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", @@ -10948,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", ] @@ -10994,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", ] @@ -11019,6 +11990,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -11045,7 +12028,7 @@ dependencies = [ "base64 0.22.1", "bytes", "h2", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper", @@ -11100,11 +12083,11 @@ 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", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "pin-project-lite", @@ -11116,6 +12099,21 @@ dependencies = [ "url", ] +[[package]] +name = "tower-http" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "http 1.4.2", + "percent-encoding", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -11161,7 +12159,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -11186,6 +12184,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + [[package]] name = "tracing-opentelemetry" version = "0.33.0" @@ -11224,18 +12233,20 @@ dependencies = [ "serde", "serde_json", "sharded-slab", + "smallvec", "thread_local", "time", "tracing", "tracing-core", + "tracing-log", "tracing-serde", ] [[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", @@ -11323,9 +12334,9 @@ dependencies = [ [[package]] name = "tree-sitter-swift" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3b98fb6bc8e6a6a10023f401aa6a1858115e849dfaf7de57dd8b8ea0f257bd9" +checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c" dependencies = [ "cc", "tree-sitter-language", @@ -11343,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", @@ -11371,7 +12382,7 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http 1.4.1", + "http 1.4.2", "httparse", "log", "rand 0.9.4", @@ -11390,13 +12401,10 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http 1.4.1", + "http 1.4.2", "httparse", "log", - "native-tls", "rand 0.9.4", - "rustls", - "rustls-pki-types", "sha1", "thiserror 2.0.18", ] @@ -11415,9 +12423,15 @@ 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" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" [[package]] name = "ucd-trie" @@ -11475,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", @@ -11488,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]] @@ -11520,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" @@ -11564,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" @@ -11592,6 +12613,127 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "uniffi" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a782a48d72cfd7a2d65cfc7c691dbf5375c43104b3c195f7eccc716dcc3540c8" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533b0312c73e3b54eb78a4b257ceae390962dd4767995778309a74644643f9ac" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap 2.14.0", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml 1.1.2+spec-1.1.0", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_core" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e32e261c5b0dfaba6488f536e71957dddd6b1a498ac7eb791bee56b60a086be" +dependencies = [ + "anyhow", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84ae78069a5e6772ef694fd5bdb628532c88d2c2f0e7142bf6a384636eadb1af" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "uniffi_macros" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330be6770532e86320df31f54c70bb0be67588594e8e77fa56e9083a3fed5d0d" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.118", + "toml 1.1.2+spec-1.1.0", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78de021f5547e56ab16c665a49d67d4fd3d31e77422f7739a2e9359d328cd9e7" +dependencies = [ + "anyhow", + "siphasher 1.0.3", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f8201bb1907ed8a42d80e11cbc25c8a033e7a31c3cff1d911f56eedb81d4948" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e57996bc58009cc29bf04845d627ae313c2547b87171c1c349d6c51a1656c0" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + [[package]] name = "unit-prefix" version = "0.5.2" @@ -11696,8 +12838,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "regex", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -11708,16 +12849,16 @@ checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -11729,7 +12870,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" dependencies = [ "outref", - "uuid", "vsimd", ] @@ -11747,7 +12887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f84468f393984251db025e944544590a8fb429d5088b78102bd72f09232f0da" dependencies = [ "bindgen", - "bitflags 2.11.1", + "bitflags 2.13.0", "fslock", "gzip-header", "home", @@ -11822,21 +12962,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" +name = "wasi" +version = "0.14.7+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" dependencies = [ - "wit-bindgen 0.57.1", + "wasip2", ] [[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +name = "wasip2" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -11846,12 +12986,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] -name = "wasm-bindgen" -version = "0.2.122" +name = "wasite" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" dependencies = [ - "cfg-if", + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if 1.0.4", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -11860,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", @@ -11870,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", @@ -11880,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" @@ -11945,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", @@ -11995,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", ] @@ -12008,18 +13123,27 @@ 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", ] +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "weezl" version = "0.1.12" @@ -12052,9 +13176,9 @@ dependencies = [ [[package]] name = "which" -version = "8.0.2" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +checksum = "48d7cd18d4acb58fb3cdfe9ea54e6cd96a4e7d4cc45c56338b236e82dad47248" dependencies = [ "libc", ] @@ -12066,7 +13190,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ "libredox", - "wasite", + "wasite 0.1.0", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite 1.0.2", + "web-sys", ] [[package]] @@ -12175,7 +13312,7 @@ checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -12186,7 +13323,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -12197,7 +13334,7 @@ checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -12208,7 +13345,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -12520,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", - "windows-sys 0.61.2", -] - [[package]] name = "winsafe" version = "0.0.19" @@ -12546,7 +13673,7 @@ dependencies = [ "base64 0.22.1", "deadpool", "futures", - "http 1.4.1", + "http 1.4.2", "http-body-util", "hyper", "hyper-util", @@ -12559,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" @@ -12712,6 +13751,7 @@ dependencies = [ "lazy_static", "nom 7.1.3", "oid-registry", + "ring", "rusticata-macros", "thiserror 2.0.18", "time", @@ -12728,6 +13768,153 @@ dependencies = [ "der", ] +[[package]] +name = "xet-client" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1e496dcbe6a09017acdfaf48e1a646735e7ff5b2a49e2c7e081cca77a59bc8" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "bytes", + "clap", + "crc32fast", + "futures", + "http 1.4.2", + "hyper", + "lazy_static", + "more-asserts", + "rand 0.10.2", + "redb", + "reqwest 0.13.4", + "reqwest-middleware", + "serde", + "serde_json", + "serde_repr", + "statrs", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-retry", + "tracing", + "tracing-subscriber", + "url", + "urlencoding", + "web-time", + "xet-core-structures", + "xet-runtime", +] + +[[package]] +name = "xet-core-structures" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb838aa8eb67d730af301584cf003caad407487606058292a6750711b603fbee" +dependencies = [ + "async-trait", + "base64 0.22.1", + "blake3", + "bytemuck", + "bytes", + "clap", + "countio", + "csv", + "futures", + "futures-util", + "getrandom 0.4.3", + "heapify", + "itertools 0.14.0", + "lazy_static", + "lz4_flex", + "more-asserts", + "rand 0.10.2", + "regex", + "safe-transmute", + "serde", + "static_assertions", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", + "web-time", + "xet-runtime", +] + +[[package]] +name = "xet-data" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67fd409bef621411a9d9013798540bb8036cb2678f03ab39af89a5e88034ed8c" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "clap", + "gearhash", + "http 1.4.2", + "itertools 0.14.0", + "lazy_static", + "more-asserts", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "url", + "uuid", + "walkdir", + "xet-client", + "xet-core-structures", + "xet-runtime", +] + +[[package]] +name = "xet-runtime" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15d8f121c33866f7648b737abe70d0e2dd9c0af4ffdd7219207531d0283aa63d" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "colored", + "const-str", + "ctor 0.6.3", + "dirs", + "futures", + "git-version", + "humantime", + "konst", + "lazy_static", + "libc", + "more-asserts", + "oneshot", + "pin-project", + "rand 0.10.2", + "reqwest 0.13.4", + "serde", + "serde_json", + "shellexpand", + "sysinfo", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "tracing-appender", + "tracing-subscriber", + "whoami 2.1.2", + "winapi", +] + [[package]] name = "xmlparser" version = "0.13.6" @@ -12770,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", @@ -12787,7 +13974,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -12799,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]] @@ -12840,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]] @@ -12871,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", ] @@ -12883,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", ] @@ -12896,7 +14083,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -12911,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" @@ -12956,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 a2434589a8..3e25ab5025 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,8 +8,8 @@ resolver = "2" [workspace.package] edition = "2021" -version = "1.37.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,11 +31,11 @@ async-trait = { version = "0.1.89", default-features = false } axum = { version = "0.8", default-features = false, features = ["http1", "http2", "json", "tokio", "query"] } base64 = { version = "0.22.1", default-features = false, features = ["std"] } bytes = { version = "1.10.1", default-features = false, features = ["std"] } -candle-core = { version = "0.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 = "5", default-features = false } +dirs = { version = "6", default-features = false } dotenvy = { version = "0.15.7", default-features = false } env-lock = { version = "1", default-features = false } etcetera = { version = "0.11", default-features = false } @@ -45,9 +46,9 @@ ignore = { version = "0.4.12", default-features = false } include_dir = { version = "0.7", default-features = false } indoc = { version = "2", default-features = false } keyring = { version = "3.6.3", default-features = false, features = ["vendored"] } -lru = { version = "0.16", default-features = false } +lru = { version = "0.18", default-features = false } once_cell = { version = "1.21.3", default-features = false, features = ["std"] } -rand = { version = "0.8.5", default-features = false, features = ["std"] } +rand = "0.10.1" regex = { version = "1.12.3", default-features = false, features = ["std"] } reqwest = { version = "0.13.2", default-features = false, features = ["multipart", "form"] } rustls = { version = "0.23.31", default-features = false, features = ["aws_lc_rs", "std"] } @@ -56,20 +57,20 @@ serde = { version = "1.0.228", default-features = false, features = ["derive", " serde_json = { version = "1.0.145", default-features = false, features = ["std"] } serde_yaml = { version = "0.9.32", default-features = false } shellexpand = { version = "3", default-features = false, features = ["base-0", "tilde"] } -strum = { version = "0.27.1", default-features = false, features = ["derive", "std"] } +strum = { version = "0.28.0", default-features = false, features = ["derive", "std"] } tempfile = { version = "3.10.1", default-features = false } -thiserror = { version = "1.0.49", default-features = false } +thiserror = { version = "2.0.18", default-features = false } tokio = { version = "1.48", default-features = false } tokio-stream = { version = "0.1.16", default-features = false } tokio-util = { version = "0.7.12", default-features = false } -tower-http = { version = "0.6.8", default-features = false } +tower-http = { version = "0.7.0", default-features = false } tracing = { version = "0.1.43", default-features = false, features = ["std"] } tracing-appender = { version = "0.2.1", default-features = false } tracing-futures = { version = "0.2.4", default-features = false, features = ["futures-03", "std", "std-future"] } tracing-subscriber = { version = "0.3.22", default-features = false, features = ["std"] } urlencoding = { version = "2.1", default-features = false } utoipa = { version = "4.2", default-features = false } -uuid = { version = "1.18", default-features = false, features = ["v4", "std"] } +uuid = { version = "1.23", default-features = false, features = ["v4", "std"] } webbrowser = { version = "1", default-features = false } which = { version = "8", default-features = false, features = ["real-sys"] } winapi = { version = "0.3.9", default-features = false, features = ["wincred", "std"] } @@ -113,3 +114,12 @@ icu_locale = { version = "=2.1.1", default-features = false } [patch.crates-io] v8 = { path = "vendor/v8" } cudaforge = { git = "https://github.com/jbg/cudaforge", rev = "e7c1967340e40673db98dc9e17da0f04834a456f" } + +# Dependencies don't need debug info. You rarely step-debug into third-party +# crates, but their DWARF dominates `target/debug` โ€” roughly 50-70% of each +# dependency rlib in this workspace is debug info. Dropping it for dependencies +# only shrinks the debug build substantially and speeds linking. The "*" spec +# matches all dependencies but NOT workspace members, so first-party crates keep +# full debug info and stay fully debuggable; release builds are unaffected. +[profile.dev.package."*"] +debug = false diff --git a/I18N.md b/I18N.md index 274ed4fd72..c07792efc8 100644 --- a/I18N.md +++ b/I18N.md @@ -100,11 +100,15 @@ Compiled files go to `src/i18n/compiled/` (gitignored). The locale is resolved at startup in the following order: -1. **`GOOSE_LOCALE`** โ€” explicit override (set on the `window` object or via env) -2. **`navigator.language`** โ€” the browser/OS locale -3. **`"en"`** โ€” fallback default +1. **Desktop language setting** โ€” selected in Settings, unless set to System Default +2. **`GOOSE_LOCALE`** โ€” explicit override from env/app config when the desktop setting is + System Default +3. **`navigator.language`** โ€” the browser/OS locale +4. **`"en"`** โ€” fallback default -The resolved locale is used for both text translations and all Intl formatting (dates, numbers, relative times). +The resolved locale is used for both text translations and all Intl formatting (dates, numbers, +relative times). Changing the desktop language setting reloads the renderer so startup-only locale +helpers pick up the new value. ## Date and number formatting @@ -134,7 +138,8 @@ This ensures date/number formatting uses the same locale as the rest of the UI. 1. Copy `src/i18n/messages/en.json` to a new file, e.g., `src/i18n/messages/ja.json`. 2. Translate the `defaultMessage` values. Keep ICU syntax intact (e.g., `{count, plural, ...}`). 3. Add the locale code to `SUPPORTED_LOCALES` in `src/i18n/index.ts`. -4. Optionally run `pnpm i18n:compile` to pre-compile. +4. Add the locale to the language selector in `src/components/settings/app/AppSettingsSection.tsx`. +5. Optionally run `pnpm i18n:compile` to pre-compile. No other code changes are needed โ€” `loadMessages()` dynamically imports the correct catalog at runtime. diff --git a/Justfile b/Justfile index 8e8f6e49f3..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,15 +39,10 @@ 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}}..."; \ - 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; \ cp -p ./target/{{BUILD_MODE}}/goose ./ui/desktop/src/bin/; \ else \ echo "goose CLI binary not found in target/{{BUILD_MODE}}"; \ @@ -60,15 +51,10 @@ 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..."; \ - 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; \ cp -p ./target/x86_64-apple-darwin/release/goose ./ui/desktop/src/bin/; \ else \ echo "Intel goose CLI binary not found."; \ @@ -83,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; \ @@ -112,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}" && \ @@ -157,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 @@ -314,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: @@ -329,14 +304,10 @@ prepare-release version: Cargo.lock \ ui/desktop/package.json \ ui/pnpm-lock.yaml \ - ui/desktop/openapi.json \ - crates/goose/src/providers/canonical/data/canonical_models.json \ - crates/goose/src/providers/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" @@ -371,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 @@ -400,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 7dadbd32c8..e47f98bf55 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -20,9 +20,10 @@ name = "generate_manpages" path = "src/bin/generate_manpages.rs" [dependencies] -clap_mangen = { version = "0.2", default-features = false } +clap_mangen = { version = "0.3", default-features = false } goose = { path = "../goose", default-features = false } goose-mcp = { path = "../goose-mcp", default-features = false } +goose-providers = { path = "../goose-providers", default-features = false } rmcp = { workspace = true } clap = { workspace = true } cliclack = { version = "0.5", default-features = false } @@ -43,7 +44,7 @@ rustyline = { version = "18", default-features = false, features = ["custom-bind tracing = { workspace = true } chrono = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json", "time"] } -shlex = { version = "1.3", default-features = false, features = ["std"] } +shlex = { version = "2.0", default-features = false, features = ["std"] } async-trait = { workspace = true } base64 = { workspace = true } regex = { workspace = true } @@ -61,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.8", 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] @@ -87,6 +89,7 @@ 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"] @@ -97,16 +100,22 @@ portable-default = ["rustls-tls", "aws-providers", "telemetry", "otel", "tui"] # disables the update command disable-update = [] rustls-tls = [ + "dep:axum-server", "reqwest/rustls", + "axum-server/tls-rustls", "sigstore-verify?/rustls", "goose/rustls-tls", "goose-mcp/rustls-tls", + "goose-providers/rustls-tls", ] native-tls = [ + "dep:axum-server", "reqwest/native-tls", + "axum-server/tls-openssl", "sigstore-verify?/native-tls", "goose/native-tls", "goose-mcp/native-tls", + "goose-providers/native-tls", ] [dev-dependencies] diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index aac8b77c3b..427dd80351 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -43,14 +43,30 @@ use tracing::warn; const GOOSE_SERVER_SECRET_KEY_ENV: &str = "GOOSE_SERVER__SECRET_KEY"; fn generate_serve_secret_key() -> String { - use rand::distributions::{Alphanumeric, DistString}; + use rand::distr::{Alphanumeric, SampleString}; format!( "goose-acp-{}", - Alphanumeric.sample_string(&mut rand::thread_rng(), 32) + Alphanumeric.sample_string(&mut rand::rng(), 32) ) } +#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)] +enum ServePlatform { + #[default] + Cli, + Desktop, +} + +impl From for GoosePlatform { + fn from(platform: ServePlatform) -> Self { + match platform { + ServePlatform::Cli => GoosePlatform::GooseCli, + ServePlatform::Desktop => GoosePlatform::GooseDesktop, + } + } +} + #[derive(Parser)] #[command(name = "goose", author, version, display_name = "", about, long_about = None)] pub struct Cli { @@ -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, @@ -360,6 +376,13 @@ pub struct RunBehavior { )] pub resume: bool, + /// Print generation statistics after completion + #[arg( + long = "stats", + help = "Print generation statistics after the run completes" + )] + pub stats: bool, + /// Scheduled job ID (used internally for scheduled executions) #[arg( long = "scheduled-job-id", @@ -385,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()) @@ -559,9 +584,13 @@ enum SessionCommand { )] relays: Vec, }, - #[command(about = "Import a session from JSON or an encrypted Nostr share link")] + #[command( + about = "Import a session from JSON, a Claude Code / Codex / Pi .jsonl, or an encrypted Nostr share link" + )] Import { - #[arg(help = "Path to a JSON session export, or a goose://sessions/nostr share link")] + #[arg( + help = "Path to a goose session export, a Claude Code, Codex, or Pi .jsonl transcript, or a goose://sessions/nostr share link" + )] input: String, #[arg(long = "nostr", help = "Treat input as an encrypted Nostr share link")] @@ -569,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, }, @@ -822,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", @@ -831,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 @@ -863,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, @@ -1058,8 +1120,9 @@ enum Command { #[arg(long = "override-model", value_name = "MODEL")] override_model: Option, - /// Default `turn-limit` applied to checks that do not declare their - /// own. + /// Default `turn-limit` for orchestrated main-pass subprocesses and + /// for checks that do not declare their own. Does not cap the legacy + /// `--no-orchestrate` in-process main agent. #[arg(long = "turn-limit", value_name = "N")] turn_limit: Option, @@ -1123,7 +1186,6 @@ enum Command { #[arg(long = "severity", value_name = "LEVEL", default_value = "medium")] severity: String, }, - #[command( name = "validate-extensions", about = "Validate a bundled-extensions.json file", @@ -1138,8 +1200,8 @@ enum Command { #[cfg(feature = "local-inference")] #[derive(Subcommand)] enum LocalModelsCommand { - /// Search HuggingFace for GGUF models - #[command(about = "Search HuggingFace for GGUF models")] + /// Search HuggingFace for local models + #[command(about = "Search HuggingFace for local GGUF and MLX models")] Search { /// Search query query: String, @@ -1150,9 +1212,9 @@ enum LocalModelsCommand { }, /// Download a model from HuggingFace - #[command(about = "Download a GGUF model (e.g. bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M)")] + #[command(about = "Download a local model from a search result")] Download { - /// Model spec in user/repo:quantization format + /// Model spec/download id, e.g. user/repo:Q4_K_M or user/repo spec: String, }, @@ -1315,13 +1377,39 @@ 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; use std::net::SocketAddr; use std::sync::Arc; - use tracing::info; + 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()] @@ -1345,25 +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, })); - let secret_key = std::env::var(GOOSE_SERVER_SECRET_KEY_ENV) + let env_secret = std::env::var(GOOSE_SERVER_SECRET_KEY_ENV) .ok() .map(|secret| secret.trim().to_string()) - .filter(|secret| !secret.is_empty()) - .unwrap_or_else(generate_serve_secret_key); - let router = create_router(server, secret_key); + .filter(|secret| !secret.is_empty()); + let require_token = env_secret.is_some(); + if !require_token && !dangerously_unauthenticated { + anyhow::bail!( + "{GOOSE_SERVER_SECRET_KEY_ENV} must be set to start `goose serve`; pass --dangerously-unauthenticated to run without ACP authentication" + ); + } + if dangerously_unauthenticated && !require_token { + warn!( + "{GOOSE_SERVER_SECRET_KEY_ENV} is not set and --dangerously-unauthenticated was passed; the ACP endpoint will accept unauthenticated connections" + ); + } + let additional_allowed_origins = allowed_origins + .into_iter() + .map(|origin| { + let origin = origin.trim(); + if origin.is_empty() || origin == "*" { + anyhow::bail!("--allowed-origin must be a non-wildcard Origin value"); + } + HeaderValue::from_str(origin).map_err(|error| { + anyhow::anyhow!("invalid --allowed-origin value `{origin}`: {error}") + }) + }) + .collect::>>()?; + let secret_key = env_secret.unwrap_or_else(generate_serve_secret_key); + let router = create_router( + server, + secret_key, + require_token, + additional_allowed_origins, + ); + + let config = Config::global(); + let tls_cert_path = + tls_cert_path.or_else(|| config.get_param::("GOOSE_TLS_CERT_PATH").ok()); + let tls_key_path = + tls_key_path.or_else(|| config.get_param::("GOOSE_TLS_KEY_PATH").ok()); + let tls = tls + || config.get_param::("GOOSE_TLS").unwrap_or(false) + || tls_cert_path.is_some() + || tls_key_path.is_some(); 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(()) } @@ -1448,6 +1604,7 @@ async fn handle_interactive_session( identifier: Option, resume: bool, fork: bool, + edit: bool, history: bool, session_opts: SessionOptions, extension_opts: ExtensionOptions, @@ -1487,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?; + } } } @@ -1517,6 +1693,7 @@ async fn handle_interactive_session( quiet: false, output_format: "text".to_string(), container: session_opts.container.map(Container::new), + stats: false, }) .await; @@ -1541,7 +1718,7 @@ async fn log_session_completion( let (total_tokens, message_count) = session .get_session() .await - .map(|m| (m.total_tokens.unwrap_or(0), m.message_count)) + .map(|m| (m.usage.total_tokens.unwrap_or(0), m.message_count)) .unwrap_or((0, 0)); tracing::info!( @@ -1729,6 +1906,7 @@ async fn handle_run_command( quiet: output_opts.quiet, output_format: output_opts.output_format, container: session_opts.container.map(Container::new), + stats: run_behavior.stats, }) .await; @@ -1836,20 +2014,40 @@ async fn handle_term_subcommand(command: TermCommand) -> Result<()> { } } +#[cfg(feature = "local-inference")] +fn print_download_progress(manager: &goose::download_manager::DownloadManager) { + let Some(progress) = manager + .list_progress() + .into_iter() + .find(|progress| progress.status == goose::download_manager::DownloadStatus::Downloading) + else { + return; + }; + + print!( + "\r {:.1}% ({:.0}MB / {:.0}MB)", + progress.progress_percent, + progress.bytes_downloaded as f64 / (1024.0 * 1024.0), + progress.total_bytes as f64 / (1024.0 * 1024.0), + ); + use std::io::Write; + std::io::stdout().flush().ok(); +} + #[cfg(feature = "local-inference")] async fn handle_local_models_command(command: LocalModelsCommand) -> Result<()> { use goose::providers::local_inference::hf_models; - use goose::providers::local_inference::local_model_registry::{ - get_registry, mmproj_local_path, model_id_from_repo, LocalModelEntry, - }; + 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); - let results = hf_models::search_gguf_models(&query, limit).await?; + let results = hf_models::search_local_models(&query, limit).await?; if results.is_empty() { - println!("No GGUF models found."); + println!("No compatible local models found."); return Ok(()); } @@ -1858,126 +2056,68 @@ async fn handle_local_models_command(command: LocalModelsCommand) -> Result<()> "\n{} (by {}) โ€” {} downloads", model.model_name, model.author, model.downloads ); - for file in &model.gguf_files { - let size = if file.size_bytes > 0 { + for variant in &model.variants { + let size = if variant.size_bytes > 0 { format!( "{:.1}GB", - file.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0) + variant.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0) ) } else { "unknown".to_string() }; - println!(" {} โ€” {}", file.quantization, size); + let support = if variant.supported { + String::new() + } else { + format!( + " ({})", + variant + .unsupported_reason + .as_deref() + .unwrap_or("unsupported on this platform") + ) + }; + println!( + " [{}] {} โ€” {} โ€” {}{}", + variant.format, variant.label, size, variant.description, support + ); + if variant.supported { + println!( + " Download: goose local-models download '{}'", + variant.download_id + ); + } } - println!( - " Download: goose local-models download {}:", - model.repo_id - ); } } LocalModelsCommand::Download { spec } => { println!("Resolving {}...", spec); - let (repo_id, resolved) = hf_models::resolve_model_spec_full(&spec).await?; - if resolved.files.len() > 1 { - anyhow::bail!( - "Model '{}' is sharded ({} files) โ€” download it from the desktop UI", - spec, - resolved.files.len() - ); - } - let mmproj = resolved.mmproj; - let file = resolved.files.into_iter().next().unwrap(); - let model_id = model_id_from_repo(&repo_id, &file.quantization); - let local_path = - goose::config::paths::Paths::in_data_dir("models").join(&file.filename); - let mmproj_path = mmproj - .as_ref() - .map(|mmproj| mmproj_local_path(&repo_id, &mmproj.filename)); - let mmproj_source_url = mmproj.as_ref().map(|mmproj| mmproj.download_url.clone()); - let mmproj_size_bytes = mmproj.as_ref().map_or(0, |mmproj| mmproj.size_bytes); - let mut download_files = vec![(file.download_url.clone(), local_path.clone())]; - if let Some(mmproj) = mmproj { - download_files.push((mmproj.download_url, mmproj_path.clone().unwrap())); - } + let manager = goose::download_manager::get_download_manager(); + let resolve_task = hf_models::resolve_local_model_spec(&spec); + tokio::pin!(resolve_task); + let resolved = loop { + tokio::select! { + result = &mut resolve_task => break result?, + _ = tokio::time::sleep(std::time::Duration::from_millis(500)) => { + print_download_progress(manager); + } + } + }; + let model_id = resolved.model_id(); + let total_size = resolved.total_size(); println!( - "Downloading {} ({})...", + "\nDownloaded {} ({}). Registering...", model_id, - if file.size_bytes > 0 { - format!( - "{:.1}GB", - file.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0) - ) + if total_size > 0 { + format!("{:.1}GB", total_size as f64 / (1024.0 * 1024.0 * 1024.0)) } else { "unknown size".to_string() } ); - // Register - let entry = LocalModelEntry { - id: model_id.clone(), - repo_id: repo_id.clone(), - filename: file.filename.clone(), - quantization: file.quantization.clone(), - local_path: local_path.clone(), - source_url: file.download_url.clone(), - settings: Default::default(), - size_bytes: file.size_bytes, - mmproj_path, - mmproj_source_url, - mmproj_size_bytes, - mmproj_checked: true, - shard_files: vec![], - }; + let model_id = hf_models::register_resolved_model(resolved, &spec)?; - { - let mut registry = get_registry() - .lock() - .map_err(|_| anyhow::anyhow!("Failed to acquire registry lock"))?; - registry.add_model(entry)?; - } - - // Download - let manager = goose::download_manager::get_download_manager(); - manager - .download_model_sharded( - format!("{}-model", model_id), - download_files, - file.size_bytes + mmproj_size_bytes, - None, - ) - .await?; - - // Poll progress - loop { - if let Some(progress) = manager.get_progress(&format!("{}-model", model_id)) { - match progress.status { - goose::download_manager::DownloadStatus::Downloading => { - print!( - "\r {:.1}% ({:.0}MB / {:.0}MB)", - progress.progress_percent, - progress.bytes_downloaded as f64 / (1024.0 * 1024.0), - progress.total_bytes as f64 / (1024.0 * 1024.0), - ); - use std::io::Write; - std::io::stdout().flush().ok(); - } - goose::download_manager::DownloadStatus::Completed => { - println!("\nDownloaded: {}", model_id); - break; - } - goose::download_manager::DownloadStatus::Failed => { - let err = progress.error.unwrap_or_default(); - anyhow::bail!("Download failed: {}", err); - } - goose::download_manager::DownloadStatus::Cancelled => { - println!("\nDownload cancelled."); - break; - } - } - } - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - } + println!("Registered: {}", model_id); } LocalModelsCommand::List => { let registry = get_registry() @@ -1990,12 +2130,16 @@ async fn handle_local_models_command(command: LocalModelsCommand) -> Result<()> return Ok(()); } - println!("{:<50} {:<10} Downloaded", "ID", "Quant"); - println!("{}", "-".repeat(70)); + println!( + "{:<50} {:<10} {:<12} Downloaded", + "ID", "Backend", "Variant" + ); + println!("{}", "-".repeat(88)); for m in models { println!( - "{:<50} {:<10} {}", + "{:<50} {:<10} {:<12} {}", m.id, + m.backend_id.as_deref().unwrap_or("llamacpp"), m.quantization, if m.is_downloaded() { "โœ“" } else { "โœ—" } ); @@ -2006,11 +2150,8 @@ async fn handle_local_models_command(command: LocalModelsCommand) -> Result<()> .lock() .map_err(|_| anyhow::anyhow!("Failed to acquire registry lock"))?; - if let Some(entry) = registry.get_model(&id) { - if entry.local_path.exists() { - std::fs::remove_file(&entry.local_path)?; - } - registry.remove_model(&id)?; + if registry.get_model(&id).is_some() { + registry.delete_model(&id)?; println!("Deleted model: {}", id); } else { println!("Model not found: {}", id); @@ -2055,6 +2196,7 @@ async fn handle_default_session() -> Result<()> { quiet: false, output_format: "text".to_string(), container: None, + stats: false, }) .await; session.interactive(None).await @@ -2090,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, @@ -2100,6 +2261,7 @@ pub async fn cli() -> anyhow::Result<()> { identifier, resume, fork, + edit, history, session_opts, extension_opts, @@ -2108,6 +2270,7 @@ pub async fn cli() -> anyhow::Result<()> { identifier, resume, fork, + edit, history, session_opts, extension_opts, @@ -2273,4 +2436,134 @@ mod tests { let help = String::from_utf8(buffer).expect("utf8"); assert!(help.contains("nu")); } + + #[test] + fn skills_command_accepts_list_subcommand() { + let cli = Cli::try_parse_from(["goose", "skills", "list"]).expect("parse failed"); + + match cli.command { + Some(Command::Skills { + command: SkillsCommand::List, + }) => {} + _ => panic!("expected skills list command"), + } + } + + #[test] + fn serve_command_accepts_dangerously_unauthenticated_flag() { + let cli = Cli::try_parse_from([ + "goose", + "serve", + "--dangerously-unauthenticated", + "--allowed-origin", + "app://localhost", + "--allowed-origin", + "https://app.example", + ]) + .expect("parse failed"); + + match cli.command { + Some(Command::Serve { + dangerously_unauthenticated, + allowed_origins, + .. + }) => { + assert!(dangerously_unauthenticated); + assert_eq!( + allowed_origins, + vec!["app://localhost", "https://app.example"] + ); + } + _ => panic!("expected serve command"), + } + } + + #[test] + fn review_command_accepts_options() { + let cli = Cli::try_parse_from([ + "goose", + "review", + "origin/main...HEAD", + "--prompt", + "REVIEW.md", + "--model", + "test-model", + "--provider", + "openai", + "--override-model", + "check-model", + "--turn-limit", + "4", + "--dry-run", + "--quiet", + "--no-orchestrate", + "--instructions", + "focus on correctness", + "--files", + "src/lib.rs", + "--check-filter", + "security", + "--check-scope", + ".agents", + "--checks-only", + "--summary-only", + "--severity", + "low", + ]) + .expect("parse failed"); + + match cli.command { + Some(Command::Review { + range, + prompt, + model, + provider, + override_model, + turn_limit, + dry_run, + quiet, + no_orchestrate, + instructions, + files, + check_filter, + check_scope, + checks_only, + summary_only, + severity, + }) => { + assert_eq!(range.as_deref(), Some("origin/main...HEAD")); + assert_eq!(prompt.as_deref(), Some(std::path::Path::new("REVIEW.md"))); + assert_eq!(model.as_deref(), Some("test-model")); + assert_eq!(provider.as_deref(), Some("openai")); + assert_eq!(override_model.as_deref(), Some("check-model")); + assert_eq!(turn_limit, Some(4)); + assert!(dry_run); + assert!(quiet); + assert!(no_orchestrate); + assert_eq!(instructions.as_deref(), Some("focus on correctness")); + assert_eq!(files, vec!["src/lib.rs"]); + assert_eq!(check_filter, vec!["security"]); + assert_eq!( + check_scope.as_deref(), + Some(std::path::Path::new(".agents")) + ); + assert!(checks_only); + assert!(summary_only); + assert_eq!(severity, "low"); + } + _ => panic!("expected review command"), + } + } + + #[cfg(feature = "tui")] + #[test] + fn tui_command_accepts_trailing_args() { + let cli = + Cli::try_parse_from(["goose", "tui", "--", "--theme", "dark"]).expect("parse failed"); + + match cli.command { + Some(Command::Tui { args }) => assert_eq!(args, vec!["--theme", "dark"]), + _ => panic!("expected tui command"), + } + } } diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index ce6cb63aca..96f1574b65 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -19,20 +19,31 @@ use goose::config::{ configure_tetrate, Config, ConfigError, ExperimentManager, ExtensionEntry, GooseMode, PermissionManager, }; -use goose::model::ModelConfig; #[cfg(feature = "telemetry")] use goose::posthog::{get_telemetry_choice, TELEMETRY_ENABLED_KEY}; use goose::providers::base::ConfigKey; use goose::providers::provider_test::test_provider_configuration; use goose::providers::{create, providers, retry_operation, RetryConfig}; use goose::session::SessionType; +use goose_providers::thinking::ThinkingEffort; use serde_json::Value; use std::collections::HashMap; -use std::io::IsTerminal; +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 = ModelConfig::new("temp")?.with_canonical_limits(provider_name); - 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!"); @@ -365,7 +376,12 @@ async fn handle_oauth_configuration(provider_name: &str, key_name: &str) -> anyh } } -fn interactive_model_search(models: &[String]) -> anyhow::Result { +const UNLISTED_MODEL_KEY: &str = "__unlisted__"; + +fn interactive_model_search( + models: &[String], + provider_meta: &goose::providers::base::ProviderMetadata, +) -> anyhow::Result { const MAX_VISIBLE: usize = 30; let mut query = String::new(); @@ -395,7 +411,20 @@ fn interactive_model_search(models: &[String]) -> anyhow::Result { }; if filtered.is_empty() { - let _ = cliclack::log::warning("No matching models. Try a different search."); + let selection = cliclack::select("No matching models. What would you like to do?") + .item( + "__new_search__", + "Start a new search...", + "Enter a different search term", + ) + .item(UNLISTED_MODEL_KEY, "Enter a model not listed...", "") + .interact()?; + + if selection == UNLISTED_MODEL_KEY { + return prompt_unlisted_model(provider_meta); + } + + query.clear(); continue; } @@ -429,6 +458,12 @@ fn interactive_model_search(models: &[String]) -> anyhow::Result { ); } + items.push(( + UNLISTED_MODEL_KEY.to_string(), + "Enter a model not listed...".to_string(), + "", + )); + let selection = cliclack::select("Select a model:") .items(&items) .interact()?; @@ -438,6 +473,8 @@ fn interactive_model_search(models: &[String]) -> anyhow::Result { } else if selection == "__new_search__" { query.clear(); continue; + } else if selection == UNLISTED_MODEL_KEY { + return prompt_unlisted_model(provider_meta); } else { return Ok(selection); } @@ -449,7 +486,6 @@ fn select_model_from_list( provider_meta: &goose::providers::base::ProviderMetadata, ) -> anyhow::Result { const MAX_MODELS: usize = 10; - const UNLISTED_MODEL_KEY: &str = "__unlisted__"; // Smart model selection: // If we have more than MAX_MODELS models, show the recommended models with additional search option. @@ -488,14 +524,14 @@ fn select_model_from_list( .interact()?; if selection == "search_all" { - Ok(interactive_model_search(models)?) + interactive_model_search(models, provider_meta) } else if selection == UNLISTED_MODEL_KEY { prompt_unlisted_model(provider_meta) } else { Ok(selection) } } else { - Ok(interactive_model_search(models)?) + interactive_model_search(models, provider_meta) } } else { let mut model_items: Vec<(String, String, &str)> = @@ -736,11 +772,11 @@ pub async fn configure_provider_dialog() -> anyhow::Result { let spin = spinner(); spin.start("Attempting to fetch supported models..."); - let temp_model_config = - ModelConfig::new(&provider_meta.default_model)?.with_canonical_limits(provider_name); - let temp_provider = create(provider_name, temp_model_config, Vec::new()).await?; + let 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()); @@ -765,20 +801,20 @@ pub async fn configure_provider_dialog() -> anyhow::Result { { let supports_thinking = match temp_provider.fetch_model_info(&model).await { Ok(model_info) => model_info.reasoning, - Err(_) => goose::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 { - let effort: &str = cliclack::select("Select thinking effort:") + let effort: ThinkingEffort = cliclack::select("Select thinking effort:") .item("off", "Off - No extended thinking", "") .item("low", "Low - Better latency, lighter reasoning", "") .item("medium", "Medium - Moderate thinking", "") .item("high", "High - Deep reasoning", "") .item("max", "Max - No constraints on thinking depth", "") .initial_value("off") - .interact()?; + .interact()? + .parse() + .map_err(|_| anyhow::anyhow!("invalid thinking effort"))?; config.set_goose_thinking_effort(effort)?; } } @@ -1080,6 +1116,7 @@ fn configure_stdio_extension() -> anyhow::Result<()> { env_keys, description, timeout: Some(timeout), + cwd: None, bundled: None, available_tools: Vec::new(), }, @@ -1551,7 +1588,7 @@ pub async fn configure_tool_permissions_dialog() -> anyhow::Result<()> { let model: String = config .get_goose_model() .expect("No model configured. Please set model first"); - let model_config = ModelConfig::new(&model)?.with_canonical_limits(&provider_name); + let model_config = goose::model_config::model_config_from_user_config(&provider_name, &model)?; let agent = Agent::new(); @@ -1588,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 @@ -1771,22 +1810,21 @@ pub async fn handle_openrouter_auth() -> anyhow::Result<()> { // Test configuration - get the model that was configured println!("\nTesting configuration..."); let configured_model: String = config.get_goose_model()?; - let model_config = match goose::model::ModelConfig::new(&configured_model) { - Ok(config) => config.with_canonical_limits("openrouter"), - Err(e) => { - eprintln!("โš ๏ธ Invalid model configuration: {}", e); - eprintln!("Your settings have been saved. Please check your model configuration."); - return Ok(()); - } - }; + let model_config = + match goose::model_config::model_config_from_user_config("openrouter", &configured_model) { + Ok(config) => config, + Err(e) => { + eprintln!("โš ๏ธ Invalid model configuration: {}", e); + eprintln!("Your settings have been saved. Please check your model configuration."); + return Ok(()); + } + }; - 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!'")], &[], @@ -1852,16 +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::ModelConfig::new(&configured_model) { - Ok(config) => config.with_canonical_limits("tetrate"), - Err(e) => { - eprintln!("โš ๏ธ Invalid model configuration: {}", e); - eprintln!("Your settings have been saved. Please check your model configuration."); - return Ok(()); - } - }; + 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 d702b3e43e..f471018cb3 100644 --- a/crates/goose-cli/src/commands/info.rs +++ b/crates/goose-cli/src/commands/info.rs @@ -3,8 +3,8 @@ use console::style; use goose::config::paths::Paths; use goose::config::Config; use goose::conversation::message::Message; -use goose::providers::errors::ProviderError; use goose::session::session_manager::{DB_NAME, SESSIONS_FOLDER}; +use goose_providers::errors::ProviderError; use serde_yaml; use std::time::Duration; @@ -73,11 +73,10 @@ async fn check_provider( } }; - let model_config = goose::model::ModelConfig::new(&model) - .map_err(|e| ProviderCheckError::InvalidModel(e.to_string()))? - .with_canonical_limits(&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(); @@ -88,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/review/handler.rs b/crates/goose-cli/src/commands/review/handler.rs index 1b362f6d8d..8c6f6d4cda 100644 --- a/crates/goose-cli/src/commands/review/handler.rs +++ b/crates/goose-cli/src/commands/review/handler.rs @@ -29,7 +29,9 @@ pub struct ReviewOptions { /// Force every discovered check to run with this model, regardless of /// the check's own `model:` field. pub override_model: Option, - /// Default `turn-limit` applied to checks that do not declare their own. + /// Default `turn-limit` for orchestrated main-pass subprocesses and for + /// checks that do not declare their own. Does not cap the legacy + /// `--no-orchestrate` in-process main agent. pub default_turn_limit: Option, /// Print the assembled prompt and discovered checks instead of dispatching /// the review. diff --git a/crates/goose-cli/src/commands/review/orchestrator.rs b/crates/goose-cli/src/commands/review/orchestrator.rs index af2b72e7b3..151bd181f3 100644 --- a/crates/goose-cli/src/commands/review/orchestrator.rs +++ b/crates/goose-cli/src/commands/review/orchestrator.rs @@ -12,7 +12,8 @@ //! //! - One subprocess per check (`goose run -q -t `) //! - Concurrency capped at [`MAX_WORKERS`] via a Tokio semaphore -//! - Per-check timeout of [`CHECK_TIMEOUT_SECS`] +//! - Per-subprocess turn limit via `--max-turns` (see +//! [`resolve_main_turn_limit`] and [`Check::resolved_turn_limit`]) //! - Each check is given a strict, tool-free prompt and is required to //! return only `{"findings": [...]}` JSON //! - Findings are tagged with the originating `check` name in Rust, not @@ -31,27 +32,19 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::process::Stdio; use std::sync::Arc; -use std::time::Duration; use tokio::io::AsyncWriteExt; use tokio::process::Command; use tokio::sync::Semaphore; use tokio::task::JoinSet; -use tokio::time::timeout; use super::handler::ReviewOptions; -use goose::checks::Check; +use goose::checks::{Check, DEFAULT_CHECK_TURN_LIMIT}; /// Maximum number of check subprocesses we run concurrently. 4 is /// empirically the sweet spot before LLM-side rate limits and local /// resource contention start hurting wall-clock. pub const MAX_WORKERS: usize = 4; -/// Hard wall-clock cap for a single check subprocess. A check that -/// takes longer than this is almost always stuck in a tool-call loop -/// or a retry storm; we'd rather surface the timeout than block the -/// whole review. -pub const CHECK_TIMEOUT_SECS: u64 = 5 * 60; - /// One review finding emitted by a check or by the main correctness /// pass. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -87,7 +80,7 @@ struct RawFinding { /// Run all discovered checks concurrently as `goose run` subprocesses. /// /// Returns one `Vec` per check, in the same order as `checks`. -/// A failed check (subprocess error, timeout, malformed JSON) yields an +/// A failed check (subprocess error, turn-limit exhaustion, malformed JSON) yields an /// empty findings list and a warning on stderr; a single broken check /// must never block the rest of the review. pub async fn run_checks_in_parallel( @@ -186,6 +179,14 @@ fn resolve_check_model(check: &Check, opts: &ReviewOptions) -> Option { opts.default_model.clone() } +/// Resolve the turn limit for a main-pass subprocess. +/// +/// Uses `goose review --turn-limit` when set, otherwise +/// [`DEFAULT_CHECK_TURN_LIMIT`]. +fn resolve_main_turn_limit(default_turn_limit: Option) -> usize { + default_turn_limit.unwrap_or(DEFAULT_CHECK_TURN_LIMIT) +} + /// Spawn a single `goose run` subprocess for one check and parse its /// output into [`Finding`]s. async fn run_single_check_subprocess( @@ -196,7 +197,8 @@ async fn run_single_check_subprocess( instructions: Option<&str>, max_turns: Option, ) -> Result> { - let prompt = build_check_prompt(check, diff, instructions); + let turns = max_turns.expect("check subprocess always has a resolved turn limit"); + let prompt = build_check_prompt(check, diff, instructions, turns); let raw = run_subprocess_for_findings( &prompt, &format!("check '{}'", check.name), @@ -222,8 +224,7 @@ async fn run_single_check_subprocess( /// Generic `goose run` subprocess that hands a prompt to the model /// and parses `{"findings": [...]}` JSON out of the response. Shared /// by the per-check and per-file main-pass orchestrators so both get -/// the same robust JSON extraction, timeout handling, and error -/// reporting. +/// the same robust JSON extraction and error reporting. async fn run_subprocess_for_findings( prompt: &str, label: &str, @@ -243,11 +244,8 @@ async fn run_subprocess_for_findings( .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - // Drop-on-cancel safety: when the outer `timeout` fires it drops - // this future, which drops the Child handle. Without - // kill_on_drop, the Tokio runtime leaves the subprocess running - // (and racking up tokens) in the background โ€” kill_on_drop sends - // SIGKILL on Drop instead. + // If this future is dropped, kill the child so it does not keep + // running (and racking up tokens) in the background. .kill_on_drop(true); if let Some(p) = provider { @@ -273,13 +271,10 @@ async fn run_subprocess_for_findings( drop(stdin); } - let wait = child.wait_with_output(); - let output = match timeout(Duration::from_secs(CHECK_TIMEOUT_SECS), wait).await { - Ok(o) => o.with_context(|| format!("wait on {label}"))?, - Err(_) => { - anyhow::bail!("{label} timed out after {}s", CHECK_TIMEOUT_SECS); - } - }; + let output = child + .wait_with_output() + .await + .with_context(|| format!("wait on {label}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -321,6 +316,7 @@ pub async fn run_main_pass_in_parallel( let semaphore = Arc::new(Semaphore::new(MAX_WORKERS)); let mut set: JoinSet<(usize, String, Result>, bool)> = JoinSet::new(); + let max_turns = resolve_main_turn_limit(opts.default_turn_limit); for (idx, (path, file_diff)) in per_file.iter().enumerate() { let sem = semaphore.clone(); @@ -334,15 +330,20 @@ pub async fn run_main_pass_in_parallel( set.spawn(async move { let _permit = sem.acquire().await.expect("semaphore is never closed"); - let prompt = - build_main_pass_prompt(&path, &file_diff, &base_prompt, instructions.as_deref()); + let prompt = build_main_pass_prompt( + &path, + &file_diff, + &base_prompt, + instructions.as_deref(), + max_turns, + ); let label = format!("main:{path}"); let result = run_subprocess_for_findings( &prompt, &label, provider.as_deref(), model.as_deref(), - None, + Some(max_turns), ) .await; (idx, path, result, quiet) @@ -567,6 +568,22 @@ fn take_quoted(s: &str) -> Option<(String, &str)> { None } +/// Prompt section telling review subprocesses about the `--max-turns` +/// cap enforced by goose. Without this, models routinely burn turns on +/// tool loops and return nothing when the limit stops the session. +fn build_subprocess_turn_budget_section(max_turns: usize) -> String { + format!( + "## Turn budget\n\n\ + You may take at most {max_turns} agent turns (model/tool iterations) in this run. \ + goose enforces this via `--max-turns`; when you exhaust it, the session stops and \ + any findings not yet emitted as JSON are lost.\n\n\ + Plan for the limit:\n\ + - As turns run low, stop exploring and return JSON with the findings you have verified.\n\ + - Always emit valid JSON (`{{\"findings\":[...]}}` or `{{\"findings\":[]}}`) before \ + the turn limit โ€” an empty or missing response counts as failure.\n\n" + ) +} + /// Build the strict, JSON-only prompt sent to one main-pass /// subprocess. The base prompt (custom or /// [`DEFAULT_REVIEW_PROMPT`]) supplies the reviewer voice; we then @@ -577,6 +594,7 @@ fn build_main_pass_prompt( file_diff: &str, base_prompt: &str, instructions: Option<&str>, + max_turns: usize, ) -> String { let mut s = String::new(); s.push_str(base_prompt.trim_end()); @@ -589,6 +607,7 @@ fn build_main_pass_prompt( s.push_str("\n\n"); } } + s.push_str(&build_subprocess_turn_budget_section(max_turns)); s.push_str("## File under review\n\n"); s.push_str(&format!("Path: `{path}`\n\n")); s.push_str( @@ -611,7 +630,12 @@ fn build_main_pass_prompt( /// Shape matches the prompt format Amp-authored checks already expect, /// so a check written for `amp review` runs the same way under /// `goose review`. -fn build_check_prompt(check: &Check, diff: &str, instructions: Option<&str>) -> String { +fn build_check_prompt( + check: &Check, + diff: &str, + instructions: Option<&str>, + max_turns: usize, +) -> String { let mut s = String::new(); s.push_str("You are running an automated code review check.\n\n"); s.push_str(&format!("Check name: {}\n", check.name)); @@ -633,7 +657,9 @@ fn build_check_prompt(check: &Check, diff: &str, instructions: Option<&str>) -> s.push('\n'); } } - s.push_str("\nReview ONLY the git diff provided below.\n"); + s.push('\n'); + s.push_str(&build_subprocess_turn_budget_section(max_turns)); + s.push_str("Review ONLY the git diff provided below.\n"); s.push_str("Do not ask for missing context.\n"); s.push_str("Use repo-relative file paths.\n"); s.push_str("Use post-change line numbers from the diff.\n"); @@ -820,7 +846,7 @@ mod tests { #[test] fn check_prompt_is_strict_and_diff_aware() { - let p = build_check_prompt(&ck("perf"), "diff content", None); + let p = build_check_prompt(&ck("perf"), "diff content", None, DEFAULT_CHECK_TURN_LIMIT); assert!(p.contains("automated code review check")); assert!(p.contains("Check name: perf")); assert!(p.contains("```diff\ndiff content\n```")); @@ -833,7 +859,7 @@ mod tests { fn check_prompt_restricts_findings_to_added_or_modified_lines() { // Mirrors Amp's prompt language; without these the model // happily flags pre-existing code shown for context. - let p = build_check_prompt(&ck("perf"), "diff content", None); + let p = build_check_prompt(&ck("perf"), "diff content", None, DEFAULT_CHECK_TURN_LIMIT); assert!(p.contains("ONLY in the changed lines")); assert!(p.contains("lines beginning with `+`")); assert!(p.contains("ONLY for code that was added or modified")); @@ -846,6 +872,7 @@ mod tests { &ck("perf"), "diff content", Some("This is a refactor; flag any behavior change."), + DEFAULT_CHECK_TURN_LIMIT, ); assert!(p.contains("Reviewer instructions:")); assert!(p.contains("flag any behavior change")); @@ -853,10 +880,29 @@ mod tests { #[test] fn check_prompt_skips_blank_reviewer_instructions() { - let p = build_check_prompt(&ck("perf"), "diff content", Some(" \n ")); + let p = build_check_prompt( + &ck("perf"), + "diff content", + Some(" \n "), + DEFAULT_CHECK_TURN_LIMIT, + ); assert!(!p.contains("Reviewer instructions")); } + #[test] + fn check_prompt_includes_turn_budget() { + let p = build_check_prompt(&ck("perf"), "diff content", None, 12); + assert!(p.contains("## Turn budget")); + assert!(p.contains("at most 12 agent turns")); + assert!(p.contains("--max-turns")); + } + + #[test] + fn resolve_main_turn_limit_uses_cli_default_or_fallback() { + assert_eq!(resolve_main_turn_limit(Some(40)), 40); + assert_eq!(resolve_main_turn_limit(None), DEFAULT_CHECK_TURN_LIMIT); + } + #[test] fn parse_findings_accepts_bare_json() { let raw = r#"{"findings":[{"severity":"high","path":"a.py","line_start":1,"line_end":2,"summary":"bad"}]}"#; @@ -1090,6 +1136,7 @@ rename to new/name.rs "diff --git a/src/foo.rs b/src/foo.rs\n@@ -1 +1 @@\n-old\n+new\n", "BASE PROMPT", None, + DEFAULT_CHECK_TURN_LIMIT, ); assert!(p.starts_with("BASE PROMPT")); assert!(p.contains("Path: `src/foo.rs`")); @@ -1108,6 +1155,7 @@ rename to new/name.rs "diff body", "BASE", Some("PR is a refactor; flag behavior changes."), + DEFAULT_CHECK_TURN_LIMIT, ); assert!(p.contains("## Reviewer instructions")); assert!(p.contains("flag behavior changes")); @@ -1115,7 +1163,20 @@ rename to new/name.rs #[test] fn main_pass_prompt_skips_blank_reviewer_instructions() { - let p = build_main_pass_prompt("src/foo.rs", "diff body", "BASE", Some(" \n \t\n")); + let p = build_main_pass_prompt( + "src/foo.rs", + "diff body", + "BASE", + Some(" \n \t\n"), + DEFAULT_CHECK_TURN_LIMIT, + ); assert!(!p.contains("Reviewer instructions")); } + + #[test] + fn main_pass_prompt_includes_turn_budget() { + let p = build_main_pass_prompt("src/foo.rs", "diff body", "BASE", None, 18); + assert!(p.contains("## Turn budget")); + assert!(p.contains("at most 18 agent turns")); + } } diff --git a/crates/goose-cli/src/commands/schedule.rs b/crates/goose-cli/src/commands/schedule.rs index 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 01efebf21a..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)? { @@ -300,6 +307,15 @@ pub async fn handle_session_import(input: String, nostr: bool) -> Result<()> { .with_context(|| format!("Failed to read session import file: {input}"))? }; + let format = goose::session::import_formats::detect_format(&json); + let label = match format { + goose::session::import_formats::ImportFormat::Goose => "goose", + goose::session::import_formats::ImportFormat::ClaudeCode => "Claude Code", + goose::session::import_formats::ImportFormat::Codex => "Codex", + goose::session::import_formats::ImportFormat::Pi => "Pi", + }; + println!("Detected format: {}", label); + let session_manager = SessionManager::instance(); let session = session_manager .import_session(&json, Some(SessionType::User)) @@ -313,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!( @@ -341,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 8596eeb782..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() { @@ -324,7 +330,10 @@ pub async fn handle_term_info() -> Result<()> { let session_manager = SessionManager::instance(); let session = session_manager.get_session(&session_id, false).await.ok(); - let total_tokens = session.as_ref().and_then(|s| s.total_tokens).unwrap_or(0) as usize; + let total_tokens = session + .as_ref() + .and_then(|s| s.usage.total_tokens) + .unwrap_or(0) as usize; let config = goose::config::Config::global(); let model_name = config @@ -345,9 +354,7 @@ pub async fn handle_term_info() -> Result<()> { .ok() .and_then(|model_name| { config.get_goose_provider().ok().and_then(|provider_name| { - goose::model::ModelConfig::new(&model_name) - .ok() - .map(|c| c.with_canonical_limits(&provider_name)) + goose::model_config::model_config_from_user_config(&provider_name, &model_name).ok() }) }) .map(|mc| mc.context_limit()) 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/logging.rs b/crates/goose-cli/src/logging.rs index 68d435c3c2..a7f8af7c95 100644 --- a/crates/goose-cli/src/logging.rs +++ b/crates/goose-cli/src/logging.rs @@ -1,53 +1,31 @@ use anyhow::Result; -use std::sync::Once; +use goose::providers::utils::init_goose_request_log; +use std::sync::OnceLock; // Used to ensure we only set up tracing once -static INIT: Once = Once::new(); +static INIT: OnceLock> = OnceLock::new(); /// Sets up the logging infrastructure for the CLI. /// Logs go to a JSON file only (no console output). -pub fn setup_logging(name: Option<&str>) -> Result<()> { - setup_logging_internal(name, false) -} +pub fn setup_logging(name: Option<&str>) -> &'static Result<()> { + INIT.get_or_init(|| { + use tracing_subscriber::util::SubscriberInitExt; -fn setup_logging_internal(name: Option<&str>, force: bool) -> Result<()> { - let mut result = Ok(()); + init_goose_request_log()?; + let config = goose::logging::LoggingConfig { + component: "cli", + name, + extra_directives: &["goose_cli=info"], + console: false, + json: true, + }; + let subscriber = goose::logging::build_logging_subscriber(&config)?; - let mut setup = || { - result = (|| { - use tracing_subscriber::util::SubscriberInitExt; - - let config = goose::logging::LoggingConfig { - component: "cli", - name, - extra_directives: &["goose_cli=info"], - console: false, - json: true, - }; - let subscriber = goose::logging::build_logging_subscriber(&config)?; - - if force { - let _guard = subscriber.set_default(); - tracing::warn!("Test log entry from setup"); - tracing::info!("Another test log entry from setup"); - std::thread::sleep(std::time::Duration::from_millis(100)); - Ok(()) - } else { - subscriber - .try_init() - .map_err(|e| anyhow::anyhow!("Failed to set global subscriber: {}", e))?; - Ok(()) - } - })(); - }; - - if force { - setup(); - } else { - INIT.call_once(setup); - } - - result + subscriber + .try_init() + .map_err(|e| anyhow::anyhow!("Failed to set global subscriber: {}", e))?; + Ok(()) + }) } #[cfg(test)] 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/recipes/secret_discovery.rs b/crates/goose-cli/src/recipes/secret_discovery.rs index 3a9a17f8ee..29a102ee31 100644 --- a/crates/goose-cli/src/recipes/secret_discovery.rs +++ b/crates/goose-cli/src/recipes/secret_discovery.rs @@ -178,6 +178,7 @@ mod tests { envs: Envs::new(HashMap::new()), env_keys: vec!["SLACK_TOKEN".to_string()], timeout: None, + cwd: None, description: "slack-mcp".to_string(), bundled: None, available_tools: Vec::new(), @@ -275,6 +276,7 @@ mod tests { envs: Envs::new(HashMap::new()), env_keys: vec!["API_KEY".to_string()], // Same original key, different extension timeout: None, + cwd: None, description: "service-b".to_string(), bundled: None, available_tools: Vec::new(), diff --git a/crates/goose-cli/src/scenario_tests/scenario_runner.rs b/crates/goose-cli/src/scenario_tests/scenario_runner.rs index eae04307b3..2498c78cc0 100644 --- a/crates/goose-cli/src/scenario_tests/scenario_runner.rs +++ b/crates/goose-cli/src/scenario_tests/scenario_runner.rs @@ -9,7 +9,6 @@ use anyhow::Result; use goose::agents::{Agent, AgentConfig, GoosePlatform}; use goose::config::permission::PermissionManager; use goose::config::GooseMode; -use goose::model::ModelConfig; use goose::providers::{create, testprovider::TestProvider}; use goose::session::session_manager::SessionType; use goose::session::SessionManager; @@ -186,12 +185,7 @@ where let original_env = setup_environment(config)?; - let inner_provider = create( - &factory_name, - ModelConfig::new(config.model_name)?.with_canonical_limits(&factory_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)); ( @@ -246,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?; @@ -262,6 +259,7 @@ where None, None, "text".to_string(), + false, ) .await; diff --git a/crates/goose-cli/src/scenario_tests/scenarios.rs b/crates/goose-cli/src/scenario_tests/scenarios.rs index 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 a013952081..de228d6e99 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -6,6 +6,7 @@ use console::style; use goose::agents::{Agent, Container, ExtensionError}; use goose::config::resolve_extensions_for_new_session; use goose::config::{Config, ExtensionConfig, GooseMode}; +use goose::model_config::model_config_from_user_config; use goose::providers::create; use goose::recipe::Recipe; use goose::session::session_manager::SessionType; @@ -116,6 +117,8 @@ pub struct SessionBuilderConfig { pub output_format: String, /// Docker container to run stdio extensions inside pub container: Option, + /// Print generation statistics after headless runs. + pub stats: bool, } /// Manual implementation of Default to ensure proper initialization of output_format @@ -143,6 +146,7 @@ impl Default for SessionBuilderConfig { quiet: false, output_format: "text".to_string(), container: None, + stats: false, } } } @@ -226,14 +230,14 @@ async fn load_extensions( struct ResolvedProviderConfig { provider_name: String, model_name: String, - model_config: goose::model::ModelConfig, + model_config: goose_providers::model::ModelConfig, } fn resolve_provider_and_model( session_config: &SessionBuilderConfig, config: &Config, saved_provider: Option, - saved_model_config: Option, + saved_model_config: Option, ) -> ResolvedProviderConfig { let recipe_settings = session_config .recipe @@ -274,14 +278,16 @@ fn resolve_provider_and_model( } config } else { - let temperature = recipe_settings.and_then(|s| s.temperature); - goose::model::ModelConfig::new(&model_name) - .unwrap_or_else(|e| { - output::render_error(&format!("Failed to create model configuration: {}", e)); - process::exit(1); - }) - .with_canonical_limits(&provider_name) - .with_temperature(temperature) + let mut config = + goose::model_config::model_config_from_user_config(&provider_name, &model_name) + .unwrap_or_else(|e| { + output::render_error(&format!("Failed to create model configuration: {}", e)); + process::exit(1); + }); + if let Some(temp) = recipe_settings.and_then(|s| s.temperature) { + config = config.with_temperature(Some(temp)); + } + config }; ResolvedProviderConfig { @@ -327,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"); @@ -408,6 +417,7 @@ async fn collect_extension_configs( recipe: Option<&Recipe>, session_id: &str, ) -> Result, ExtensionError> { + let recipe_extensions = recipe.and_then(|r| r.extensions.as_deref()); let configured_extensions: Vec = if session_config.resume { EnabledExtensionsState::for_session( &agent.config.session_manager, @@ -418,7 +428,7 @@ async fn collect_extension_configs( } else if session_config.no_profile { Vec::new() } else { - resolve_extensions_for_new_session(recipe.and_then(|r| r.extensions.as_deref()), None) + resolve_extensions_for_new_session(recipe_extensions, None) }; let cli_flag_extensions = parse_cli_flag_extensions( @@ -428,6 +438,12 @@ async fn collect_extension_configs( ); let mut all: Vec = configured_extensions; + if !session_config.no_profile && !session_config.resume && recipe_extensions.is_none() { + let project_root = std::env::current_dir().ok(); + all.extend(goose::plugins::mcp_servers::enabled_plugin_mcp_servers( + project_root.as_deref(), + )); + } all.extend(cli_flag_extensions.into_iter().map(|(_, cfg)| cfg)); Ok(all) @@ -531,29 +547,79 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { } }; - let new_provider = match create( - &resolved.provider_name, - resolved.model_config, - extensions_for_provider.clone(), - ) - .await - { - Ok(provider) => provider, - Err(e) => { - output::render_error(&format!( + let (new_provider, effective_provider_name, effective_model_name, effective_model_config) = + match create(&resolved.provider_name, extensions_for_provider.clone()).await { + Ok(provider) => ( + provider, + resolved.provider_name.clone(), + resolved.model_name.clone(), + resolved.model_config.clone(), + ), + Err(e) + if session_config.resume + && session_config.provider.is_none() + && is_provider_unavailable_error(&e) => + { + let fallback_provider = config.get_goose_provider().unwrap_or_else(|_| { + output::render_error("No provider configured. Run 'goose configure' first."); + process::exit(1); + }); + let fallback_model = config.get_goose_model().unwrap_or_else(|_| { + output::render_error("No model configured. Run 'goose configure' first."); + process::exit(1); + }); + eprintln!( + "{}", + style(format!( + "Warning: Could not create the session's original provider '{}' ({}). \ + Falling back to the default provider '{}'.", + resolved.provider_name, e, fallback_provider + )) + .yellow() + ); + let fallback_model_config = + model_config_from_user_config(fallback_provider.as_str(), &fallback_model) + .unwrap_or_else(|e| { + output::render_error(&format!( + "Failed to create model configuration: {}", + e + )); + process::exit(1); + }); + match create(&fallback_provider, extensions_for_provider.clone()).await { + Ok(provider) => ( + provider, + fallback_provider, + fallback_model, + fallback_model_config, + ), + Err(e2) => { + output::render_error(&format!( + "Error {}.\n\ + Please check your system keychain and run 'goose configure' again.\n\ + If your system is unable to use the keyring, please try setting secret key(s) via environment variables.\n\ + For more info, see: https://goose-docs.ai/docs/troubleshooting/#keychainkeyring-errors", + e2 + )); + process::exit(1); + } + } + } + Err(e) => { + output::render_error(&format!( "Error {}.\n\ Please check your system keychain and run 'goose configure' again.\n\ If your system is unable to use the keyring, please try setting secret key(s) via environment variables.\n\ For more info, see: https://goose-docs.ai/docs/troubleshooting/#keychainkeyring-errors", e )); - process::exit(1); - } - }; - tracing::info!("๐Ÿค– Using model: {}", resolved.model_name); + 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)); @@ -605,6 +671,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { edit_mode, recipe.and_then(|r| r.retry.clone()), session_config.output_format.clone(), + session_config.stats, ) .await; @@ -613,17 +680,26 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { if !session_config.quiet { output::display_session_info( session_config.resume, - &resolved.provider_name, - &resolved.model_name, + &effective_provider_name, + &effective_model_name, &Some(session_id), ); } session } +fn is_provider_unavailable_error(e: &anyhow::Error) -> bool { + let msg = e.to_string(); + msg.contains("is not set") + || msg.contains("not configured") + || msg.contains("Configuration value not found") +} + #[cfg(test)] mod tests { use super::*; + use goose::session::SessionManager; + use tempfile::TempDir; #[test] fn test_session_builder_config_creation() { @@ -651,6 +727,7 @@ mod tests { quiet: false, output_format: "text".to_string(), container: None, + stats: false, }; assert_eq!(config.extensions.len(), 1); @@ -686,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 69bffe1a7d..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,22 +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", + 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 @@ -577,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/elicitation.rs b/crates/goose-cli/src/session/elicitation.rs index ef4930d76e..012295ff96 100644 --- a/crates/goose-cli/src/session/elicitation.rs +++ b/crates/goose-cli/src/session/elicitation.rs @@ -1,12 +1,15 @@ use console::style; +use rmcp::model::ElicitationAction; use serde_json::Value; use std::collections::HashMap; use std::io::{self, BufRead, IsTerminal, Write}; -pub fn collect_elicitation_input( - message: &str, - schema: &Value, -) -> io::Result>> { +pub struct ElicitationInput { + pub action: ElicitationAction, + pub user_data: HashMap, +} + +pub fn collect_elicitation_input(message: &str, schema: &Value) -> io::Result { if !message.is_empty() { println!("\n{}", style(message).cyan()); } @@ -24,9 +27,18 @@ pub fn collect_elicitation_input( "Approve?" }; return match cliclack::confirm(prompt).initial_value(true).interact() { - Ok(true) => Ok(Some(HashMap::new())), - Ok(false) => Ok(None), - Err(e) if e.kind() == io::ErrorKind::Interrupted => Ok(None), + Ok(true) => Ok(ElicitationInput { + action: ElicitationAction::Accept, + user_data: HashMap::new(), + }), + Ok(false) => Ok(ElicitationInput { + action: ElicitationAction::Decline, + user_data: HashMap::new(), + }), + Err(e) if e.kind() == io::ErrorKind::Interrupted => Ok(ElicitationInput { + action: ElicitationAction::Cancel, + user_data: HashMap::new(), + }), Err(e) => Err(e), }; } @@ -65,7 +77,12 @@ pub fn collect_elicitation_input( Ok(v) => { data.insert(name.clone(), Value::Bool(v)); } - Err(e) if e.kind() == io::ErrorKind::Interrupted => return Ok(None), + Err(e) if e.kind() == io::ErrorKind::Interrupted => { + return Ok(ElicitationInput { + action: ElicitationAction::Cancel, + user_data: HashMap::new(), + }); + } Err(e) => return Err(e), } continue; @@ -93,7 +110,10 @@ pub fn collect_elicitation_input( // Handle Ctrl+C / EOF for cancellation if input.is_none() { - return Ok(None); + return Ok(ElicitationInput { + action: ElicitationAction::Cancel, + user_data: HashMap::new(), + }); } let input = input.unwrap(); @@ -114,12 +134,18 @@ pub fn collect_elicitation_input( "{}", style(format!("Required field '{}' is missing", name)).red() ); - return Ok(None); + return Ok(ElicitationInput { + action: ElicitationAction::Decline, + user_data: HashMap::new(), + }); } } println!(); - Ok(Some(data)) + Ok(ElicitationInput { + action: ElicitationAction::Accept, + user_data: data, + }) } fn read_line() -> io::Result> { diff --git a/crates/goose-cli/src/session/export.rs b/crates/goose-cli/src/session/export.rs index 9f6b509a8a..f4969e6803 100644 --- a/crates/goose-cli/src/session/export.rs +++ b/crates/goose-cli/src/session/export.rs @@ -353,7 +353,7 @@ pub fn message_to_markdown(message: &Message, export_all_content: bool) -> Strin message )); } - ActionRequiredData::ElicitationResponse { id, user_data } => { + ActionRequiredData::ElicitationResponse { id, user_data, .. } => { md.push_str(&format!( "**Action Required** (elicitation_response): {}\n```json\n{}\n```\n\n", id, diff --git a/crates/goose-cli/src/session/input.rs b/crates/goose-cli/src/session/input.rs index 729428b294..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,6 +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. +{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 [...]) @@ -435,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 @@ -534,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 cd7e63b740..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; @@ -13,6 +13,7 @@ use crate::session::task_execution_display::{ format_task_execution_notification, TASK_EXECUTION_NOTIFICATION_TYPE, }; use goose::conversation::Conversation; +use std::env; use std::io::Write; use std::str::FromStr; use tokio::signal::ctrl_c; @@ -27,6 +28,7 @@ use goose::permission::permission_confirmation::PrincipalType; use goose::permission::Permission; use goose::permission::PermissionConfirmation; use goose::providers::base::Provider; +use goose::providers::base::ProviderUsage; use goose::utils::safe_truncate; use anyhow::{Context, Result}; @@ -37,8 +39,8 @@ use goose::agents::{Agent, SessionConfig, COMPACT_TRIGGERS}; use goose::config::extensions::name_to_key; use goose::config::{Config, GooseMode}; use input::InputResult; -use rmcp::model::PromptMessage; use rmcp::model::ServerNotification; +use rmcp::model::{ElicitationAction, PromptMessage}; use rmcp::model::{ErrorCode, ErrorData}; use strum::VariantNames; @@ -51,11 +53,13 @@ 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; +const GOOSE_PLANNER_CONTEXT_LIMIT: &str = "GOOSE_PLANNER_CONTEXT_LIMIT"; + #[derive(Serialize, Deserialize, Debug)] struct JsonOutput { messages: Vec, @@ -172,6 +176,7 @@ pub struct CliSession { edit_mode: Option, retry_config: Option, output_format: String, + stats: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -214,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") { @@ -250,6 +256,7 @@ impl CliSession { edit_mode: Option, retry_config: Option, output_format: String, + stats: bool, ) -> Self { let messages = agent .config @@ -271,6 +278,7 @@ impl CliSession { edit_mode, retry_config, output_format, + stats, } } @@ -312,6 +320,7 @@ impl CliSession { env_keys: Vec::new(), description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), + cwd: None, bundled: None, available_tools: Vec::new(), }) @@ -478,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 @@ -647,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)) => {} @@ -714,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?; } } @@ -802,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() { @@ -837,8 +847,11 @@ impl CliSession { let new_model_config = build_switched_model_config(¤t_provider_name, model_name, ¤t_model_config)?; + let configured_effort = Config::global().get_goose_thinking_effort(); + let new_effort = new_model_config.thinking_effort().or(configured_effort); + let current_effort = current_model_config.thinking_effort().or(configured_effort); if new_model_config.model_name == current_model_config.model_name - && new_model_config.thinking_effort() == current_model_config.thinking_effort() + && new_effort == current_effort { output::goose_mode_message(&format!( "Session already using model '{}' for provider '{}'", @@ -848,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; @@ -877,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<()> { @@ -898,9 +911,11 @@ impl CliSession { .config .session_manager .update(&self.session_id) - .total_tokens(Some(0)) - .input_tokens(Some(0)) - .output_tokens(Some(0)) + .usage(goose_providers::conversation::token_usage::Usage::new( + Some(0), + Some(0), + Some(0), + )) .apply() .await { @@ -1038,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?; @@ -1121,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) @@ -1176,6 +1187,9 @@ impl CliSession { let mut markdown_buffer = streaming_buffer::MarkdownBuffer::new(); let mut prompted_credits_urls: HashSet = HashSet::new(); let mut thinking_header_shown = false; + let run_started = Instant::now(); + let mut first_token_at: Option = None; + let mut last_usage: Option = None; use futures::StreamExt; loop { @@ -1183,6 +1197,9 @@ impl CliSession { result = stream.next() => { match result { Some(Ok(AgentEvent::Message(message))) => { + if first_token_at.is_none() && message_has_text(&message) { + first_token_at = Some(Instant::now()); + } if let Some((id, security_prompt)) = find_tool_confirmation(&message) { let permission = if interactive { prompt_tool_confirmation(&security_prompt)? @@ -1248,25 +1265,37 @@ impl CliSession { let _ = progress_bars.hide(); match elicitation::collect_elicitation_input(&elicitation_message, &schema) { - Ok(Some(user_data)) => { - let user_data_value = serde_json::to_value(user_data) + Ok(input) => { + match &input.action { + ElicitationAction::Decline => { + output::render_text("Information request declined.", Some(Color::Yellow), true); + } + ElicitationAction::Cancel => { + output::render_text("Information request cancelled.", Some(Color::Yellow), true); + } + ElicitationAction::Accept => {} + } + + let should_cancel = input.action == ElicitationAction::Cancel; + let action = input.action; + let user_data_value = serde_json::to_value(input.user_data) .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); let response_message = Message::user() .with_content(MessageContent::action_required_elicitation_response( elicitation_id, user_data_value, + action, )) .with_visibility(false, true); self.messages.push(response_message.clone()); // Elicitation responses return an empty stream - the response // unblocks the waiting tool call via ActionRequiredManager let _ = self.agent.reply(response_message, session_config.clone(), Some(cancel_token.clone())).await?; - } - Ok(None) => { - output::render_text("Information request cancelled.", Some(Color::Yellow), true); - cancel_token_clone.cancel(); - drop(stream); - break; + if should_cancel { + cancel_token_clone.cancel(); + drop(stream); + break; + } } Err(e) => { output::render_error(&format!("Failed to collect input: {}", e)); @@ -1294,6 +1323,10 @@ 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, @@ -1350,9 +1383,18 @@ impl CliSession { .await { Ok(session) => JsonMetadata { - total_tokens: session.accumulated_total_tokens.or(session.total_tokens), - input_tokens: session.accumulated_input_tokens.or(session.input_tokens), - output_tokens: session.accumulated_output_tokens.or(session.output_tokens), + total_tokens: session + .accumulated_usage + .total_tokens + .or(session.usage.total_tokens), + input_tokens: session + .accumulated_usage + .input_tokens + .or(session.usage.input_tokens), + output_tokens: session + .accumulated_usage + .output_tokens + .or(session.usage.output_tokens), status: "completed".to_string(), }, Err(_) => JsonMetadata { @@ -1377,9 +1419,9 @@ impl CliSession { .ok(); let (total_tokens, input_tokens, output_tokens) = match session { Some(s) => ( - s.accumulated_total_tokens.or(s.total_tokens), - s.accumulated_input_tokens.or(s.input_tokens), - s.accumulated_output_tokens.or(s.output_tokens), + s.accumulated_usage.total_tokens.or(s.usage.total_tokens), + s.accumulated_usage.input_tokens.or(s.usage.input_tokens), + s.accumulated_usage.output_tokens.or(s.usage.output_tokens), ), None => (None, None, None), }; @@ -1390,6 +1432,9 @@ impl CliSession { }); } else { println!(); + if self.stats { + print_run_stats(run_started, first_token_at, last_usage.as_ref()); + } } Ok(()) @@ -1544,14 +1589,20 @@ impl CliSession { pub async fn get_total_token_usage(&self) -> Result> { let metadata = self.get_session().await?; - Ok(metadata.accumulated_total_tokens) + Ok(metadata.accumulated_usage.total_tokens) } /// Display enhanced context usage with session totals pub async fn display_context_usage(&self) -> Result<()> { let provider = self.agent.provider().await?; - let model_config = 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 @@ -1564,18 +1615,15 @@ impl CliSession { match self.get_session().await { Ok(metadata) => { - let total_tokens = metadata.total_tokens.unwrap_or(0) as usize; + let total_tokens = metadata.usage.total_tokens.unwrap_or(0) as usize; output::display_context_usage(total_tokens, context_limit); if show_cost { - let input_tokens = metadata.input_tokens.unwrap_or(0) as usize; - let output_tokens = metadata.output_tokens.unwrap_or(0) as usize; output::display_cost_usage( &provider_name, &model_config.model_name, - input_tokens, - output_tokens, + &metadata.usage, ); } } @@ -1709,6 +1757,87 @@ impl CliSession { } } +fn message_has_text(message: &Message) -> bool { + message.content.iter().any( + |content| matches!(content, MessageContent::Text(text) if !text.text.trim().is_empty()), + ) +} + +fn print_run_stats( + run_started: Instant, + first_token_at: Option, + usage: Option<&ProviderUsage>, +) { + let elapsed = run_started.elapsed(); + let stats = usage.and_then(|usage| usage.stats.as_ref()); + let generation_elapsed = stats + .and_then(|stats| stats.elapsed_ms) + .map(Duration::from_millis); + let output_tokens = usage + .and_then(|usage| usage.usage.output_tokens) + .and_then(|tokens| usize::try_from(tokens).ok()) + .or_else(|| stats.and_then(|stats| stats.output_tokens)); + let tokens_per_second = output_tokens.map(|tokens| { + let rate_elapsed = generation_elapsed.unwrap_or(elapsed); + if rate_elapsed.as_secs_f64() > 0.0 { + tokens as f64 / rate_elapsed.as_secs_f64() + } else { + 0.0 + } + }); + let model_load_ms = stats.and_then(|stats| stats.model_load_ms); + let generation_time_to_first_token_ms = stats.and_then(|stats| stats.time_to_first_token_ms); + + eprintln!("\nStats:"); + if let Some(ms) = model_load_ms { + eprintln!(" Model load: {:.2}s", ms as f64 / 1000.0); + } + if model_load_ms.is_some() { + match generation_time_to_first_token_ms { + Some(ms) => eprintln!( + " Generation time to first token: {:.2}s", + ms as f64 / 1000.0 + ), + None => eprintln!(" Generation time to first token: unavailable"), + } + match first_token_at { + Some(first) => eprintln!( + " End-to-end time to first token: {:.2}s", + first.duration_since(run_started).as_secs_f64() + ), + None => eprintln!(" End-to-end time to first token: unavailable"), + } + } else if let Some(ms) = generation_time_to_first_token_ms { + eprintln!(" Time to first token: {:.2}s", ms as f64 / 1000.0); + } else { + match first_token_at { + Some(first) => eprintln!( + " Time to first token: {:.2}s", + first.duration_since(run_started).as_secs_f64() + ), + None => eprintln!(" Time to first token: unavailable"), + } + } + match tokens_per_second { + Some(rate) => eprintln!(" Tokens/sec: {:.2}", rate), + None => eprintln!(" Tokens/sec: unavailable"), + } + if let Some(tokens) = output_tokens { + eprintln!(" Output tokens: {tokens}"); + } + + if let Some(draft) = stats.and_then(|stats| stats.draft.as_ref()) { + eprintln!(" Draft accept rate: {:.1}%", draft.accept_rate * 100.0); + eprintln!( + " Draft tokens: {} accepted: {} target verified: {} rounds: {}", + draft.draft_tokens, draft.accepted_tokens, draft.target_tokens, draft.rounds + ); + if let Some(model) = &draft.model { + eprintln!(" Draft model: {model}"); + } + } +} + fn maybe_open_credits_top_up_url( message: &Message, interactive: bool, @@ -2091,11 +2220,11 @@ fn handle_agent_error(e: &anyhow::Error, is_stream_json_mode: bool) { }); } - if e.downcast_ref::() + if e.downcast_ref::() .map(|provider_error| { matches!( provider_error, - goose::providers::errors::ProviderError::ContextLengthExceeded(_) + goose_providers::errors::ProviderError::ContextLengthExceeded(_) ) }) .unwrap_or(false) @@ -2115,8 +2244,8 @@ fn handle_agent_error(e: &anyhow::Error, is_stream_json_mode: bool) { } } -async fn get_reasoner() -> Result, anyhow::Error> { - use goose::model::ModelConfig; +async fn get_reasoner( +) -> Result<(Arc, goose_providers::model::ModelConfig), anyhow::Error> { use goose::providers::create; let config = Config::global(); @@ -2141,12 +2270,23 @@ async fn get_reasoner() -> Result, anyhow::Error> { .expect("No model configured. Run 'goose configure' first") }; - let model_config = - ModelConfig::new_with_context_env(model, &provider, Some("GOOSE_PLANNER_CONTEXT_LIMIT"))?; - let extensions = goose::config::extensions::get_enabled_extensions_with_config(config); - let reasoner = create(&provider, model_config, extensions).await?; + let planner_context_limit = match env::var(GOOSE_PLANNER_CONTEXT_LIMIT) + .ok() + .map(|v| v.parse::()) + { + Some(Ok(n)) if n >= 4096 => Some(n), + Some(Ok(_)) => anyhow::bail!("{} must be at least 4096", GOOSE_PLANNER_CONTEXT_LIMIT), + Some(Err(e)) => anyhow::bail!("{}: {}", GOOSE_PLANNER_CONTEXT_LIMIT, e), + None => None, + }; - Ok(reasoner) + let model_config = + 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, extensions).await?; + + Ok((reasoner, model_config)) } /// Format elapsed time duration @@ -2165,12 +2305,11 @@ fn format_elapsed_time(duration: std::time::Duration) -> String { fn build_switched_model_config( provider_name: &str, model_name: &str, - current_model_config: &goose::model::ModelConfig, -) -> Result { - goose::model::ModelConfig::new(model_name) + current_model_config: &goose_providers::model::ModelConfig, +) -> Result { + goose::model_config::model_config_from_user_config(provider_name, model_name) .map(|config| { config - .with_canonical_limits(provider_name) .with_temperature(current_model_config.temperature) .with_toolshim(current_model_config.toolshim) .with_toolshim_model(current_model_config.toolshim_model.clone()) @@ -2263,6 +2402,7 @@ mod tests { env_keys: vec![], description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), + cwd: None, bundled: None, available_tools: vec![], } @@ -2278,6 +2418,7 @@ mod tests { env_keys: vec![], description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), + cwd: None, bundled: None, available_tools: vec![], } @@ -2293,6 +2434,7 @@ mod tests { env_keys: vec![], description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), + cwd: None, bundled: None, available_tools: vec![], } @@ -2317,14 +2459,13 @@ mod tests { ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), ]); - let current_model_config = goose::model::ModelConfig { + let current_model_config = goose_providers::model::ModelConfig { model_name: "gpt-4o".to_string(), context_limit: Some(128_000), temperature: Some(0.25), max_tokens: Some(16_384), toolshim: true, toolshim_model: Some("qwen2.5-coder".to_string()), - fast_model_config: None, request_params: Some(HashMap::from([( "anthropic_beta".to_string(), serde_json::json!(["output-128k-2025-02-19"]), @@ -2334,7 +2475,7 @@ mod tests { let switched = build_switched_model_config("openai", "gpt-5.4", ¤t_model_config).unwrap(); - let expected = goose::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) @@ -2361,12 +2502,12 @@ mod tests { ("GOOSE_THINKING_EFFORT", None::<&str>), ]); - let current = - goose::model::ModelConfig::new_or_fail("gpt-5.4-high").with_canonical_limits("openai"); + 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!( current.thinking_effort(), - Some(goose::model::ThinkingEffort::High) + Some(goose_providers::thinking::ThinkingEffort::High) ); let switched = build_switched_model_config("openai", "gpt-5.4", ¤t).unwrap(); diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index b0c71e2b42..17648a0892 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -10,6 +10,7 @@ use goose::providers::canonical::maybe_get_canonical_model; #[cfg(target_os = "windows")] use goose::subprocess::SubprocessExt; use goose::utils::safe_truncate; +use goose_providers::conversation::token_usage::Usage; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use rmcp::model::{CallToolRequestParams, JsonObject, PromptArgument}; use serde_json::Value; @@ -247,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); } @@ -329,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); } @@ -1414,31 +1417,33 @@ pub fn display_context_usage(total_tokens: usize, context_limit: usize) { ); } -fn estimate_cost_usd( - provider: &str, - model: &str, - input_tokens: usize, - output_tokens: usize, -) -> Option { +fn estimate_cost_usd(provider: &str, model: &str, usage: &Usage) -> Option { let canonical_model = maybe_get_canonical_model(provider, model)?; - - let input_cost_per_token = canonical_model.cost.input? / 1_000_000.0; - let output_cost_per_token = canonical_model.cost.output? / 1_000_000.0; - - let input_cost = input_cost_per_token * input_tokens as f64; - let output_cost = output_cost_per_token * output_tokens as f64; - Some(input_cost + output_cost) + canonical_model.cost.estimate_cost(usage) } /// Display cost information, if price data is available. -pub fn display_cost_usage(provider: &str, model: &str, input_tokens: usize, output_tokens: usize) { - if let Some(cost) = estimate_cost_usd(provider, model, input_tokens, output_tokens) { +pub fn display_cost_usage(provider: &str, model: &str, usage: &Usage) { + if let Some(cost) = estimate_cost_usd(provider, model, usage) { use console::style; + let input_tokens = usage.input_tokens.unwrap_or(0); + let output_tokens = usage.output_tokens.unwrap_or(0); + let cache_read = usage.cache_read_input_tokens.unwrap_or(0); + let cache_write = usage.cache_write_input_tokens.unwrap_or(0); + + let cache_breakdown = match (cache_read, cache_write) { + (0, 0) => String::new(), + (read, 0) => format!(" ({} cache read)", read), + (0, write) => format!(" ({} cache write)", write), + (read, write) => format!(" ({} cache read, {} cache write)", read, write), + }; + eprintln!( - "Cost: {} USD ({} tokens: in {}, out {})", + "Cost: {} USD ({} tokens: in {}{}, out {})", style(format!("${:.4}", cost)).cyan(), input_tokens + output_tokens, input_tokens, + cache_breakdown, output_tokens ); } diff --git a/crates/goose-cli/src/session/streaming_buffer.rs b/crates/goose-cli/src/session/streaming_buffer.rs index 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-cli/src/session/thinking.rs b/crates/goose-cli/src/session/thinking.rs index 6b36c756bc..d4948b55c9 100644 --- a/crates/goose-cli/src/session/thinking.rs +++ b/crates/goose-cli/src/session/thinking.rs @@ -1,4 +1,4 @@ -use rand::seq::SliceRandom; +use rand::seq::IndexedRandom; /// Extended list of playful thinking messages including both goose and general AI actions const THINKING_MESSAGES: &[&str] = &[ @@ -215,6 +215,6 @@ const THINKING_MESSAGES: &[&str] = &[ /// Returns a random thinking message from the extended list pub fn get_random_thinking_message() -> &'static str { THINKING_MESSAGES - .choose(&mut rand::thread_rng()) + .choose(&mut rand::rng()) .unwrap_or(&THINKING_MESSAGES[0]) } 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 67% rename from crates/goose/src/providers/local_inference/backend.rs rename to crates/goose-local-inference/src/backend.rs index 0968aafcf1..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::errors::ProviderError; -use crate::providers::local_inference::local_model_registry::ModelSettings; -use crate::providers::utils::RequestLog; +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}; @@ -12,17 +12,22 @@ pub(super) trait BackendLoadedModel: Send { fn as_any_mut(&mut self) -> &mut dyn Any; } +#[cfg_attr(not(feature = "mlx"), allow(dead_code))] pub(super) struct LocalGenerationRequest<'a> { pub model_name: String, pub system: &'a str, pub messages: &'a [Message], pub tools: &'a [Tool], pub settings: &'a ModelSettings, + 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, pub tx: &'a StreamSender, - pub log: &'a mut RequestLog, + pub log: &'a mut Option>, } pub(super) trait LocalInferenceBackend: Send + Sync { 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-local-inference/src/hf_models.rs b/crates/goose-local-inference/src/hf_models.rs new file mode 100644 index 0000000000..a63d34a122 --- /dev/null +++ b/crates/goose-local-inference/src/hf_models.rs @@ -0,0 +1,2490 @@ +use anyhow::{bail, Result}; +use futures::StreamExt; +use hf_hub::progress::{DownloadEvent, FileStatus, ProgressEvent, ProgressHandler}; +use hf_hub::repository::{ModelInfo, RepoSibling}; +use hf_hub::{HFClient, HFRepository, RepoTypeModel}; + +use super::local_model_registry::{get_registry, model_id_from_repo, LocalModelStorage, ShardFile}; +use serde::{Deserialize, Serialize}; +use std::sync::{Arc, Mutex}; + +use crate::huggingface_auth; + +use utoipa::ToSchema; + +const HF_API_BASE: &str = "https://huggingface.co/api/models"; +const HF_DOWNLOAD_BASE: &str = "https://huggingface.co"; +const LLAMACPP_BACKEND_ID: &str = "llamacpp"; +const MLX_BACKEND_ID: &str = "mlx"; +const GGUF_FORMAT: &str = "gguf"; +const MLX_FORMAT: &str = "mlx-safetensors"; +const MLX_VARIANT_ID: &str = "default"; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct HfModelInfo { + pub repo_id: String, + pub author: String, + pub model_name: String, + pub downloads: u64, + pub gguf_files: Vec, + #[serde(default)] + pub variants: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct HfModelVariant { + pub variant_id: String, + pub label: String, + pub backend_id: String, + pub format: String, + pub model_id: String, + pub download_id: String, + pub size_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub filename: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub download_url: Option, + pub description: String, + pub quality_rank: u8, + #[serde(default)] + pub sharded: bool, + #[serde(default = "default_supported")] + pub supported: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub unsupported_reason: Option, +} + +fn default_supported() -> bool { + true +} + +/// A single downloadable GGUF file (used internally and for downloads). +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct HfGgufFile { + pub filename: String, + pub size_bytes: u64, + pub quantization: String, + pub download_url: String, +} + +/// A quantization variant โ€” groups sharded files into one logical entry. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct HfQuantVariant { + pub quantization: String, + pub size_bytes: u64, + pub filename: String, + pub download_url: String, + pub description: &'static str, + pub quality_rank: u8, + #[serde(default)] + pub sharded: bool, +} + +impl HfQuantVariant { + pub fn to_model_variant(&self, repo_id: &str) -> HfModelVariant { + let model_id = model_id_from_repo(repo_id, &self.quantization); + HfModelVariant { + variant_id: self.quantization.clone(), + label: self.quantization.clone(), + backend_id: LLAMACPP_BACKEND_ID.to_string(), + format: GGUF_FORMAT.to_string(), + model_id: model_id.clone(), + download_id: model_id, + size_bytes: self.size_bytes, + filename: Some(self.filename.clone()), + download_url: Some(self.download_url.clone()), + description: self.description.to_string(), + quality_rank: self.quality_rank, + sharded: self.sharded, + supported: true, + unsupported_reason: None, + } + } +} + +/// Result of resolving a model spec โ€” may contain multiple shard files. +#[derive(Debug, Clone)] +pub struct ResolvedModel { + pub files: Vec, + pub total_size: u64, + pub mmproj: Option, +} + +#[derive(Debug, Clone)] +pub enum ResolvedLocalModel { + Gguf { + repo_id: String, + quantization: String, + resolved: ResolvedModel, + local_paths: Vec, + mmproj_path: Option, + storage: LocalModelStorage, + }, + Mlx { + repo_id: String, + variant_id: String, + snapshot_path: std::path::PathBuf, + total_size: u64, + }, +} + +impl ResolvedLocalModel { + pub fn repo_id(&self) -> &str { + match self { + Self::Gguf { repo_id, .. } | Self::Mlx { repo_id, .. } => repo_id, + } + } + + pub fn variant_id(&self) -> &str { + match self { + Self::Gguf { quantization, .. } => quantization, + Self::Mlx { variant_id, .. } => variant_id, + } + } + + pub fn backend_id(&self) -> &'static str { + match self { + Self::Gguf { .. } => "llamacpp", + Self::Mlx { .. } => "mlx", + } + } + + pub fn model_id(&self) -> String { + match self { + Self::Gguf { + repo_id, + quantization, + .. + } => model_id_from_repo(repo_id, quantization), + Self::Mlx { repo_id, .. } => repo_id.clone(), + } + } + + pub fn total_size(&self) -> u64 { + match self { + Self::Gguf { resolved, .. } => resolved.total_size, + Self::Mlx { total_size, .. } => *total_size, + } + } + + pub fn storage(&self) -> LocalModelStorage { + match self { + Self::Gguf { storage, .. } => *storage, + Self::Mlx { .. } => LocalModelStorage::HuggingFaceCache, + } + } +} + +#[derive(Debug, Deserialize)] +struct HfApiModel { + id: Option, + author: Option, + downloads: Option, + siblings: Option>, +} + +#[derive(Debug, Deserialize)] +struct HfApiSibling { + rfilename: String, + #[serde(default)] + size: Option, +} + +struct QuantInfo { + description: &'static str, + quality_rank: u8, +} + +// quality_rank groups quants by bit-level so that all N-bit variants sort +// together. Within a group, higher rank = higher quality. +// +// 1-bit: 10โ€“19 4-bit: 40โ€“49 8-bit: 80โ€“89 +// 2-bit: 20โ€“29 5-bit: 50โ€“59 16-bit: 90โ€“94 +// 3-bit: 30โ€“39 6-bit: 60โ€“69 32-bit: 95โ€“99 +// +const QUANT_TABLE: &[(&str, &str, u8)] = &[ + // 1-bit + ("TQ1_0", "Tiny, ternary quantization", 10), + ("IQ1_S", "Extremely small, very low quality", 11), + ("IQ1_M", "Extremely small, very low quality", 12), + // 2-bit + ("IQ2_XXS", "Very small, low quality", 20), + ("IQ2_XS", "Very small, low quality", 21), + ("IQ2_S", "Very small, low quality", 22), + ("IQ2_M", "Very small, low quality", 23), + ("Q2_K", "Small, low quality", 24), + ("Q2_K_S", "Small, low quality", 24), + ("Q2_K_L", "Small, low quality", 25), + ("Q2_K_XL", "Small, low quality", 26), + // 3-bit + ("IQ3_XXS", "Very small, moderate quality loss", 30), + ("IQ3_XS", "Small, moderate quality loss", 31), + ("IQ3_S", "Small, moderate quality loss", 32), + ("IQ3_M", "Small, moderate quality loss", 33), + ("Q3_K_S", "Small, moderate quality loss", 34), + ("Q3_K_M", "Small, balanced quality/size", 35), + ("Q3_K_L", "Medium-small, decent quality", 36), + ("Q3_K_XL", "Medium-small, decent quality", 37), + // 4-bit + ("IQ4_XS", "Medium, good quality", 40), + ("IQ4_NL", "Medium, good quality", 41), + ("Q4_0", "Medium, good quality", 42), + ("Q4_1", "Medium, good quality", 43), + ("Q4_K_S", "Medium, good quality/size balance", 44), + ( + "Q4_K_M", + "Medium, recommended balance of quality and size", + 45, + ), + ("Q4_K_L", "Medium, good quality", 46), + ("Q4_K_XL", "Medium, good quality", 47), + ( + "MXFP4_MOE", + "Medium, mixed-precision 4-bit for MoE models", + 48, + ), + // 5-bit + ("Q5_0", "Medium-large, high quality", 50), + ("Q5_1", "Medium-large, high quality", 51), + ("Q5_K_S", "Medium-large, high quality", 52), + ("Q5_K_M", "Medium-large, very high quality", 53), + ("Q5_K_XL", "Medium-large, very high quality", 54), + // 6-bit + ("Q6_K", "Large, near-lossless quality", 60), + ("Q6_K_XL", "Large, near-lossless quality", 61), + // 8-bit + ("Q8_0", "Large, near-lossless quality", 80), + ("Q8_K_XL", "Large, near-lossless quality", 81), + // 16-bit + ("F16", "Full size, original quality (16-bit)", 90), + ("BF16", "Full size, original quality (bfloat16)", 91), + // 32-bit + ("F32", "Full size, original quality (32-bit)", 95), +]; + +fn quant_info(quant: &str) -> QuantInfo { + QUANT_TABLE + .iter() + .find(|(name, _, _)| *name == quant) + .map(|(_, description, quality_rank)| QuantInfo { + description, + quality_rank: *quality_rank, + }) + .unwrap_or(QuantInfo { + description: "", + quality_rank: 45, + }) +} + +pub fn parse_quantization_from_filename(filename: &str) -> String { + parse_quantization(filename) +} + +fn parse_quantization(filename: &str) -> String { + // Strip directory prefix (e.g. "Q5_K_M/Model-Q5_K_M-00001-of-00002.gguf") + let basename = filename.rsplit('/').next().unwrap_or(filename); + let stem = basename.trim_end_matches(".gguf"); + + // Strip shard suffix like "-00001-of-00004" + let stem = if let Some(pos) = stem.rfind("-of-") { + stem.get(..pos) + .and_then(|s| s.rsplit_once('-').map(|(prefix, _)| prefix)) + .unwrap_or(stem) + } else { + stem + }; + + // The quantization tag is typically the last hyphen-separated component + // that looks like a quant identifier (starts with Q, IQ, F, BF, TQ, MXFP, etc.) + // e.g. "Qwen3-Coder-Next-Q4_K_M" -> "Q4_K_M" + // "Model-UD-IQ1_M" -> "IQ1_M" + if let Some((_, candidate)) = stem.rsplit_once('-') { + if looks_like_quant(candidate) { + return candidate.to_string(); + } + } + + // Fallback: try dot separator (e.g. "model.Q4_K_M") + if let Some((_, candidate)) = stem.rsplit_once('.') { + if looks_like_quant(candidate) { + return candidate.to_string(); + } + } + + "unknown".to_string() +} + +fn quant_bits(quantization: &str) -> u8 { + let digits: String = quantization + .chars() + .skip_while(|c| !c.is_ascii_digit()) + .take_while(|c| c.is_ascii_digit()) + .collect(); + digits.parse().unwrap_or(0) +} + +fn mmproj_precision_preference(quantization: &str) -> u8 { + match quantization.to_uppercase().as_str() { + "BF16" => 3, + "F16" => 2, + "F32" => 1, + _ => 0, + } +} + +fn looks_like_quant(s: &str) -> bool { + let upper = s.to_uppercase(); + upper.starts_with("Q") + || upper.starts_with("IQ") + || upper.starts_with("TQ") + || upper.starts_with("MXFP") + || upper == "F16" + || upper == "F32" + || upper == "BF16" +} + +fn is_shard_file(filename: &str) -> bool { + // Matches patterns like "-00001-of-00003.gguf" + parse_shard_index(filename).is_some() +} + +/// Parse the shard index (1-based) from a filename like "model-BF16-00001-of-00002.gguf". +fn parse_shard_index(filename: &str) -> Option { + let basename = filename.rsplit('/').next().unwrap_or(filename); + let stem = basename.trim_end_matches(".gguf"); + let pos = stem.rfind("-of-")?; + let before = stem.get(..pos)?; + let idx_str = before.rsplit('-').next()?; + if !idx_str.is_empty() && idx_str.chars().all(|c| c.is_ascii_digit()) { + idx_str.parse().ok() + } else { + None + } +} + +/// Parse the total shard count from a filename like "model-BF16-00001-of-00002.gguf". +fn parse_shard_total(filename: &str) -> Option { + let basename = filename.rsplit('/').next().unwrap_or(filename); + let stem = basename.trim_end_matches(".gguf"); + let pos = stem.rfind("-of-")?; + let total_str = stem.get(pos + 4..)?; + total_str.parse().ok() +} + +fn build_download_url(repo_id: &str, filename: &str) -> String { + format!("{}/{}/resolve/main/{}", HF_DOWNLOAD_BASE, repo_id, filename) +} + +pub fn hf_authorization_header(token: Option<&str>) -> Option { + token + .filter(|token| !token.is_empty()) + .map(|token| format!("Bearer {}", token)) +} + +fn apply_hf_auth(request: reqwest::RequestBuilder, token: Option<&str>) -> reqwest::RequestBuilder { + if let Some(header) = hf_authorization_header(token) { + request.header("Authorization", header) + } else { + request + } +} + +async fn optional_hf_token( + token: impl std::future::Future>>, +) -> Option { + token.await.ok().flatten() +} + +fn parent_components(filename: &str) -> Vec<&str> { + filename.rsplit_once('/').map_or(Vec::new(), |(parent, _)| { + parent.split('/').filter(|part| !part.is_empty()).collect() + }) +} + +fn is_prefix(prefix: &[&str], parts: &[&str]) -> bool { + prefix.len() <= parts.len() && prefix.iter().zip(parts).all(|(a, b)| a == b) +} + +fn select_best_mmproj( + repo_id: &str, + siblings: &[HfApiSibling], + model_filename: &str, + model_quantization: &str, +) -> Option { + let model_dir = parent_components(model_filename); + let model_bits = quant_bits(model_quantization); + + siblings + .iter() + .filter(|s| { + let lowercase = s.rfilename.to_lowercase(); + lowercase.ends_with(".gguf") && lowercase.contains("mmproj") + }) + .filter_map(|s| { + let mmproj_dir = parent_components(&s.rfilename); + if !is_prefix(&mmproj_dir, &model_dir) { + return None; + } + + let quantization = parse_quantization(&s.rfilename); + let bits = quant_bits(&quantization); + let diff = bits.abs_diff(model_bits); + let proximity = u8::MAX - diff; + + Some(( + mmproj_dir.len(), + proximity, + mmproj_precision_preference(&quantization), + s, + quantization, + )) + }) + .max_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.cmp(&b.2)) + .then_with(|| b.3.rfilename.cmp(&a.3.rfilename)) + }) + .map(|(_, _, _, sibling, quantization)| HfGgufFile { + filename: sibling.rfilename.clone(), + size_bytes: sibling.size.unwrap_or(0), + quantization, + download_url: build_download_url(repo_id, &sibling.rfilename), + }) +} + +/// Derive the expected model filename stem from a repo_id. +/// e.g. "unsloth/gemma-4-26B-A4B-it-GGUF" โ†’ "gemma-4-26b-a4b-it" (lowercased) +fn model_stem_from_repo(repo_id: &str) -> String { + let repo_name = repo_id.rsplit('/').next().unwrap_or(repo_id); + let stem = repo_name + .strip_suffix("-GGUF") + .or_else(|| repo_name.strip_suffix("-gguf")) + .unwrap_or(repo_name); + stem.to_lowercase() +} + +/// Check whether a GGUF file belongs to the main model (vs auxiliary files like mmproj). +/// Matches files whose basename starts with the model stem derived from the repo name. +fn is_model_file(filename: &str, model_stem_lower: &str) -> bool { + let basename = filename.rsplit('/').next().unwrap_or(filename); + basename.to_lowercase().starts_with(model_stem_lower) +} + +/// Collect GGUF files into quantization variants. +/// Single-file quants use the file directly. +/// Sharded quants (multiple files for one quantization) aggregate sizes and use the +/// first shard filename as the representative โ€” the download path must handle all shards. +fn group_into_variants(repo_id: &str, files: Vec) -> Vec { + use std::collections::HashMap; + + let stem = model_stem_from_repo(repo_id); + + let gguf_files: Vec<_> = files + .into_iter() + .filter(|s| { + s.rfilename.ends_with(".gguf") + && is_model_file(&s.rfilename, &stem) + && parse_quantization(&s.rfilename) != "unknown" + }) + .collect(); + + // Separate single files from shards + let mut single_files: Vec<&HfApiSibling> = Vec::new(); + let mut shard_groups: HashMap> = HashMap::new(); + + for file in &gguf_files { + if is_shard_file(&file.rfilename) { + let quant = parse_quantization(&file.rfilename); + shard_groups.entry(quant).or_default().push(file); + } else { + single_files.push(file); + } + } + + let mut variants: Vec = Vec::new(); + let mut seen_quants: std::collections::HashSet = std::collections::HashSet::new(); + + // Add single-file variants + for s in single_files { + let quant = parse_quantization(&s.rfilename); + seen_quants.insert(quant.clone()); + let info = quant_info(&quant); + let download_url = build_download_url(repo_id, &s.rfilename); + variants.push(HfQuantVariant { + quantization: quant, + size_bytes: s.size.unwrap_or(0), + filename: s.rfilename.clone(), + download_url, + description: info.description, + quality_rank: info.quality_rank, + sharded: false, + }); + } + + // Add shard-only variants (quants that only exist as sharded files) + for (quant, mut shards) in shard_groups { + if seen_quants.contains(&quant) { + continue; + } + shards.sort_by(|a, b| a.rfilename.cmp(&b.rfilename)); + let total_size: u64 = shards.iter().map(|s| s.size.unwrap_or(0)).sum(); + let info = quant_info(&quant); + let first_filename = &shards[0].rfilename; + let download_url = build_download_url(repo_id, first_filename); + variants.push(HfQuantVariant { + quantization: quant, + size_bytes: total_size, + filename: first_filename.clone(), + download_url, + description: info.description, + quality_rank: info.quality_rank, + sharded: true, + }); + } + + // Sort descending by quality_rank, then by size descending as tiebreaker + variants.sort_by(|a, b| { + b.quality_rank + .cmp(&a.quality_rank) + .then_with(|| b.size_bytes.cmp(&a.size_bytes)) + }); + variants +} + +pub async fn search_local_models(query: &str, limit: usize) -> Result> { + let mut results = Vec::new(); + + if looks_like_repo_id(query) { + if let Some(model) = get_local_model_info_for_repo(query).await? { + results.push(model); + } + } else if let Some(model) = get_exact_name_local_model_info(query).await? { + results.push(model); + } + + let mut gguf_results = search_gguf_models(query, limit).await?; + for model in &mut gguf_results { + let gguf_variants = get_repo_gguf_variants(&model.repo_id) + .await + .unwrap_or_default(); + model.variants = gguf_variants + .iter() + .map(|variant| variant.to_model_variant(&model.repo_id)) + .collect(); + } + + results.extend(gguf_results); + append_optional_mlx_results(&mut results, search_mlx_models(query, limit).await, query); + dedupe_models(&mut results); + results.sort_by(|a, b| { + model_search_rank(query, a) + .cmp(&model_search_rank(query, b)) + .then_with(|| b.downloads.cmp(&a.downloads)) + }); + results.truncate(limit); + Ok(results) +} + +fn append_optional_mlx_results( + results: &mut Vec, + mlx_results: Result>, + query: &str, +) { + match mlx_results { + Ok(models) => results.extend(models), + Err(error) => tracing::warn!( + query, + error = %error, + "Failed to search MLX models; returning non-MLX results" + ), + } +} + +pub async fn search_gguf_models(query: &str, limit: usize) -> Result> { + let client = reqwest::Client::new(); + let token = optional_hf_token(huggingface_auth::resolve_token_async()).await; + let url = format!( + "{}?search={}&filter=gguf&sort=downloads&direction=-1&limit={}", + HF_API_BASE, query, limit + ); + + let response = apply_hf_auth(client.get(&url), token.as_deref()) + .header("User-Agent", "goose-ai-agent") + .send() + .await?; + + if !response.status().is_success() { + bail!("HuggingFace API returned status {}", response.status()); + } + + let models: Vec = response.json().await?; + + let results = models + .into_iter() + .filter_map(|m| { + let repo_id = m.id?; + let siblings = m.siblings.unwrap_or_default(); + + // The search endpoint may not include `siblings`; parse whatever + // is available. Files are fetched on-demand via `get_repo_gguf_variants`. + let gguf_files: Vec = siblings + .into_iter() + .filter(|s| s.rfilename.ends_with(".gguf")) + .map(|s| { + let quantization = parse_quantization(&s.rfilename); + let download_url = build_download_url(&repo_id, &s.rfilename); + HfGgufFile { + filename: s.rfilename, + size_bytes: s.size.unwrap_or(0), + quantization, + download_url, + } + }) + .collect(); + + let author = m + .author + .unwrap_or_else(|| repo_id.split('/').next().unwrap_or_default().to_string()); + let model_name = repo_id + .split('/') + .next_back() + .unwrap_or(&repo_id) + .to_string(); + + Some(HfModelInfo { + repo_id, + author, + model_name, + downloads: m.downloads.unwrap_or(0), + gguf_files, + variants: Vec::new(), + }) + }) + .collect(); + + Ok(results) +} + +/// Fetch GGUF files for a repo and return them grouped by quantization. +pub async fn get_repo_gguf_variants(repo_id: &str) -> Result> { + let client = reqwest::Client::new(); + let token = optional_hf_token(huggingface_auth::resolve_token_async()).await; + let url = format!("{}/{}?blobs=true", HF_API_BASE, repo_id); + + let response = apply_hf_auth(client.get(&url), token.as_deref()) + .header("User-Agent", "goose-ai-agent") + .send() + .await?; + + if !response.status().is_success() { + bail!( + "HuggingFace API returned status {} for repo {}", + response.status(), + repo_id + ); + } + + let model: HfApiModel = response.json().await?; + let siblings = model.siblings.unwrap_or_default(); + + Ok(group_into_variants(repo_id, siblings)) +} + +/// Fetch raw GGUF files (kept for resolve_model_spec). +pub async fn get_repo_gguf_files(repo_id: &str) -> Result> { + let client = reqwest::Client::new(); + let token = optional_hf_token(huggingface_auth::resolve_token_async()).await; + let url = format!("{}/{}?blobs=true", HF_API_BASE, repo_id); + + let response = apply_hf_auth(client.get(&url), token.as_deref()) + .header("User-Agent", "goose-ai-agent") + .send() + .await?; + + if !response.status().is_success() { + bail!( + "HuggingFace API returned status {} for repo {}", + response.status(), + repo_id + ); + } + + let model: HfApiModel = response.json().await?; + let siblings = model.siblings.unwrap_or_default(); + + let stem = model_stem_from_repo(repo_id); + + let files = siblings + .into_iter() + .filter(|s| s.rfilename.ends_with(".gguf")) + .filter(|s| !is_shard_file(&s.rfilename)) + .filter(|s| is_model_file(&s.rfilename, &stem)) + .map(|s| { + let quantization = parse_quantization(&s.rfilename); + let download_url = build_download_url(repo_id, &s.rfilename); + HfGgufFile { + filename: s.rfilename, + size_bytes: s.size.unwrap_or(0), + quantization, + download_url, + } + }) + .collect(); + + Ok(files) +} + +/// Parse a model spec like "bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M" into (repo_id, quantization). +pub fn parse_model_spec(spec: &str) -> Result<(String, String)> { + let (repo_id, quant) = spec.rsplit_once(':').ok_or_else(|| { + anyhow::anyhow!( + "Invalid model spec '{}': expected format 'user/repo:quantization'", + spec + ) + })?; + + if !repo_id.contains('/') { + bail!("Invalid repo_id '{}': expected format 'user/repo'", repo_id); + } + + if quant.is_empty() { + bail!( + "Invalid model spec '{}': expected format 'user/repo:quantization'", + spec + ); + } + + Ok((repo_id.to_string(), quant.to_string())) +} + +/// Resolve a model spec to all GGUF files for that quantization (handles shards). +pub async fn resolve_model_spec_full(spec: &str) -> Result<(String, ResolvedModel)> { + let (repo_id, quant) = parse_model_spec(spec)?; + + let client = reqwest::Client::new(); + let token = optional_hf_token(huggingface_auth::resolve_token_async()).await; + let url = format!("{}/{}?blobs=true", HF_API_BASE, repo_id); + let response = apply_hf_auth(client.get(&url), token.as_deref()) + .header("User-Agent", "goose-ai-agent") + .send() + .await?; + + if !response.status().is_success() { + bail!( + "HuggingFace API returned status {} for repo {}", + response.status(), + repo_id + ); + } + + let model: HfApiModel = response.json().await?; + let siblings = model.siblings.unwrap_or_default(); + let stem = model_stem_from_repo(&repo_id); + + // Collect all GGUF files matching the quantization + let matching: Vec<_> = siblings + .iter() + .filter(|s| { + s.rfilename.ends_with(".gguf") + && is_model_file(&s.rfilename, &stem) + && parse_quantization(&s.rfilename).eq_ignore_ascii_case(&quant) + }) + .collect(); + + if matching.is_empty() { + bail!( + "No GGUF file with quantization '{}' found in {}", + quant, + repo_id + ); + } + + // Separate single files from shards + let mut single_files: Vec<&HfApiSibling> = Vec::new(); + let mut shard_files: Vec<&HfApiSibling> = Vec::new(); + for &f in &matching { + if is_shard_file(&f.rfilename) { + shard_files.push(f); + } else { + single_files.push(f); + } + } + + // Prefer single file if available + if let Some(single) = single_files.first() { + let mmproj = select_best_mmproj(&repo_id, &siblings, &single.rfilename, &quant); + let file = HfGgufFile { + filename: single.rfilename.clone(), + size_bytes: single.size.unwrap_or(0), + quantization: quant, + download_url: build_download_url(&repo_id, &single.rfilename), + }; + let total_size = file.size_bytes; + return Ok(( + repo_id, + ResolvedModel { + files: vec![file], + total_size, + mmproj, + }, + )); + } + + // Use shards, sorted by filename so shard 1 is first + shard_files.sort_by(|a, b| a.rfilename.cmp(&b.rfilename)); + + // Validate shard set completeness: every file must parse to the same + // -of-N total, and indices must be contiguous 1..=N. + let expected_total = parse_shard_total(&shard_files[0].rfilename).ok_or_else(|| { + anyhow::anyhow!( + "Cannot parse shard total from '{}'", + shard_files[0].rfilename + ) + })?; + if shard_files.len() != expected_total as usize { + bail!( + "Incomplete shard set for '{}' in {}: found {} of {} shards", + quant, + repo_id, + shard_files.len(), + expected_total + ); + } + for (i, shard) in shard_files.iter().enumerate() { + let shard_total = parse_shard_total(&shard.rfilename); + if shard_total != Some(expected_total) { + bail!( + "Inconsistent shard totals for '{}' in {}: shard '{}' has total {:?}, expected {}", + quant, + repo_id, + shard.rfilename, + shard_total, + expected_total + ); + } + let idx = parse_shard_index(&shard.rfilename); + if idx != Some((i + 1) as u32) { + bail!( + "Non-contiguous shard set for '{}' in {}: expected shard {} but found {:?}", + quant, + repo_id, + i + 1, + idx + ); + } + } + + let files: Vec = shard_files + .iter() + .map(|s| HfGgufFile { + filename: s.rfilename.clone(), + size_bytes: s.size.unwrap_or(0), + quantization: quant.clone(), + download_url: build_download_url(&repo_id, &s.rfilename), + }) + .collect(); + let total_size: u64 = files.iter().map(|f| f.size_bytes).sum(); + + let mmproj = select_best_mmproj(&repo_id, &siblings, &files[0].filename, &quant); + + Ok(( + repo_id, + ResolvedModel { + files, + total_size, + mmproj, + }, + )) +} + +/// Resolve a model spec to a specific GGUF file from the repo. +pub async fn resolve_model_spec(spec: &str) -> Result<(String, HfGgufFile)> { + let (repo_id, resolved) = resolve_model_spec_full(spec).await?; + if resolved.files.len() > 1 { + bail!( + "Model '{}' is sharded ({} files) โ€” use resolve_model_spec_full instead", + spec, + resolved.files.len() + ); + } + Ok((repo_id, resolved.files.into_iter().next().unwrap())) +} + +/// Recommend which quantization variant to use based on available memory. +pub fn recommend_variant( + variants: &[HfQuantVariant], + available_memory_bytes: u64, +) -> Option { + // We need ~10-20% overhead beyond model size for inference context. + // Pick the highest-quality variant that fits. + let usable = (available_memory_bytes as f64 * 0.85) as u64; + + let mut best: Option = None; + for (i, v) in variants.iter().enumerate() { + if v.size_bytes <= usable { + match best { + Some(bi) if variants[bi].quality_rank < v.quality_rank => best = Some(i), + None => best = Some(i), + _ => {} + } + } + } + best +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_quantization() { + assert_eq!(parse_quantization("Model-Q4_K_M.gguf"), "Q4_K_M"); + assert_eq!(parse_quantization("Model-Q8_0.gguf"), "Q8_0"); + assert_eq!(parse_quantization("Model-IQ4_NL.gguf"), "IQ4_NL"); + assert_eq!(parse_quantization("Model-F16.gguf"), "F16"); + assert_eq!(parse_quantization("random-name.gguf"), "unknown"); + } + + #[test] + fn test_hf_authorization_header() { + assert_eq!( + hf_authorization_header(Some("hf_test")).as_deref(), + Some("Bearer hf_test") + ); + assert_eq!(hf_authorization_header(Some("")), None); + assert_eq!(hf_authorization_header(None), None); + } + + #[test] + fn test_parse_quantization_with_directory() { + assert_eq!( + parse_quantization("Q5_K_M/Model-Q5_K_M-00001-of-00002.gguf"), + "Q5_K_M" + ); + } + + #[test] + fn test_parse_quantization_extended_tags() { + assert_eq!(parse_quantization("Model-MXFP4_MOE.gguf"), "MXFP4_MOE"); + assert_eq!(parse_quantization("Model-UD-TQ1_0.gguf"), "TQ1_0"); + assert_eq!(parse_quantization("Model-Q2_K_L.gguf"), "Q2_K_L"); + assert_eq!(parse_quantization("Model-UD-Q4_K_XL.gguf"), "Q4_K_XL"); + assert_eq!(parse_quantization("Model-UD-IQ1_M.gguf"), "IQ1_M"); + } + + #[test] + fn test_is_shard_file() { + assert!(is_shard_file("Q5_K_M/Model-Q5_K_M-00001-of-00002.gguf")); + assert!(is_shard_file("Model-BF16-00003-of-00004.gguf")); + assert!(!is_shard_file("Model-Q4_K_M.gguf")); + } + + #[test] + fn test_parse_model_spec() { + let (repo, quant) = + parse_model_spec("bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M").unwrap(); + assert_eq!(repo, "bartowski/Llama-3.2-1B-Instruct-GGUF"); + assert_eq!(quant, "Q4_K_M"); + } + + #[test] + fn test_parse_model_spec_invalid() { + assert!(parse_model_spec("no-colon").is_err()); + assert!(parse_model_spec("noslash:Q4_K_M").is_err()); + assert!(parse_model_spec("owner/repo:").is_err()); + } + + #[test] + fn test_dedupe_models_merges_variants_for_same_repo() { + let repo_id = "mixed/repo".to_string(); + let gguf_variant = HfModelVariant { + variant_id: "Q4_K_M".to_string(), + label: "Q4_K_M".to_string(), + backend_id: LLAMACPP_BACKEND_ID.to_string(), + format: GGUF_FORMAT.to_string(), + model_id: "mixed/repo:Q4_K_M".to_string(), + download_id: "mixed/repo:Q4_K_M".to_string(), + size_bytes: 4, + filename: Some("model-Q4_K_M.gguf".to_string()), + download_url: Some("https://example.test/model-Q4_K_M.gguf".to_string()), + description: "Medium".to_string(), + quality_rank: 45, + sharded: false, + supported: true, + unsupported_reason: None, + }; + let mlx_variant = HfModelVariant { + variant_id: MLX_VARIANT_ID.to_string(), + label: "Default".to_string(), + backend_id: MLX_BACKEND_ID.to_string(), + format: MLX_FORMAT.to_string(), + model_id: repo_id.clone(), + download_id: repo_id.clone(), + size_bytes: 8, + filename: None, + download_url: None, + description: "MLX".to_string(), + quality_rank: 91, + sharded: true, + supported: true, + unsupported_reason: None, + }; + let mut models = vec![ + HfModelInfo { + repo_id: repo_id.clone(), + author: "mixed".to_string(), + model_name: "repo".to_string(), + downloads: 1, + gguf_files: vec![HfGgufFile { + filename: "model-Q4_K_M.gguf".to_string(), + size_bytes: 4, + quantization: "Q4_K_M".to_string(), + download_url: "https://example.test/model-Q4_K_M.gguf".to_string(), + }], + variants: vec![gguf_variant], + }, + HfModelInfo { + repo_id, + author: "mixed".to_string(), + model_name: "repo".to_string(), + downloads: 2, + gguf_files: Vec::new(), + variants: vec![mlx_variant], + }, + ]; + + dedupe_models(&mut models); + + assert_eq!(models.len(), 1); + assert_eq!(models[0].downloads, 2); + assert_eq!(models[0].gguf_files.len(), 1); + assert_eq!(models[0].variants.len(), 2); + assert!(models[0] + .variants + .iter() + .any(|variant| variant.backend_id == LLAMACPP_BACKEND_ID)); + assert!(models[0] + .variants + .iter() + .any(|variant| variant.backend_id == MLX_BACKEND_ID)); + } + + fn sibling(filename: &str) -> RepoSibling { + RepoSibling { + rfilename: filename.to_string(), + size: Some(1), + lfs: None, + } + } + + fn mlx_siblings(tokenizer_files: &[&str]) -> Vec { + let mut siblings = vec![sibling("config.json"), sibling("model.safetensors")]; + siblings.extend(tokenizer_files.iter().map(|filename| sibling(filename))); + siblings + } + + #[test] + fn mlx_compatible_repo_accepts_tokenizer_json() { + let config = Some(serde_json::json!({ "model_type": "llama" })); + + assert!(is_mlx_compatible_repo( + &config, + &mlx_siblings(&["tokenizer.json"]) + )); + } + + #[test] + fn mlx_compatible_repo_rejects_incomplete_tokenizer_files() { + let config = Some(serde_json::json!({ "model_type": "llama" })); + + for tokenizer_files in [ + vec!["tokenizer_config.json"], + vec!["tokenizer.model"], + vec!["tokenizer.tiktoken"], + vec!["vocab.json"], + vec!["merges.txt"], + vec!["vocab.json", "merges.txt"], + ] { + assert!( + !is_mlx_compatible_repo(&config, &mlx_siblings(&tokenizer_files)), + "{tokenizer_files:?}" + ); + } + } + + #[test] + fn mlx_download_filenames_include_fp8_snapshot_metadata() { + let siblings = [ + "config.json", + "configuration.json", + "generation_config.json", + "model.safetensors.index.json", + "layers-0.safetensors", + "outside.safetensors", + "tokenizer.json", + "tokenizer_config.json", + "chat_template.jinja", + "preprocessor_config.json", + "video_preprocessor_config.json", + "README.md", + ] + .into_iter() + .map(sibling) + .collect::>(); + + let filenames = mlx_download_filenames(&siblings) + .into_iter() + .collect::>(); + + for filename in [ + "configuration.json", + "chat_template.jinja", + "preprocessor_config.json", + "video_preprocessor_config.json", + ] { + assert!(filenames.contains(filename), "{filename}"); + } + assert!(!filenames.contains("README.md")); + } + + #[test] + fn mlx_download_size_uses_safetensors_metadata_when_sibling_sizes_are_missing() { + let info: ModelInfo = serde_json::from_value(serde_json::json!({ + "id": "owner/repo", + "safetensors": { + "parameters": { + "BF16": 10, + "F8_E4M3": 20 + }, + "total": 30 + } + })) + .unwrap(); + let siblings = vec![ + RepoSibling { + rfilename: "config.json".to_string(), + size: None, + lfs: None, + }, + RepoSibling { + rfilename: "model.safetensors".to_string(), + size: None, + lfs: None, + }, + ]; + + assert_eq!(mlx_download_size_bytes(&info, &siblings), 40); + } + + #[test] + fn mlx_variant_id_detects_fp8_repo_name() { + assert_eq!(mlx_variant_id("Qwen/Qwen3.6-35B-A3B-FP8", &None), "fp8"); + } + + fn test_model(repo_id: &str) -> HfModelInfo { + HfModelInfo { + repo_id: repo_id.to_string(), + author: repo_id.split_once('/').unwrap().0.to_string(), + model_name: repo_id.split_once('/').unwrap().1.to_string(), + downloads: 1, + gguf_files: Vec::new(), + variants: Vec::new(), + } + } + + #[test] + fn append_optional_mlx_results_extends_on_success() { + let mut results = vec![test_model("gguf/repo")]; + + append_optional_mlx_results( + &mut results, + Ok(vec![test_model("mlx/repo")]), + "search-query", + ); + + assert_eq!(results.len(), 2); + assert!(results.iter().any(|model| model.repo_id == "gguf/repo")); + assert!(results.iter().any(|model| model.repo_id == "mlx/repo")); + } + + #[test] + fn append_optional_mlx_results_preserves_existing_on_error() { + let mut results = vec![test_model("gguf/repo")]; + + append_optional_mlx_results( + &mut results, + Err(anyhow::anyhow!("hf-hub unavailable")), + "search-query", + ); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].repo_id, "gguf/repo"); + } + + #[test] + fn best_download_count_ignores_zero_primary() { + assert_eq!(best_download_count(Some(0), Some(42)), Some(42)); + assert_eq!(best_download_count(Some(7), Some(42)), Some(7)); + assert_eq!(best_download_count(Some(0), Some(0)), None); + } + + #[test] + fn test_recommend_variant() { + let variants = vec![ + HfQuantVariant { + quantization: "Q2_K".into(), + size_bytes: 2_000_000_000, + filename: "m-Q2_K.gguf".into(), + download_url: String::new(), + description: "Small", + quality_rank: 24, + sharded: false, + }, + HfQuantVariant { + quantization: "Q4_K_M".into(), + size_bytes: 4_000_000_000, + filename: "m-Q4_K_M.gguf".into(), + download_url: String::new(), + description: "Medium", + quality_rank: 45, + sharded: false, + }, + HfQuantVariant { + quantization: "Q8_0".into(), + size_bytes: 8_000_000_000, + filename: "m-Q8_0.gguf".into(), + download_url: String::new(), + description: "Large", + quality_rank: 80, + sharded: false, + }, + ]; + + assert_eq!(recommend_variant(&variants, 5_000_000_000), Some(1)); + assert_eq!(recommend_variant(&variants, 10_000_000_000), Some(2)); + assert_eq!(recommend_variant(&variants, 1_000_000_000), None); + } + + #[test] + fn test_model_stem_from_repo() { + assert_eq!( + model_stem_from_repo("unsloth/gemma-4-26B-A4B-it-GGUF"), + "gemma-4-26b-a4b-it" + ); + assert_eq!( + model_stem_from_repo("bartowski/Llama-3.2-3B-Instruct-GGUF"), + "llama-3.2-3b-instruct" + ); + assert_eq!(model_stem_from_repo("someone/SomeModel"), "somemodel"); + } + + #[test] + fn hf_download_progress_init_preserves_cancelled_reservation() { + let model_id = "test-cancelled-hf-progress-init"; + let download_id = format!("{}-model", model_id); + let manager = crate::download_manager::get_download_manager(); + manager.set_progress(crate::download_manager::DownloadProgress { + model_id: download_id.clone(), + status: crate::download_manager::DownloadStatus::Cancelled, + bytes_downloaded: 0, + total_bytes: 0, + progress_percent: 0.0, + speed_bps: None, + eta_seconds: None, + error: None, + task_exited: false, + }); + + HfDownloadProgress::new(model_id.to_string(), 42).init(); + + let progress = manager.get_progress(&download_id).expect("progress"); + assert_eq!( + progress.status, + crate::download_manager::DownloadStatus::Cancelled + ); + assert!(!progress.task_exited); + + manager.update_progress(&download_id, |progress| { + progress.task_exited = true; + }); + manager.clear_completed(&download_id); + } + + #[test] + fn test_is_model_file() { + let stem = "gemma-3-27b-it"; + assert!(is_model_file("gemma-3-27b-it-Q4_K_M.gguf", stem)); + assert!(is_model_file( + "BF16/gemma-3-27b-it-BF16-00001-of-00002.gguf", + stem + )); + assert!(!is_model_file("mmproj-BF16.gguf", stem)); + assert!(!is_model_file("vision-encoder-Q4_K_M.gguf", stem)); + } + + #[test] + fn test_group_into_variants_filters_auxiliary_files() { + let files = vec![ + HfApiSibling { + rfilename: "gemma-3-27b-it-Q4_K_M.gguf".into(), + size: Some(4_000_000_000), + }, + HfApiSibling { + rfilename: "mmproj-BF16.gguf".into(), + size: Some(800_000_000), + }, + ]; + let variants = group_into_variants("unsloth/gemma-3-27b-it-GGUF", files); + assert_eq!(variants.len(), 1); + assert_eq!(variants[0].quantization, "Q4_K_M"); + } + + #[test] + fn test_group_into_variants_includes_shard_only_quants() { + let files = vec![ + HfApiSibling { + rfilename: "BF16/gemma-3-27b-it-BF16-00001-of-00002.gguf".into(), + size: Some(40_000_000_000), + }, + HfApiSibling { + rfilename: "BF16/gemma-3-27b-it-BF16-00002-of-00002.gguf".into(), + size: Some(10_000_000_000), + }, + HfApiSibling { + rfilename: "gemma-3-27b-it-Q4_K_M.gguf".into(), + size: Some(4_000_000_000), + }, + ]; + let variants = group_into_variants("unsloth/gemma-3-27b-it-GGUF", files); + assert_eq!(variants.len(), 2); + // Sorted descending by quality_rank: BF16 (91) > Q4_K_M (45) + assert_eq!(variants[0].quantization, "BF16"); + assert!(variants[0].sharded); + assert_eq!(variants[0].size_bytes, 50_000_000_000); + assert_eq!(variants[1].quantization, "Q4_K_M"); + assert!(!variants[1].sharded); + } + + #[test] + fn test_group_into_variants_sorted_descending() { + let files = vec![ + HfApiSibling { + rfilename: "Model-IQ1_S.gguf".into(), + size: Some(500_000_000), + }, + HfApiSibling { + rfilename: "Model-Q4_K_M.gguf".into(), + size: Some(4_000_000_000), + }, + HfApiSibling { + rfilename: "Model-Q8_0.gguf".into(), + size: Some(8_000_000_000), + }, + ]; + let variants = group_into_variants("someone/Model-GGUF", files); + assert_eq!(variants.len(), 3); + assert_eq!(variants[0].quantization, "Q8_0"); + assert_eq!(variants[1].quantization, "Q4_K_M"); + assert_eq!(variants[2].quantization, "IQ1_S"); + } + + #[test] + fn test_select_best_mmproj_prefers_closest_precision() { + let files = vec![ + HfApiSibling { + rfilename: "mmproj-F32.gguf".into(), + size: Some(3_000), + }, + HfApiSibling { + rfilename: "mmproj-BF16.gguf".into(), + size: Some(2_000), + }, + ]; + + let mmproj = + select_best_mmproj("someone/model-GGUF", &files, "model-Q4_K_M.gguf", "Q4_K_M") + .unwrap(); + + assert_eq!(mmproj.filename, "mmproj-BF16.gguf"); + assert_eq!(mmproj.quantization, "BF16"); + } + + #[tokio::test] + async fn optional_hf_token_returns_resolved_token() { + let token = optional_hf_token(async { Ok(Some("token".to_string())) }).await; + + assert_eq!(token.as_deref(), Some("token")); + } + + #[tokio::test] + async fn optional_hf_token_ignores_resolution_errors() { + let token = + optional_hf_token(async { Err(anyhow::anyhow!("refresh token revoked")) }).await; + + assert_eq!(token, None); + } + + #[test] + fn test_select_best_mmproj_prefers_bf16_over_f16_tie() { + let files = vec![ + HfApiSibling { + rfilename: "mmproj-F16.gguf".into(), + size: Some(2_000), + }, + HfApiSibling { + rfilename: "mmproj-BF16.gguf".into(), + size: Some(2_000), + }, + ]; + + let mmproj = + select_best_mmproj("someone/model-GGUF", &files, "model-Q8_0.gguf", "Q8_0").unwrap(); + + assert_eq!(mmproj.filename, "mmproj-BF16.gguf"); + } + + #[test] + fn test_select_best_mmproj_prefers_nearest_directory() { + let files = vec![ + HfApiSibling { + rfilename: "mmproj-BF16.gguf".into(), + size: Some(2_000), + }, + HfApiSibling { + rfilename: "Q4_K_M/mmproj-F32.gguf".into(), + size: Some(3_000), + }, + ]; + + let mmproj = select_best_mmproj( + "someone/model-GGUF", + &files, + "Q4_K_M/model-Q4_K_M.gguf", + "Q4_K_M", + ) + .unwrap(); + + assert_eq!(mmproj.filename, "Q4_K_M/mmproj-F32.gguf"); + } + + #[test] + fn test_select_best_mmproj_ignores_sibling_directories() { + let files = vec![ + HfApiSibling { + rfilename: "Q8_0/mmproj-BF16.gguf".into(), + size: Some(2_000), + }, + HfApiSibling { + rfilename: "mmproj-F32.gguf".into(), + size: Some(3_000), + }, + ]; + + let mmproj = select_best_mmproj( + "someone/model-GGUF", + &files, + "Q4_K_M/model-Q4_K_M.gguf", + "Q4_K_M", + ) + .unwrap(); + + assert_eq!(mmproj.filename, "mmproj-F32.gguf"); + } +} + +async fn hf_client() -> Result { + let mut builder = HFClient::builder().user_agent("goose-ai-agent"); + if let Some(token) = optional_hf_token(huggingface_auth::resolve_token_async()).await { + builder = builder.token(token); + } + builder.build().map_err(Into::into) +} + +fn model_repo(client: &HFClient, repo_id: &str) -> Result> { + let (owner, name) = split_repo_id(repo_id)?; + Ok(client.model(owner, name)) +} + +fn split_repo_id(repo_id: &str) -> Result<(&str, &str)> { + repo_id + .split_once('/') + .ok_or_else(|| anyhow::anyhow!("Invalid repo id '{}': expected owner/name", repo_id)) +} + +async fn search_mlx_models(query: &str, limit: usize) -> Result> { + let mut results = search_mlx_models_with_query(query, limit).await?; + if !query.contains('/') { + results + .extend(search_mlx_models_with_query(&format!("mlx-community/{query}"), limit).await?); + results.extend(search_mlx_models_with_query(&format!("google/{query}"), limit).await?); + } + dedupe_models(&mut results); + results.truncate(limit); + Ok(results) +} + +async fn search_mlx_models_with_query(query: &str, limit: usize) -> Result> { + let client = hf_client().await?; + let stream = client + .list_models() + .search(query.to_string()) + .sort("downloads".to_string()) + .limit(limit.saturating_mul(5).max(limit)) + .send()?; + futures::pin_mut!(stream); + + let mut results = Vec::new(); + while let Some(info) = stream.next().await { + let info = info?; + if results.len() >= limit { + break; + } + if let Some(model) = get_local_model_info_for_repo_with_client_and_downloads( + &client, + &info.id, + info.downloads, + ) + .await? + { + results.push(model); + } + } + + Ok(results) +} + +async fn get_local_model_info_for_repo(repo_id: &str) -> Result> { + let client = hf_client().await?; + get_local_model_info_for_repo_with_client(&client, repo_id).await +} + +async fn get_local_model_info_for_repo_with_client( + client: &HFClient, + repo_id: &str, +) -> Result> { + get_local_model_info_for_repo_with_client_and_downloads(client, repo_id, None).await +} + +async fn get_local_model_info_for_repo_with_client_and_downloads( + client: &HFClient, + repo_id: &str, + downloads_hint: Option, +) -> Result> { + let repo = model_repo(client, repo_id)?; + let info = repo + .info() + .expand(vec![ + "siblings".to_string(), + "config".to_string(), + "safetensors".to_string(), + ]) + .send() + .await?; + model_info_to_local_model_info(&repo, info, downloads_hint).await +} + +async fn get_exact_name_local_model_info(model_name: &str) -> Result> { + for owner in ["google", "mlx-community"] { + let repo_id = format!("{owner}/{model_name}"); + if let Ok(Some(model)) = get_local_model_info_for_repo(&repo_id).await { + return Ok(Some(model)); + } + } + Ok(None) +} + +async fn model_info_to_local_model_info( + repo: &HFRepository, + info: ModelInfo, + downloads_hint: Option, +) -> Result> { + let repo_id = info.id.clone(); + let mut variants: Vec = get_repo_gguf_variants(&repo_id) + .await + .unwrap_or_default() + .iter() + .map(|variant| variant.to_model_variant(&repo_id)) + .collect(); + if is_mlx_compatible_model_info(&info) { + let mlx_config = load_repo_config_json(repo).await.unwrap_or_else(|error| { + tracing::debug!(repo_id, %error, "Failed to load MLX config.json; falling back to API config"); + info.config.clone() + }); + variants.extend(mlx_variants_from_model_info(&repo_id, &info, &mlx_config)); + } + + if variants.is_empty() { + return Ok(None); + } + + let author = info + .author + .unwrap_or_else(|| repo_id.split('/').next().unwrap_or_default().to_string()); + let model_name = repo_id + .split('/') + .next_back() + .unwrap_or(&repo_id) + .to_string(); + let downloads = match best_download_count(info.downloads, downloads_hint) { + Some(downloads) => downloads, + None => get_repo_downloads(&repo_id).await?.unwrap_or(0), + }; + + Ok(Some(HfModelInfo { + repo_id, + author, + model_name, + downloads, + gguf_files: Vec::new(), + variants, + })) +} + +fn best_download_count(primary: Option, hint: Option) -> Option { + primary + .filter(|downloads| *downloads > 0) + .or_else(|| hint.filter(|downloads| *downloads > 0)) +} + +async fn get_repo_downloads(repo_id: &str) -> Result> { + let client = reqwest::Client::new(); + let token = optional_hf_token(huggingface_auth::resolve_token_async()).await; + let url = format!("{}/{}", HF_API_BASE, repo_id); + + let response = apply_hf_auth(client.get(&url), token.as_deref()) + .header("User-Agent", "goose-ai-agent") + .send() + .await?; + + if !response.status().is_success() { + bail!( + "HuggingFace API returned status {} for repo {}", + response.status(), + repo_id + ); + } + + let model: HfApiModel = response.json().await?; + Ok(model.downloads) +} + +pub async fn get_repo_local_variants(repo_id: &str) -> Result> { + let mut variants: Vec = get_repo_gguf_variants(repo_id) + .await + .unwrap_or_default() + .iter() + .map(|variant| variant.to_model_variant(repo_id)) + .collect(); + variants.extend(get_repo_mlx_variants(repo_id).await.unwrap_or_default()); + variants.sort_by(|a, b| { + a.backend_id + .cmp(&b.backend_id) + .then_with(|| b.quality_rank.cmp(&a.quality_rank)) + .then_with(|| a.variant_id.cmp(&b.variant_id)) + }); + Ok(variants) +} + +pub async fn get_repo_mlx_variants(repo_id: &str) -> Result> { + let client = hf_client().await?; + let repo = model_repo(&client, repo_id)?; + let info = repo + .info() + .expand(vec![ + "siblings".to_string(), + "config".to_string(), + "safetensors".to_string(), + ]) + .send() + .await?; + if !is_mlx_compatible_model_info(&info) { + return Ok(Vec::new()); + } + let mlx_config = load_repo_config_json(&repo) + .await + .unwrap_or_else(|_| info.config.clone()); + Ok(mlx_variants_from_model_info(repo_id, &info, &mlx_config)) +} + +async fn load_repo_config_json( + repo: &HFRepository, +) -> Result> { + let config_path = repo + .download_file() + .filename("config.json".to_string()) + .send() + .await?; + let config_json = tokio::fs::read_to_string(config_path).await?; + Ok(Some(serde_json::from_str(&config_json)?)) +} + +fn mlx_variants_from_model_info( + repo_id: &str, + info: &ModelInfo, + mlx_config: &Option, +) -> Vec { + let siblings = info.siblings.as_deref().unwrap_or(&[]); + + if !is_mlx_compatible_repo(&info.config, siblings) { + return Vec::new(); + } + + let size_bytes = mlx_download_size_bytes(info, siblings); + let variant_id = mlx_variant_id(repo_id, &info.config); + + vec![HfModelVariant { + variant_id: variant_id.clone(), + label: mlx_variant_label(&variant_id), + backend_id: MLX_BACKEND_ID.to_string(), + format: MLX_FORMAT.to_string(), + model_id: repo_id.to_string(), + download_id: repo_id.to_string(), + size_bytes, + filename: None, + download_url: None, + description: mlx_variant_description(mlx_config), + quality_rank: 91, + sharded: siblings + .iter() + .filter(|s| s.rfilename.ends_with(".safetensors")) + .count() + > 1, + supported: is_mlx_runtime_supported(mlx_config) + && cfg!(target_os = "macos") + && cfg!(feature = "mlx"), + unsupported_reason: mlx_unsupported_reason(mlx_config), + }] +} + +fn is_mlx_compatible_model_info(info: &ModelInfo) -> bool { + is_mlx_compatible_repo(&info.config, info.siblings.as_deref().unwrap_or_default()) +} + +fn is_mlx_compatible_repo(config: &Option, siblings: &[RepoSibling]) -> bool { + let has_config = siblings.iter().any(|s| s.rfilename == "config.json"); + let has_tokenizer = has_mlx_tokenizer(siblings); + let has_safetensors = siblings + .iter() + .any(|s| s.rfilename.ends_with(".safetensors")); + + has_config && has_tokenizer && has_safetensors && mlx_model_type(config).is_some() +} + +fn mlx_download_size_bytes(info: &ModelInfo, siblings: &[RepoSibling]) -> u64 { + let sibling_size: u64 = mlx_download_filenames(siblings) + .into_iter() + .filter_map(|filename| { + siblings + .iter() + .find(|s| s.rfilename == filename) + .and_then(|s| s.size) + }) + .sum(); + sibling_size.max(estimated_safetensors_size_bytes(info)) +} + +fn estimated_safetensors_size_bytes(info: &ModelInfo) -> u64 { + info.safetensors + .as_ref() + .map(|safetensors| { + safetensors + .parameters + .iter() + .map(|(dtype, count)| count.saturating_mul(dtype_size_bytes(dtype))) + .sum() + }) + .unwrap_or(0) +} + +fn dtype_size_bytes(dtype: &str) -> u64 { + match dtype.to_ascii_uppercase().as_str() { + "BOOL" | "I8" | "U8" | "F8_E4M3" | "F8_E4M3FN" | "F8_E5M2" | "F8_E5M2FNUZ" => 1, + "BF16" | "F16" | "I16" | "U16" => 2, + "F32" | "I32" | "U32" => 4, + "F64" | "I64" | "U64" => 8, + _ => 0, + } +} + +fn has_mlx_tokenizer(siblings: &[RepoSibling]) -> bool { + siblings + .iter() + .any(|s| is_standalone_mlx_tokenizer_file(&s.rfilename)) +} + +fn is_standalone_mlx_tokenizer_file(filename: &str) -> bool { + filename == "tokenizer.json" +} + +fn mlx_model_type(config: &Option) -> Option<&str> { + config + .as_ref() + .and_then(|config| config.get("model_type")) + .and_then(|value| value.as_str()) +} + +fn is_mlx_runtime_supported(config: &Option) -> bool { + mlx_config_support(config).is_none() +} + +fn mlx_unsupported_reason(config: &Option) -> Option { + if !cfg!(target_os = "macos") { + return Some("MLX requires macOS".to_string()); + } + if !cfg!(feature = "mlx") { + return Some("MLX support was not compiled in".to_string()); + } + + mlx_config_support(config) +} + +fn mlx_config_support(config: &Option) -> Option { + let config = config.as_ref()?; + mlx_config_support_for_value(config) +} + +#[cfg(feature = "mlx")] +fn mlx_config_support_for_value(config: &serde_json::Value) -> Option { + safemlx_lm::check_model_config(config) + .unsupported_reason() + .map(str::to_string) +} + +#[cfg(not(feature = "mlx"))] +fn mlx_config_support_for_value(_config: &serde_json::Value) -> Option { + None +} + +fn mlx_variant_description(config: &Option) -> String { + match mlx_unsupported_reason(config) { + None => "MLX safetensors snapshot".to_string(), + Some(reason) => format!("MLX safetensors snapshot ({reason})"), + } +} + +fn mlx_download_filenames(siblings: &[RepoSibling]) -> Vec { + siblings + .iter() + .filter(|s| should_download_for_mlx(&s.rfilename)) + .map(|s| s.rfilename.clone()) + .collect() +} + +fn looks_like_repo_id(query: &str) -> bool { + let Some((owner, repo)) = query.split_once('/') else { + return false; + }; + !owner.is_empty() && !repo.is_empty() && !repo.contains('/') +} + +fn model_search_rank(query: &str, model: &HfModelInfo) -> u8 { + let query = query.to_lowercase(); + let repo_id = model.repo_id.to_lowercase(); + let model_name = model.model_name.to_lowercase(); + + if repo_id == query { + 0 + } else if model_name == query { + 1 + } else if repo_id.ends_with(&format!("/{query}")) { + 2 + } else if repo_id.contains(&query) { + 3 + } else { + 4 + } +} + +fn dedupe_models(models: &mut Vec) { + let mut merged: Vec = Vec::with_capacity(models.len()); + for model in std::mem::take(models) { + if let Some(existing) = merged + .iter_mut() + .find(|existing| existing.repo_id == model.repo_id) + { + merge_model_info(existing, model); + } else { + merged.push(model); + } + } + *models = merged; +} + +fn merge_model_info(existing: &mut HfModelInfo, duplicate: HfModelInfo) { + existing.downloads = existing.downloads.max(duplicate.downloads); + + let mut filenames: std::collections::HashSet = existing + .gguf_files + .iter() + .map(|file| file.filename.clone()) + .collect(); + existing.gguf_files.extend( + duplicate + .gguf_files + .into_iter() + .filter(|file| filenames.insert(file.filename.clone())), + ); + + let mut variant_keys: std::collections::HashSet<(String, String)> = existing + .variants + .iter() + .map(|variant| (variant.backend_id.clone(), variant.variant_id.clone())) + .collect(); + existing + .variants + .extend(duplicate.variants.into_iter().filter(|variant| { + variant_keys.insert((variant.backend_id.clone(), variant.variant_id.clone())) + })); +} + +fn should_download_for_mlx(filename: &str) -> bool { + filename.ends_with(".safetensors") + || filename == "config.json" + || is_standalone_mlx_tokenizer_file(filename) + || filename == "tokenizer_config.json" + || filename == "generation_config.json" + || filename == "configuration.json" + || filename == "chat_template.jinja" + || filename == "preprocessor_config.json" + || filename == "video_preprocessor_config.json" + || filename == "special_tokens_map.json" + || filename == "model.safetensors.index.json" + || filename == "vocab.json" + || filename == "merges.txt" + || filename == "added_tokens.json" +} + +fn mlx_variant_id(repo_id: &str, config: &Option) -> String { + let repo_lower = repo_id.to_lowercase(); + for marker in ["bf16", "f16", "fp16", "f32", "fp32", "fp8", "4bit", "8bit"] { + if repo_lower.contains(marker) { + return marker.to_string(); + } + } + config + .as_ref() + .and_then(|config| config.get("torch_dtype")) + .and_then(|value| value.as_str()) + .map(|dtype| dtype.replace("float", "f")) + .unwrap_or_else(|| MLX_VARIANT_ID.to_string()) +} + +fn mlx_variant_label(variant_id: &str) -> String { + if variant_id == MLX_VARIANT_ID { + "MLX".to_string() + } else { + format!("MLX {}", variant_id.to_uppercase()) + } +} + +pub async fn resolve_local_model_selection( + repo_id: &str, + backend_id: &str, + variant_id: Option<&str>, +) -> Result { + match backend_id { + MLX_BACKEND_ID => resolve_mlx_model(repo_id, variant_id.unwrap_or(MLX_VARIANT_ID)).await, + LLAMACPP_BACKEND_ID => { + let quantization = variant_id.ok_or_else(|| { + anyhow::anyhow!("llama.cpp model '{}' is missing a quantization", repo_id) + })?; + resolve_gguf_model(repo_id, quantization).await + } + _ => bail!("Unknown local inference backend '{}'", backend_id), + } +} + +fn snapshot_root_for_file( + path: &std::path::Path, + repo_filename: &str, +) -> Option { + let mut root = path.to_path_buf(); + for _ in 0..repo_filename.split('/').count() { + root.pop(); + } + Some(root) +} + +async fn resolve_gguf_model(repo_id: &str, quantization: &str) -> Result { + let spec = format!("{}:{}", repo_id, quantization); + let (_repo, resolved) = resolve_model_spec_full(&spec).await?; + let (local_paths, mmproj_path) = + download_gguf_to_hf_cache(repo_id, quantization, &resolved).await?; + Ok(ResolvedLocalModel::Gguf { + repo_id: repo_id.to_string(), + quantization: quantization.to_string(), + resolved, + local_paths, + mmproj_path, + storage: LocalModelStorage::HuggingFaceCache, + }) +} + +async fn download_gguf_to_hf_cache( + repo_id: &str, + quantization: &str, + resolved: &ResolvedModel, +) -> Result<(Vec, Option)> { + let (owner, name) = split_repo_id(repo_id)?; + let model_id = model_id_from_repo(repo_id, quantization); + let total_size = resolved + .files + .iter() + .chain(resolved.mmproj.iter()) + .map(|file| file.size_bytes) + .sum(); + let progress = HfDownloadProgress::new(model_id, total_size); + progress.init(); + let client = hf_client().await?; + let repo = client.model(owner.to_string(), name.to_string()); + let mut paths = Vec::with_capacity(resolved.files.len()); + for file in &resolved.files { + let path = match repo + .download_file() + .filename(file.filename.clone()) + .progress(progress.clone()) + .send() + .await + .map_err(anyhow::Error::from) + { + Ok(path) => path, + Err(error) => { + progress.fail(&error); + return Err(error); + } + }; + progress.finish_file(file.size_bytes); + paths.push(path); + } + + let mmproj_path = if let Some(mmproj) = &resolved.mmproj { + let path = match repo + .download_file() + .filename(mmproj.filename.clone()) + .progress(progress.clone()) + .send() + .await + .map_err(anyhow::Error::from) + { + Ok(path) => path, + Err(error) => { + progress.fail(&error); + return Err(error); + } + }; + progress.finish_file(mmproj.size_bytes); + Some(path) + } else { + None + }; + + progress.complete(); + Ok((paths, mmproj_path)) +} + +pub async fn resolve_local_model_spec(spec: &str) -> Result { + match parse_model_spec(spec) { + Ok((repo_id, quantization)) => return resolve_gguf_model(&repo_id, &quantization).await, + Err(error) if spec.contains(':') => return Err(error), + Err(_) => {} + } + + if looks_like_repo_id(spec) { + let variants = get_repo_local_variants(spec).await?; + let mlx_variants: Vec<_> = variants + .iter() + .filter(|variant| variant.backend_id == MLX_BACKEND_ID) + .collect(); + if mlx_variants.len() == 1 + && !variants + .iter() + .any(|variant| variant.backend_id == LLAMACPP_BACKEND_ID) + { + return resolve_mlx_model(spec, &mlx_variants[0].variant_id).await; + } + bail!( + "Model spec '{}' is ambiguous; choose one of: {}", + spec, + variants + .iter() + .map(|variant| variant.download_id.as_str()) + .collect::>() + .join(", ") + ); + } + + let (repo_id, quantization) = parse_model_spec(spec)?; + resolve_gguf_model(&repo_id, &quantization).await +} + +async fn resolve_mlx_model(repo_id: &str, variant_id: &str) -> Result { + let variants = get_repo_mlx_variants(repo_id).await?; + if !variants + .iter() + .any(|variant| variant.variant_id == variant_id) + { + bail!("No MLX variant '{}' found in {}", variant_id, repo_id); + } + let (owner, name) = split_repo_id(repo_id)?; + let client = hf_client().await?; + let repo = client.model(owner.to_string(), name.to_string()); + let info = repo + .info() + .expand(vec!["siblings".to_string(), "safetensors".to_string()]) + .send() + .await?; + let siblings = info.siblings.as_deref().unwrap_or(&[]); + let filenames = mlx_download_filenames(siblings); + let total_size = mlx_download_size_bytes(&info, siblings); + let progress = HfDownloadProgress::new(repo_id.to_string(), total_size); + progress.init(); + let mut snapshot_path = None; + for filename in filenames { + let file_size = siblings + .iter() + .find(|s| s.rfilename == filename) + .and_then(|s| s.size) + .unwrap_or(0); + let path = match repo + .download_file() + .filename(filename.clone()) + .progress(progress.clone()) + .send() + .await + .map_err(anyhow::Error::from) + { + Ok(path) => path, + Err(error) => { + progress.fail(&error); + return Err(error); + } + }; + if snapshot_path.is_none() { + snapshot_path = snapshot_root_for_file(&path, &filename); + } + progress.finish_file(file_size); + } + progress.complete(); + let snapshot_path = snapshot_path + .ok_or_else(|| anyhow::anyhow!("MLX model {} has no downloadable files", repo_id))?; + let total_size = if total_size > 0 { + total_size + } else { + dir_size(&snapshot_path) + }; + Ok(ResolvedLocalModel::Mlx { + repo_id: repo_id.to_string(), + variant_id: variant_id.to_string(), + snapshot_path, + total_size, + }) +} + +#[derive(Clone)] +struct HfDownloadProgress { + model_id: String, + total_bytes: u64, + completed_bytes: Arc>, + state: Arc>, +} + +#[derive(Default)] +struct HfDownloadState { + bytes_downloaded: u64, + current_file_total_bytes: u64, + speed_bps: Option, +} + +impl HfDownloadProgress { + fn new(model_id: String, total_bytes: u64) -> Self { + Self { + model_id, + total_bytes, + completed_bytes: Arc::new(Mutex::new(0)), + state: Arc::new(Mutex::new(HfDownloadState::default())), + } + } + + fn init(&self) { + let manager = crate::download_manager::get_download_manager(); + let download_id = format!("{}-model", self.model_id); + if manager.get_progress(&download_id).is_some() { + manager.update_progress(&download_id, |progress| { + if progress.status != crate::download_manager::DownloadStatus::Cancelled { + progress.status = crate::download_manager::DownloadStatus::Downloading; + progress.bytes_downloaded = 0; + progress.total_bytes = self.total_bytes; + progress.progress_percent = 0.0; + progress.speed_bps = None; + progress.eta_seconds = None; + progress.error = None; + progress.task_exited = false; + } + }); + } else { + manager.set_progress(crate::download_manager::DownloadProgress { + model_id: download_id, + status: crate::download_manager::DownloadStatus::Downloading, + bytes_downloaded: 0, + total_bytes: self.total_bytes, + progress_percent: 0.0, + speed_bps: None, + eta_seconds: None, + error: None, + task_exited: false, + }); + } + } + + fn is_cancelled(&self) -> bool { + crate::download_manager::get_download_manager() + .get_progress(&format!("{}-model", self.model_id)) + .is_some_and(|progress| { + progress.status == crate::download_manager::DownloadStatus::Cancelled + }) + } + + fn complete(&self) { + crate::download_manager::get_download_manager().update_progress( + &format!("{}-model", self.model_id), + |progress| { + if progress.status != crate::download_manager::DownloadStatus::Cancelled { + progress.status = crate::download_manager::DownloadStatus::Completed; + progress.progress_percent = 100.0; + } + progress.task_exited = true; + }, + ); + } + + fn fail(&self, error: impl ToString) { + crate::download_manager::get_download_manager().update_progress( + &format!("{}-model", self.model_id), + |progress| { + if progress.status != crate::download_manager::DownloadStatus::Cancelled { + progress.status = crate::download_manager::DownloadStatus::Failed; + progress.error = Some(error.to_string()); + } + progress.task_exited = true; + }, + ); + } + + fn finish_file(&self, size_bytes: u64) { + let observed_size = self + .state + .lock() + .map(|state| state.current_file_total_bytes.max(state.bytes_downloaded)) + .unwrap_or(0); + if let Ok(mut completed_bytes) = self.completed_bytes.lock() { + *completed_bytes = completed_bytes.saturating_add(size_bytes.max(observed_size)); + } + if let Ok(mut state) = self.state.lock() { + state.bytes_downloaded = 0; + state.current_file_total_bytes = 0; + } + self.update_progress_from_state(); + } + + fn update_progress_from_state(&self) { + let completed_bytes = self.completed_bytes.lock().map(|value| *value).unwrap_or(0); + if let Ok(state) = self.state.lock() { + let bytes_downloaded = completed_bytes.saturating_add(state.bytes_downloaded); + let total_bytes = + self.total_bytes + .max(completed_bytes.saturating_add( + state.current_file_total_bytes.max(state.bytes_downloaded), + )); + update_download_manager_progress( + &self.model_id, + bytes_downloaded.min(total_bytes), + total_bytes, + state.speed_bps, + ); + } + } +} + +impl ProgressHandler for HfDownloadProgress { + fn on_progress(&self, event: &ProgressEvent) { + let ProgressEvent::Download(event) = event else { + return; + }; + match event { + DownloadEvent::Start { total_bytes, .. } => { + if let Ok(mut state) = self.state.lock() { + state.current_file_total_bytes = *total_bytes; + } + self.update_progress_from_state(); + } + DownloadEvent::Progress { files } => { + if self.is_cancelled() { + return; + } + let bytes_downloaded = files + .iter() + .map(|file| { + if file.status == FileStatus::Complete { + file.total_bytes + } else { + file.bytes_completed + } + }) + .sum(); + let total_bytes = files.iter().map(|file| file.total_bytes).sum(); + if let Ok(mut state) = self.state.lock() { + state.bytes_downloaded = state.bytes_downloaded.max(bytes_downloaded); + state.current_file_total_bytes = + state.current_file_total_bytes.max(total_bytes); + } + self.update_progress_from_state(); + } + DownloadEvent::AggregateProgress { + bytes_completed, + total_bytes, + bytes_per_sec, + } => { + if self.is_cancelled() { + return; + } + if let Ok(mut state) = self.state.lock() { + state.bytes_downloaded = state.bytes_downloaded.max(*bytes_completed); + state.current_file_total_bytes = + (*total_bytes).max(state.current_file_total_bytes); + state.speed_bps = bytes_per_sec.map(|speed| speed as u64); + } + self.update_progress_from_state(); + } + DownloadEvent::Complete => {} + } + } +} + +pub fn register_resolved_model(resolved: ResolvedLocalModel, source: &str) -> Result { + let model_id = resolved.model_id(); + let repo_id = resolved.repo_id().to_string(); + let variant_id = resolved.variant_id().to_string(); + let backend_id = resolved.backend_id().to_string(); + let storage = resolved.storage(); + + let entry = match resolved { + ResolvedLocalModel::Gguf { + resolved, + local_paths, + mmproj_path, + .. + } => { + let first_file = &resolved.files[0]; + let first_local_path = local_paths + .first() + .cloned() + .ok_or_else(|| anyhow::anyhow!("Resolved GGUF model has no local files"))?; + let shard_files: Vec = resolved + .files + .iter() + .skip(1) + .zip(local_paths.iter().skip(1)) + .map(|(file, local_path)| ShardFile { + filename: file.filename.clone(), + local_path: local_path.clone(), + source_url: file.download_url.clone(), + size_bytes: file.size_bytes, + }) + .collect(); + let settings = super::local_model_registry::default_settings_for_model(&model_id); + super::local_model_registry::LocalModelEntry { + id: model_id.clone(), + repo_id, + filename: first_file.filename.clone(), + quantization: variant_id, + local_path: first_local_path, + source_url: first_file.download_url.clone(), + backend_id: settings.backend_id.clone(), + storage, + settings, + size_bytes: resolved.total_size, + mmproj_path, + mmproj_source_url: resolved + .mmproj + .as_ref() + .map(|mmproj| mmproj.download_url.clone()), + mmproj_size_bytes: resolved + .mmproj + .as_ref() + .map(|mmproj| mmproj.size_bytes) + .unwrap_or(0), + mmproj_checked: true, + shard_files, + } + } + ResolvedLocalModel::Mlx { + snapshot_path, + total_size, + .. + } => { + let mut settings = super::local_model_registry::default_settings_for_model(&model_id); + settings.backend_id = Some(backend_id.clone()); + super::local_model_registry::LocalModelEntry { + id: model_id.clone(), + repo_id, + filename: variant_id.clone(), + quantization: variant_id, + local_path: snapshot_path, + source_url: source.to_string(), + backend_id: Some(backend_id), + storage, + settings, + size_bytes: total_size, + mmproj_path: None, + mmproj_source_url: None, + mmproj_size_bytes: 0, + mmproj_checked: true, + shard_files: vec![], + } + } + }; + + let mut registry = get_registry() + .lock() + .map_err(|_| anyhow::anyhow!("Failed to acquire registry lock"))?; + registry.add_model(entry)?; + Ok(model_id) +} + +fn update_download_manager_progress( + model_id: &str, + bytes_downloaded: u64, + total_bytes: u64, + speed_bps: Option, +) { + crate::download_manager::get_download_manager().update_progress( + &format!("{}-model", model_id), + |progress| { + if progress.status == crate::download_manager::DownloadStatus::Cancelled { + return; + } + progress.bytes_downloaded = bytes_downloaded; + progress.total_bytes = total_bytes; + progress.progress_percent = if total_bytes > 0 { + (bytes_downloaded as f64 / total_bytes as f64 * 100.0) as f32 + } else { + 0.0 + }; + progress.speed_bps = speed_bps; + }, + ); +} + +fn dir_size(path: &std::path::Path) -> u64 { + if path.is_file() { + return std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + } + let mut total = 0; + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + total += dir_size(&entry.path()); + } + } + total +} diff --git a/crates/goose-local-inference/src/huggingface_auth.rs b/crates/goose-local-inference/src/huggingface_auth.rs new file mode 100644 index 0000000000..c1510e0625 --- /dev/null +++ b/crates/goose-local-inference/src/huggingface_auth.rs @@ -0,0 +1,64 @@ +use crate::{config_resolver, paths::Paths}; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use futures::future::BoxFuture; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +pub const HUGGINGFACE_TOKEN_SECRET_KEY: &str = "HF_TOKEN"; +pub const HUGGINGFACE_OAUTH_CACHE_PATH: &str = "huggingface/oauth/tokens.json"; + +pub type TokenResolver = fn() -> BoxFuture<'static, Result>>; + +static TOKEN_RESOLVER: OnceLock = OnceLock::new(); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HuggingFaceTokenData { + pub access_token: String, + #[serde(default)] + pub refresh_token: Option, + #[serde(default)] + pub expires_at: Option>, +} + +impl HuggingFaceTokenData { + pub fn is_expired(&self) -> bool { + self.expires_at + .is_some_and(|expires_at| expires_at <= Utc::now()) + } +} + +pub fn oauth_cache_path() -> PathBuf { + Paths::in_config_dir(HUGGINGFACE_OAUTH_CACHE_PATH) +} + +fn load_oauth_token_from_path(path: &Path) -> Option { + let contents = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&contents).ok() +} + +pub fn usable_oauth_token() -> Option { + let token = load_oauth_token_from_path(&oauth_cache_path())?; + (!token.is_expired()).then_some(token.access_token) +} + +pub fn hf_token_secret() -> Result> { + Ok(config_resolver::string_param(HUGGINGFACE_TOKEN_SECRET_KEY)? + .filter(|token| !token.trim().is_empty())) +} + +pub fn set_token_resolver(resolve_token: TokenResolver) { + let _ = TOKEN_RESOLVER.set(resolve_token); +} + +pub async fn resolve_token_async() -> Result> { + if let Some(resolve_token) = TOKEN_RESOLVER.get() { + return resolve_token().await; + } + + if let Some(token) = usable_oauth_token() { + return Ok(Some(token)); + } + hf_token_secret() +} diff --git a/crates/goose-local-inference/src/lib.rs b/crates/goose-local-inference/src/lib.rs 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 bcde004359..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 crate::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 2a19f5fe75..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::base::{FilterOut, ThinkFilter}; -use crate::providers::errors::ProviderError; -use crate::providers::local_inference::backend::LocalInferenceBackend; -use crate::providers::local_inference::local_model_registry::ModelSettings; -use crate::providers::local_inference::multimodal::ExtractedImage; -use crate::providers::utils::RequestLog; +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}; @@ -24,7 +23,7 @@ pub(super) struct GenerationContext<'a> { pub model_name: String, pub message_id: &'a str, pub tx: &'a StreamSender, - pub log: &'a mut RequestLog, + pub log: &'a mut Option>, pub images: &'a [ExtractedImage], } @@ -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 501ad6b849..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 crate::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 95% rename from crates/goose/src/providers/local_inference/llamacpp/mod.rs rename to crates/goose-local-inference/src/llamacpp/mod.rs index 4e076f4593..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::errors::ProviderError; -use crate::providers::formats::openai::format_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 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; @@ -449,6 +443,7 @@ impl LocalInferenceBackend for LlamaCppBackend { } else { (Vec::new(), None) }; + let has_media = !images.is_empty(); let effective_messages = vision_messages.as_deref().unwrap_or(request.messages); let code_mode_enabled = request.tools.iter().any(|t| t.name == CODE_EXECUTION_TOOL); @@ -469,7 +464,11 @@ impl LocalInferenceBackend for LlamaCppBackend { if matches!(request.settings.tool_calling, ToolCallingMode::Auto) && has_native_tool_payload { - let messages_json = build_openai_messages_json(request.system, effective_messages); + let messages_json = build_openai_messages_json( + request.system, + effective_messages, + has_media.then_some(marker), + ); if let Some(template) = loaded.templates.tool_use.as_ref() { supports_native_tool_calling( loaded, @@ -506,9 +505,17 @@ impl LocalInferenceBackend for LlamaCppBackend { }; let oai_messages_json = if use_emulator { - build_openai_text_messages_json(&system_prompt, effective_messages) + build_openai_text_messages_json( + &system_prompt, + effective_messages, + has_media.then_some(marker), + ) } else { - build_openai_messages_json(&system_prompt, effective_messages) + build_openai_messages_json( + &system_prompt, + effective_messages, + has_media.then_some(marker), + ) }; if !images.is_empty() && loaded.mtmd_ctx.is_none() { 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 56% rename from crates/goose/src/providers/local_inference/local_model_registry.rs rename to crates/goose-local-inference/src/local_model_registry.rs index abe9ee6355..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; @@ -61,8 +61,13 @@ pub enum ChatTemplate { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ModelSettings { + /// Backend implementation to use for this model. Defaults to llama.cpp. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend_id: Option, pub context_size: Option, pub max_output_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draft_model: Option, #[serde(default)] pub sampling: SamplingConfig, #[serde(default = "default_repeat_penalty")] @@ -117,8 +122,10 @@ fn default_repeat_last_n() -> i32 { impl Default for ModelSettings { fn default() -> Self { Self { + backend_id: None, context_size: None, max_output_tokens: None, + draft_model: None, sampling: SamplingConfig::default(), repeat_penalty: 1.0, repeat_last_n: 64, @@ -139,38 +146,106 @@ impl Default for ModelSettings { } } +/// HuggingFace repo + filename for multimodal projection weights (vision encoder). +pub struct MmprojSpec { + pub repo: &'static str, + pub filename: &'static str, +} + +impl MmprojSpec { + /// Local path for this mmproj, namespaced by repo to avoid collisions + /// between different models that use the same filename. + pub fn local_path(&self) -> std::path::PathBuf { + let repo_name = self.repo.split('/').next_back().unwrap_or(self.repo); + Paths::in_data_dir("models") + .join(repo_name) + .join(self.filename) + } +} + pub struct FeaturedModel { /// HuggingFace spec in "author/repo-GGUF:quantization" format. pub spec: &'static str, + /// Whether this model's GGUF template supports native tool calling via llama.cpp. + pub native_tool_calling: bool, + /// Multimodal projection weights spec. None for text-only models. + pub mmproj: Option, } pub const FEATURED_MODELS: &[FeaturedModel] = &[ FeaturedModel { spec: "bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M", + native_tool_calling: false, + mmproj: None, }, FeaturedModel { spec: "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M", + native_tool_calling: false, + mmproj: None, }, FeaturedModel { spec: "bartowski/Hermes-2-Pro-Mistral-7B-GGUF:Q4_K_M", + native_tool_calling: false, + mmproj: None, }, FeaturedModel { spec: "bartowski/Mistral-Small-24B-Instruct-2501-GGUF:Q4_K_M", + native_tool_calling: false, + mmproj: None, }, FeaturedModel { spec: "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M", + native_tool_calling: true, + mmproj: Some(MmprojSpec { + repo: "unsloth/gemma-4-E4B-it-GGUF", + filename: "mmproj-BF16.gguf", + }), }, FeaturedModel { spec: "unsloth/gemma-4-26B-A4B-it-GGUF:Q4_K_M", + native_tool_calling: true, + mmproj: Some(MmprojSpec { + repo: "unsloth/gemma-4-26B-A4B-it-GGUF", + filename: "mmproj-BF16.gguf", + }), }, ]; -pub fn default_settings_for_model(_model_id: &str) -> ModelSettings { +pub fn default_settings_for_model(model_id: &str) -> ModelSettings { + use super::hf_models::parse_model_spec; + let model_repo = model_id.split(':').next().unwrap_or(model_id); + let featured = FEATURED_MODELS.iter().find(|m| { + if let Ok((repo_id, _quant)) = parse_model_spec(m.spec) { + repo_id == model_repo + } else { + false + } + }); ModelSettings { + tool_calling: if featured.is_some_and(|m| m.native_tool_calling) { + ToolCallingMode::ForceNative + } else { + ToolCallingMode::Auto + }, + vision_capable: featured.is_some_and(|m| m.mmproj.is_some()), ..ModelSettings::default() } } +/// Look up the `MmprojSpec` for a featured model by its model ID. +pub fn featured_mmproj_spec(model_id: &str) -> Option<&'static MmprojSpec> { + use super::hf_models::parse_model_spec; + let model_repo = model_id.split(':').next().unwrap_or(model_id); + FEATURED_MODELS.iter().find_map(|m| { + if let Ok((repo_id, _quant)) = parse_model_spec(m.spec) { + if repo_id == model_repo { + return m.mmproj.as_ref(); + } + } + None + }) +} + /// Local path for an mmproj file, namespaced by repo to avoid collisions /// between different models that use the same filename. pub fn mmproj_local_path(repo_id: &str, filename: &str) -> PathBuf { @@ -199,6 +274,15 @@ pub fn get_registry() -> &'static Mutex { }) } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum LocalModelStorage { + #[default] + GooseManaged, + HuggingFaceCache, + ManualPath, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ShardFile { pub filename: String, @@ -215,6 +299,14 @@ pub struct LocalModelEntry { pub quantization: String, pub local_path: PathBuf, pub source_url: String, + /// Backend implementation to use for this model. Defaults to llama.cpp. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend_id: Option, + /// Where the model artifacts are stored. Goose keeps this registry as a + /// lightweight settings overlay; Hugging Face cache entries are not deleted + /// by Goose because they may be shared with other tools. + #[serde(default)] + pub storage: LocalModelStorage, #[serde(default)] pub settings: ModelSettings, #[serde(default)] @@ -235,6 +327,35 @@ pub struct LocalModelEntry { } impl LocalModelEntry { + /// Populate mmproj metadata and vision settings from the featured model + /// table if this model's repo has a known vision encoder. + pub fn enrich_with_featured_mmproj(&mut self) { + if let Some(mmproj) = featured_mmproj_spec(&self.id) { + let existing_path = self + .mmproj_path + .as_ref() + .filter(|path| path.exists()) + .cloned(); + let preserve_existing_path = existing_path.is_some(); + let path = existing_path.unwrap_or_else(|| mmproj.local_path()); + if self.mmproj_path.as_ref() != Some(&path) { + self.mmproj_path = Some(path.clone()); + } + if !preserve_existing_path || self.mmproj_source_url.is_none() { + self.mmproj_source_url = Some(format!( + "https://huggingface.co/{}/resolve/main/{}", + mmproj.repo, mmproj.filename + )); + } + self.mmproj_size_bytes = path_size(&path); + self.settings.vision_capable = true; + self.settings.mmproj_size_bytes = self.mmproj_size_bytes; + if matches!(self.settings.tool_calling, ToolCallingMode::Auto) { + self.settings.tool_calling = ToolCallingMode::ForceNative; + } + } + } + pub fn refresh_mmproj_metadata(&mut self) { self.settings.vision_capable = self.mmproj_path.is_some(); if let Some(path) = &self.mmproj_path { @@ -256,17 +377,19 @@ impl LocalModelEntry { self.local_path.exists() && self.shard_files.iter().all(|s| s.local_path.exists()) } - /// Returns all GGUF model file paths (primary + shards). + /// Returns all local paths owned by Goose for this model. /// Does NOT include mmproj โ€” that has separate shared-ownership deletion logic. pub fn all_local_paths(&self) -> impl Iterator { + let goose_managed = self.storage == LocalModelStorage::GooseManaged; std::iter::once(self.local_path.as_path()) .chain(self.shard_files.iter().map(|s| s.local_path.as_path())) + .filter(move |path| goose_managed && !path.is_dir()) } pub fn is_downloading(&self) -> bool { let download_id = format!("{}-model", self.id); let manager = get_download_manager(); - manager.get_progress(&download_id).is_some() + manager.is_downloading(&download_id) } pub fn download_status(&self) -> ModelDownloadStatus { @@ -328,9 +451,7 @@ impl LocalModelEntry { if self.size_bytes > 0 { return self.size_bytes; } - std::fs::metadata(&self.local_path) - .map(|m| m.len()) - .unwrap_or(0) + path_size(&self.local_path) } } @@ -397,7 +518,7 @@ impl LocalModelRegistry { for mut entry in featured_entries { if !self.models.iter().any(|m| m.id == entry.id) { - entry.refresh_mmproj_metadata(); + entry.enrich_with_featured_mmproj(); self.models.push(entry); changed = true; } @@ -416,6 +537,7 @@ impl LocalModelRegistry { } pub fn add_model(&mut self, mut entry: LocalModelEntry) -> Result<()> { + entry.enrich_with_featured_mmproj(); entry.refresh_mmproj_metadata(); if let Some(existing) = self.models.iter_mut().find(|m| m.id == entry.id) { *existing = entry; @@ -430,6 +552,42 @@ impl LocalModelRegistry { self.save() } + pub fn delete_model(&mut self, id: &str) -> Result<()> { + let plan = self.deletion_plan(id)?; + delete_model_artifacts(&plan)?; + + if is_featured_model(id) { + if let Some(entry) = self.models.iter_mut().find(|m| m.id == id) { + entry.local_path = Paths::in_data_dir("models").join(&entry.filename); + entry.storage = LocalModelStorage::GooseManaged; + entry.size_bytes = 0; + entry.shard_files.clear(); + } + self.save() + } else { + self.remove_model(id) + } + } + + fn deletion_plan(&self, id: &str) -> Result { + let entry = self + .get_model(id) + .ok_or_else(|| anyhow::anyhow!("Model not found: {}", id))?; + let mmproj_path = entry.mmproj_path.clone(); + let other_uses_mmproj = mmproj_path.as_ref().is_some_and(|target| { + self.models + .iter() + .any(|m| m.id != id && m.is_downloaded() && m.mmproj_path.as_ref() == Some(target)) + }); + + Ok(ModelDeletionPlan { + all_paths: entry.all_local_paths().map(|p| p.to_path_buf()).collect(), + primary_path: entry.local_path.clone(), + mmproj_path, + delete_mmproj: entry.storage == LocalModelStorage::GooseManaged && !other_uses_mmproj, + }) + } + pub fn get_model(&self, id: &str) -> Option<&LocalModelEntry> { self.models.iter().find(|m| m.id == id) } @@ -461,7 +619,142 @@ impl LocalModelRegistry { } } +struct ModelDeletionPlan { + all_paths: Vec, + primary_path: PathBuf, + mmproj_path: Option, + delete_mmproj: bool, +} + +fn delete_model_artifacts(plan: &ModelDeletionPlan) -> Result<()> { + for path in &plan.all_paths { + if path.exists() { + std::fs::remove_file(path)?; + } + } + + if !plan.all_paths.is_empty() { + if let Some(parent) = plan.primary_path.parent() { + let models_dir = Paths::in_data_dir("models"); + if parent != models_dir { + let _ = std::fs::remove_dir(parent); + } + } + } + + if plan.delete_mmproj { + if let Some(mmproj) = &plan.mmproj_path { + if mmproj.exists() { + std::fs::remove_file(mmproj)?; + } + } + } + + Ok(()) +} + /// Generate a unique ID for a model from its repo_id and quantization. +fn path_size(path: &std::path::Path) -> u64 { + if path.is_file() { + return std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + } + let mut total = 0; + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + total += path_size(&entry.path()); + } + } + total +} + pub fn model_id_from_repo(repo_id: &str, quantization: &str) -> String { format!("{}:{}", repo_id, quantization) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::download_manager::DownloadProgress; + + fn test_entry(id: &str) -> LocalModelEntry { + LocalModelEntry { + id: id.to_string(), + repo_id: "test/repo".to_string(), + filename: "model.gguf".to_string(), + quantization: "Q4_K_M".to_string(), + local_path: PathBuf::from(format!("/tmp/{id}.gguf")), + source_url: "https://example.test/model.gguf".to_string(), + backend_id: None, + storage: LocalModelStorage::GooseManaged, + settings: ModelSettings::default(), + size_bytes: 0, + mmproj_path: None, + mmproj_source_url: None, + mmproj_size_bytes: 0, + mmproj_checked: false, + shard_files: vec![], + } + } + + fn set_progress(entry: &LocalModelEntry, status: DownloadStatus) { + get_download_manager().set_progress(DownloadProgress { + model_id: format!("{}-model", entry.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 is_downloading_only_for_active_progress() { + let entry = test_entry("test-is-downloading-only-active-progress"); + let download_id = format!("{}-model", entry.id); + + set_progress(&entry, DownloadStatus::Downloading); + assert!(entry.is_downloading()); + + set_progress(&entry, DownloadStatus::Failed); + assert!(!entry.is_downloading()); + + set_progress(&entry, DownloadStatus::Cancelled); + assert!(!entry.is_downloading()); + + get_download_manager().clear_completed(&download_id); + } + + #[test] + fn enrich_with_featured_mmproj_preserves_existing_downloaded_path() { + let existing_path = std::env::temp_dir().join(format!( + "goose-mmproj-preserve-{}-{}.gguf", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&existing_path, b"mmproj").unwrap(); + + let mut entry = test_entry("unsloth/gemma-4-E4B-it-GGUF:Q4_K_M"); + entry.mmproj_path = Some(existing_path.clone()); + entry.mmproj_source_url = Some("https://example.test/mmproj.gguf".to_string()); + + entry.enrich_with_featured_mmproj(); + + assert_eq!(entry.mmproj_path.as_ref(), Some(&existing_path)); + assert_eq!( + entry.mmproj_source_url.as_deref(), + Some("https://example.test/mmproj.gguf") + ); + assert_eq!(entry.mmproj_size_bytes, 6); + assert!(entry.settings.vision_capable); + assert_eq!(entry.settings.mmproj_size_bytes, 6); + assert_eq!(entry.settings.tool_calling, ToolCallingMode::ForceNative); + + let _ = std::fs::remove_file(existing_path); + } +} 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 157e2faec0..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 crate::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-local-inference/src/native_tool_parsing.rs b/crates/goose-local-inference/src/native_tool_parsing.rs new file mode 100644 index 0000000000..3773c6b95f --- /dev/null +++ b/crates/goose-local-inference/src/native_tool_parsing.rs @@ -0,0 +1,323 @@ +use goose_provider_types::conversation::message::{Message, MessageContent}; +use goose_provider_types::errors::ProviderError; +use goose_provider_types::formats::openai::is_valid_function_name; +use goose_provider_types::json::safely_parse_json; +use rmcp::model::{object, CallToolRequestParams, ErrorCode, ErrorData}; +use serde_json::{json, Value}; +use std::borrow::Cow; +use uuid::Uuid; + +pub(crate) fn message_from_native_tool_text( + generated_text: &str, + message_id: &str, +) -> Result, ProviderError> { + let mut content = Vec::new(); + + if let Some(message) = parse_openai_message_json(generated_text) { + append_text(&mut content, message.get("content")); + append_tool_calls(&mut content, message.get("tool_calls")); + } else if let Some(tool_calls) = parse_tool_calls_json(generated_text) { + append_tool_calls(&mut content, Some(&tool_calls)); + } else if generated_text.contains(" Option { + json_candidates(generated_text) + .into_iter() + .find(|value| value.get("tool_calls").is_some_and(is_tool_call_array)) +} + +fn parse_tool_calls_json(generated_text: &str) -> Option { + for value in json_candidates(generated_text) { + if is_tool_call_array(&value) { + return Some(value); + } + if let Some(tool_calls) = value + .get("tool_calls") + .filter(|value| is_tool_call_array(value)) + { + return Some(tool_calls.clone()); + } + if is_tool_call_value(&value) { + return Some(Value::Array(vec![value])); + } + } + None +} + +fn is_tool_call_array(value: &Value) -> bool { + value + .as_array() + .is_some_and(|items| !items.is_empty() && items.iter().all(is_tool_call_value)) +} + +fn is_tool_call_value(value: &Value) -> bool { + let direct_name = value.get("name").and_then(|name| name.as_str()).is_some(); + let direct_arguments = value.get("arguments").is_some(); + let function_name = value + .get("function") + .and_then(|function| function.get("name")) + .and_then(|name| name.as_str()) + .is_some(); + + (direct_name && direct_arguments) || function_name +} + +fn json_candidates(text: &str) -> Vec { + let mut candidates = Vec::new(); + let trimmed = text.trim(); + if let Ok(value) = serde_json::from_str::(trimmed) { + candidates.push(value); + } + + for (open, close) in [('{', '}'), ('[', ']')] { + let starts = text.match_indices(open).map(|(idx, _)| idx); + for start in starts { + let mut depth = 0i32; + let mut in_string = false; + let mut escaped = false; + for (offset, ch) in text[start..].char_indices() { + if escaped { + escaped = false; + continue; + } + if ch == '\\' && in_string { + escaped = true; + continue; + } + if ch == '"' { + in_string = !in_string; + continue; + } + if in_string { + continue; + } + if ch == open { + depth += 1; + } else if ch == close { + depth -= 1; + if depth == 0 { + let end = start + offset + ch.len_utf8(); + if let Ok(value) = serde_json::from_str::(&text[start..end]) { + candidates.push(value); + } + break; + } + } + } + } + } + + candidates +} + +fn append_text(content: &mut Vec, value: Option<&Value>) { + if let Some(text) = value.and_then(|value| value.as_str()) { + if !text.is_empty() { + content.push(MessageContent::text(text)); + } + } +} + +fn append_tool_calls(content: &mut Vec, value: Option<&Value>) { + let Some(tool_calls) = value.and_then(|value| value.as_array()) else { + return; + }; + + for tool_call in tool_calls { + content.push(tool_call_content(tool_call)); + } +} + +fn tool_call_content(tool_call: &Value) -> MessageContent { + let id = tool_call + .get("id") + .and_then(|id| id.as_str()) + .map(ToString::to_string) + .unwrap_or_else(|| Uuid::new_v4().to_string()); + + let function = tool_call.get("function").unwrap_or(tool_call); + let name = function + .get("name") + .and_then(|name| name.as_str()) + .unwrap_or_default() + .to_string(); + + if !is_valid_function_name(&name) { + return MessageContent::tool_request( + id, + Err(ErrorData { + code: ErrorCode::INVALID_REQUEST, + message: Cow::from(format!( + "The provided function name '{}' had invalid characters, it must match this regex [a-zA-Z0-9_-]+", + name + )), + data: None, + }), + ); + } + + let arguments = function + .get("arguments") + .or_else(|| tool_call.get("arguments")) + .cloned() + .unwrap_or_else(|| json!({})); + + let raw_arguments = arguments.to_string(); + let parsed = match arguments { + Value::String(arguments) if arguments.trim().is_empty() => Ok(json!({})), + Value::String(arguments) => safely_parse_json(&arguments), + Value::Object(_) => Ok(arguments), + Value::Null => Ok(json!({})), + other => Ok(other), + }; + + match parsed { + Ok(params) => MessageContent::tool_request( + id, + Ok(CallToolRequestParams::new(name).with_arguments(object(params))), + ), + Err(error) => { + let message = format!( + "Could not interpret tool use parameters for id {}: {}. Raw arguments: '{}'", + id, error, raw_arguments + ); + MessageContent::tool_request( + id, + Err(ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: Cow::from(message), + data: None, + }), + ) + } + } +} + +fn parse_xml_tool_calls(content: &str) -> (Option, Vec) { + let function_re = regex::Regex::new(r"]+)>([\s\S]*?)").unwrap(); + let param_re = regex::Regex::new(r"]+)>([\s\S]*?)").unwrap(); + + let prefix = content + .find(" usize { + message + .content + .iter() + .filter(|content| matches!(content, MessageContent::ToolRequest(_))) + .count() + } + + #[test] + fn parses_openai_message_tool_calls() { + let text = r#"{"content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"developer__shell","arguments":"{\"command\":\"pwd\"}"}}]}"#; + let message = message_from_native_tool_text(text, "msg").unwrap().unwrap(); + assert_eq!(tool_count(&message), 1); + } + + #[test] + fn parses_top_level_tool_calls() { + let text = r#"{"tool_calls":[{"name":"developer__shell","arguments":{"command":"pwd"}}]}"#; + let message = message_from_native_tool_text(text, "msg").unwrap().unwrap(); + assert_eq!(tool_count(&message), 1); + } + + #[test] + fn parses_top_level_tool_call_array() { + let text = r#"[{"name":"developer__shell","arguments":{"command":"pwd"}}]"#; + let message = message_from_native_tool_text(text, "msg").unwrap().unwrap(); + assert_eq!(tool_count(&message), 1); + } + + #[test] + fn parses_top_level_tool_call_object_with_arguments() { + let text = r#"{"name":"developer__shell","arguments":{"command":"pwd"}}"#; + let message = message_from_native_tool_text(text, "msg").unwrap().unwrap(); + assert_eq!(tool_count(&message), 1); + } + + #[test] + fn ignores_plain_json_objects_with_name_fields() { + let text = r#"{"name":"Alice","age":30}"#; + assert!(message_from_native_tool_text(text, "msg") + .unwrap() + .is_none()); + } + + #[test] + fn ignores_plain_json_arrays() { + let text = r#"["a","b"]"#; + assert!(message_from_native_tool_text(text, "msg") + .unwrap() + .is_none()); + } + + #[test] + fn ignores_non_tool_call_arrays_in_tool_calls_field() { + let text = r#"{"tool_calls":["a","b"]}"#; + assert!(message_from_native_tool_text(text, "msg") + .unwrap() + .is_none()); + } + + #[test] + fn parses_xml_tool_calls() { + let text = r#"pwd"#; + let message = message_from_native_tool_text(text, "msg").unwrap().unwrap(); + assert_eq!(tool_count(&message), 1); + } +} diff --git a/crates/goose-local-inference/src/paths.rs b/crates/goose-local-inference/src/paths.rs 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-local-inference/src/tool_emulation.rs b/crates/goose-local-inference/src/tool_emulation.rs new file mode 100644 index 0000000000..ac528be232 --- /dev/null +++ b/crates/goose-local-inference/src/tool_emulation.rs @@ -0,0 +1,555 @@ +//! Shared text-based tool call emulation for local inference backends. +//! +//! Models that do not have native tool-calling support are prompted to emit shell commands +//! as `$ command` on a new line and code blocks as ```execute_typescript fenced blocks. +//! The parser converts those patterns into Goose tool-call messages. + +use goose_provider_types::conversation::message::{Message, MessageContent}; +use rmcp::model::{CallToolRequestParams, Tool}; +use serde_json::json; +use std::borrow::Cow; +use uuid::Uuid; + +pub(crate) const SHELL_TOOL: &str = "developer__shell"; +pub(crate) const CODE_EXECUTION_TOOL: &str = "code_execution__execute_typescript"; + +const HOLD_BACK_CODE_MODE: usize = " ```execute_typescript\n".len(); +const HOLD_BACK_SHELL_ONLY: usize = "\n$".len(); + +pub(crate) fn load_tiny_model_prompt() -> String { + use std::env; + + let os = if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "windows") { + "windows" + } else { + "unknown" + }; + + let working_directory = env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + + let shell = env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); + + let context = json!({ + "os": os, + "working_directory": working_directory, + "shell": shell, + }); + + crate::prompt_template::render_template("tiny_model_system.md", &context).unwrap_or_else(|e| { + tracing::warn!("Failed to load tiny_model_system.md: {:?}", e); + "You are Goose, an AI assistant. You can execute shell commands by starting lines with $." + .to_string() + }) +} + +pub(crate) fn build_emulator_tool_description(tools: &[Tool], code_mode_enabled: bool) -> String { + let mut tool_desc = String::new(); + + if code_mode_enabled { + tool_desc.push_str("\n\n# Running Code\n\n"); + tool_desc.push_str( + "You can call tools by writing code in a ```execute_typescript block. \ + The code runs immediately โ€” do not explain it, just run it.\n\n", + ); + tool_desc.push_str("Example โ€” counting files in /tmp:\n\n"); + tool_desc.push_str("```execute_typescript\nasync function run() {\n"); + tool_desc.push_str( + " const result = await Developer.shell({ command: \"ls -1 /tmp | wc -l\" });\n", + ); + tool_desc.push_str(" return result;\n}\n```\n\n"); + tool_desc.push_str("Rules:\n"); + tool_desc.push_str("- Code MUST define async function run() and return a result\n"); + tool_desc.push_str("- All function calls are async โ€” use await\n"); + tool_desc.push_str( + "- Use ```execute_typescript for tool calls, $ for simple shell one-liners\n\n", + ); + tool_desc.push_str("Available functions:\n\n"); + + for tool in tools { + if tool.name.starts_with("code_execution__") { + continue; + } + let parts: Vec<&str> = tool.name.splitn(2, "__").collect(); + if parts.len() == 2 { + let namespace = { + let mut c = parts[0].chars(); + match c.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(c).collect::(), + } + }; + let camel_name: String = parts[1] + .split('_') + .enumerate() + .map(|(i, part)| { + if i == 0 { + part.to_string() + } else { + let mut c = part.chars(); + match c.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(c).collect(), + } + } + }) + .collect(); + let desc = tool.description.as_ref().map(|d| d.as_ref()).unwrap_or(""); + tool_desc.push_str(&format!("- {namespace}.{camel_name}(): {desc}\n")); + } + } + } else { + tool_desc.push_str("\n\n# Tools\n\nYou have access to the following tools:\n\n"); + for tool in tools { + let desc = tool + .description + .as_ref() + .map(|d| d.as_ref()) + .unwrap_or("No description"); + tool_desc.push_str(&format!("- {}: {}\n", tool.name, desc)); + } + } + + tool_desc +} + +pub(crate) enum EmulatorAction { + Text(String), + ShellCommand(String), + ExecuteCode(String), +} + +enum ParserState { + Normal, + InCommand, + InExecuteBlock, +} + +pub(crate) struct StreamingEmulatorParser { + buffer: String, + state: ParserState, + code_mode_enabled: bool, +} + +impl StreamingEmulatorParser { + pub(crate) fn new(code_mode_enabled: bool) -> Self { + Self { + buffer: String::new(), + state: ParserState::Normal, + code_mode_enabled, + } + } + + pub(crate) fn process_chunk(&mut self, chunk: &str) -> Vec { + self.buffer.push_str(chunk); + let mut results = Vec::new(); + + loop { + match self.state { + ParserState::InCommand => { + if let Some((command_line, rest)) = self.buffer.split_once('\n') { + if let Some(command) = command_line.strip_prefix('$') { + let command = command.trim(); + if !command.is_empty() { + results.push(EmulatorAction::ShellCommand(command.to_string())); + } + } + self.buffer = rest.to_string(); + self.state = ParserState::Normal; + } else { + break; + } + } + ParserState::InExecuteBlock => { + if let Some(end_idx) = self.buffer.find("\n```") { + #[allow(clippy::string_slice)] + let code = self.buffer[..end_idx].to_string(); + #[allow(clippy::string_slice)] + let rest = &self.buffer[end_idx + 4..]; + let rest = rest.strip_prefix('\n').unwrap_or(rest); + self.buffer = rest.to_string(); + self.state = ParserState::Normal; + if !code.trim().is_empty() { + results.push(EmulatorAction::ExecuteCode(code)); + } + } else { + break; + } + } + ParserState::Normal => { + if self.code_mode_enabled { + if let Some((before, after)) = + self.buffer.split_once("```execute_typescript\n") + { + if !before.trim().is_empty() { + results.push(EmulatorAction::Text(before.to_string())); + } + self.buffer = after.to_string(); + self.state = ParserState::InExecuteBlock; + continue; + } + if self.buffer.ends_with("```execute_typescript") { + let before = self.buffer.trim_end_matches("```execute_typescript"); + if !before.trim().is_empty() { + results.push(EmulatorAction::Text(before.to_string())); + } + self.buffer.clear(); + self.state = ParserState::InExecuteBlock; + continue; + } + } + + if let Some((before_dollar, from_dollar)) = self.buffer.split_once("\n$") { + let text = format!("{}\n", before_dollar); + if !text.trim().is_empty() { + results.push(EmulatorAction::Text(text)); + } + self.buffer = format!("${}", from_dollar); + self.state = ParserState::InCommand; + } else if self.buffer.starts_with('$') && self.buffer.len() == chunk.len() { + self.state = ParserState::InCommand; + } else { + let hold_back = if self.code_mode_enabled { + HOLD_BACK_CODE_MODE + } else { + HOLD_BACK_SHELL_ONLY + }; + let char_count = self.buffer.chars().count(); + if char_count > hold_back && !self.buffer.ends_with('\n') { + let mut chars = self.buffer.chars(); + let emit_count = char_count - hold_back; + let emit_text: String = chars.by_ref().take(emit_count).collect(); + let keep_text: String = chars.collect(); + if !emit_text.is_empty() { + results.push(EmulatorAction::Text(emit_text)); + } + self.buffer = keep_text; + } + break; + } + } + } + } + + results + } + + pub(crate) fn flush(&mut self) -> Vec { + let mut results = Vec::new(); + + if !self.buffer.is_empty() { + match self.state { + ParserState::InCommand => { + let command_line = self.buffer.trim(); + if let Some(command) = command_line.strip_prefix('$') { + let command = command.trim(); + if !command.is_empty() { + results.push(EmulatorAction::ShellCommand(command.to_string())); + } + } else if !command_line.is_empty() { + results.push(EmulatorAction::Text(self.buffer.clone())); + } + } + ParserState::InExecuteBlock => { + let code = self.buffer.trim(); + if !code.is_empty() { + results.push(EmulatorAction::ExecuteCode(code.to_string())); + } + } + ParserState::Normal => { + results.push(EmulatorAction::Text(self.buffer.clone())); + } + } + self.buffer.clear(); + self.state = ParserState::Normal; + } + + results + } +} + +pub(crate) fn message_for_emulator_action( + action: &EmulatorAction, + message_id: &str, +) -> (Message, bool) { + match action { + EmulatorAction::Text(text) => { + let mut message = Message::assistant().with_text(text); + message.id = Some(message_id.to_string()); + (message, false) + } + EmulatorAction::ShellCommand(command) => { + let tool_id = Uuid::new_v4().to_string(); + let mut args = serde_json::Map::new(); + args.insert("command".to_string(), json!(command)); + let tool_call = + CallToolRequestParams::new(Cow::Borrowed(SHELL_TOOL)).with_arguments(args); + let mut message = Message::assistant(); + message + .content + .push(MessageContent::tool_request(tool_id, Ok(tool_call))); + message.id = Some(message_id.to_string()); + (message, true) + } + EmulatorAction::ExecuteCode(code) => { + let tool_id = Uuid::new_v4().to_string(); + let wrapped = if code.contains("async function run()") { + code.clone() + } else { + format!("async function run() {{\n{}\n}}", code) + }; + let mut args = serde_json::Map::new(); + args.insert("code".to_string(), json!(wrapped)); + let tool_call = + CallToolRequestParams::new(Cow::Borrowed(CODE_EXECUTION_TOOL)).with_arguments(args); + let mut message = Message::assistant(); + message + .content + .push(MessageContent::tool_request(tool_id, Ok(tool_call))); + message.id = Some(message_id.to_string()); + (message, true) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_chunks(chunks: &[&str], code_mode: bool) -> Vec { + let mut parser = StreamingEmulatorParser::new(code_mode); + let mut actions = Vec::new(); + for chunk in chunks { + actions.extend(parser.process_chunk(chunk)); + } + actions.extend(parser.flush()); + actions + } + + fn parse_all(input: &str, code_mode: bool) -> Vec { + parse_chunks(&[input], code_mode) + } + + fn assert_text(action: &EmulatorAction, expected: &str) { + match action { + EmulatorAction::Text(t) => assert_eq!(t.trim(), expected.trim(), "text mismatch"), + other => panic!("expected Text, got {:?}", action_label(other)), + } + } + + fn assert_shell(action: &EmulatorAction, expected: &str) { + match action { + EmulatorAction::ShellCommand(cmd) => { + assert_eq!(cmd, expected, "shell command mismatch") + } + other => panic!("expected ShellCommand, got {:?}", action_label(other)), + } + } + + fn assert_execute(action: &EmulatorAction, expected: &str) { + match action { + EmulatorAction::ExecuteCode(code) => { + assert_eq!(code.trim(), expected.trim(), "execute code mismatch") + } + other => panic!("expected ExecuteCode, got {:?}", action_label(other)), + } + } + + fn action_label(a: &EmulatorAction) -> &'static str { + match a { + EmulatorAction::Text(_) => "Text", + EmulatorAction::ShellCommand(_) => "ShellCommand", + EmulatorAction::ExecuteCode(_) => "ExecuteCode", + } + } + + #[test] + fn plain_text_no_tools() { + let actions = parse_all("Hello, world!", false); + let all_text: String = actions + .iter() + .map(|a| match a { + EmulatorAction::Text(t) => t.as_str(), + _ => panic!("expected only Text actions"), + }) + .collect(); + assert_eq!(all_text.trim(), "Hello, world!"); + } + + #[test] + fn single_shell_command() { + let actions = parse_all("$ ls -la\n", false); + assert_eq!(actions.len(), 1); + assert_shell(&actions[0], "ls -la"); + } + + #[test] + fn text_then_shell_command() { + let actions = parse_all("Let me check:\n$ ls -la\n", false); + assert!(actions.len() >= 2); + assert_text(&actions[0], "Let me check:"); + assert_shell(&actions[actions.len() - 1], "ls -la"); + } + + #[test] + fn shell_command_at_start_of_output() { + let actions = parse_all("$ whoami\n", false); + assert_eq!(actions.len(), 1); + assert_shell(&actions[0], "whoami"); + } + + #[test] + fn shell_command_without_trailing_newline() { + let actions = parse_all("$ whoami", false); + assert_eq!(actions.len(), 1); + assert_shell(&actions[0], "whoami"); + } + + #[test] + fn dollar_sign_mid_sentence_is_not_command() { + let actions = parse_all("It costs $50 per month", false); + for action in &actions { + assert!(matches!(action, EmulatorAction::Text(_))); + } + let all_text: String = actions + .iter() + .filter_map(|a| match a { + EmulatorAction::Text(t) => Some(t.as_str()), + _ => None, + }) + .collect(); + assert_eq!(all_text.trim(), "It costs $50 per month"); + } + + #[test] + fn execute_block() { + let input = "Here's the code:\n```execute_typescript\nconsole.log('hi');\n```\n"; + let actions = parse_all(input, true); + assert!(actions.len() >= 2); + assert_text(&actions[0], "Here's the code:"); + assert_execute(&actions[actions.len() - 1], "console.log('hi');"); + } + + #[test] + fn tool_description_uses_parser_execute_fence() { + let description = build_emulator_tool_description(&[], true); + + assert!(description.contains("```execute_typescript")); + assert!(!description.contains("```execute block")); + assert!(!description.contains("Use ```execute for tool calls")); + } + + #[test] + fn execute_block_not_detected_without_code_mode() { + let input = "```execute_typescript\nconsole.log('hi');\n```\n"; + let actions = parse_all(input, false); + for action in &actions { + assert!(matches!(action, EmulatorAction::Text(_))); + } + } + + #[test] + fn dollar_split_across_chunks() { + let actions = parse_chunks(&["Let me check\n", "$ ls -la\n"], false); + let shells: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ShellCommand(_))) + .collect(); + assert_eq!(shells.len(), 1); + assert_shell(shells[0], "ls -la"); + } + + #[test] + fn execute_fence_split_across_chunks() { + let actions = parse_chunks( + &["Here:\n```ex", "ecute_typescript\nlet x = 1;\n", "```\n"], + true, + ); + let executes: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ExecuteCode(_))) + .collect(); + assert_eq!(executes.len(), 1); + assert_execute(executes[0], "let x = 1;"); + } + + #[test] + fn multiple_commands_on_separate_lines() { + let actions = parse_chunks(&["Here:\n$ cd /tmp\n", "Done.\n$ ls\n"], false); + let shells: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ShellCommand(_))) + .collect(); + assert_eq!(shells.len(), 2); + assert_shell(shells[0], "cd /tmp"); + assert_shell(shells[1], "ls"); + } + + #[test] + fn regular_code_fence_not_treated_as_execute() { + let input = "```python\nprint('hi')\n```\n"; + let actions = parse_all(input, true); + for action in &actions { + assert!(matches!(action, EmulatorAction::Text(_))); + } + } + + #[test] + fn empty_command_ignored() { + let actions = parse_all("$\n", false); + let shells: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ShellCommand(_))) + .collect(); + assert_eq!(shells.len(), 0); + } + + #[test] + fn token_by_token_streaming() { + let input = "$ echo hello\n"; + let chars: Vec = input.chars().map(|c| c.to_string()).collect(); + let chunks: Vec<&str> = chars.iter().map(|s| s.as_str()).collect(); + let actions = parse_chunks(&chunks, false); + let shells: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ShellCommand(_))) + .collect(); + assert_eq!(shells.len(), 1); + assert_shell(shells[0], "echo hello"); + } + + #[test] + fn execute_block_with_multiline_code() { + let input = "```execute_typescript\nasync function run() {\n const r = await Developer.shell({ command: \"ls\" });\n return r;\n}\n```\n"; + let actions = parse_all(input, true); + let executes: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ExecuteCode(_))) + .collect(); + assert_eq!(executes.len(), 1); + match executes[0] { + EmulatorAction::ExecuteCode(code) => { + assert!(code.contains("async function run()")); + assert!(code.contains("Developer.shell")); + } + _ => unreachable!(), + } + } + + #[test] + fn unclosed_execute_block_flushed() { + let input = "```execute_typescript\nlet x = 1;"; + let actions = parse_all(input, true); + let executes: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ExecuteCode(_))) + .collect(); + assert_eq!(executes.len(), 1); + assert_execute(executes[0], "let x = 1;"); + } +} diff --git a/crates/goose/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 610e9986b6..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.40", 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/autovisualiser/mod.rs b/crates/goose-mcp/src/autovisualiser/mod.rs index a2906bcce3..1b78ff67d9 100644 --- a/crates/goose-mcp/src/autovisualiser/mod.rs +++ b/crates/goose-mcp/src/autovisualiser/mod.rs @@ -116,6 +116,25 @@ fn validation_err(msg: impl Into) -> ErrorData { ErrorData::new(ErrorCode::INVALID_PARAMS, msg.into(), None) } +/// Accepts `data` either as a JSON value or as a JSON-encoded string, +/// since models sometimes emit complex tool parameters double-encoded. +fn lenient_data<'de, D, T>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, + T: serde::de::DeserializeOwned, +{ + let value = Value::deserialize(deserializer)?; + let value = match value { + Value::String(s) => serde_json::from_str(&s).map_err(|e| { + serde::de::Error::custom(format!( + "the 'data' parameter was a JSON-encoded string that could not be parsed as JSON ({e}); provide 'data' as a JSON object, not a string" + )) + })?, + other => other, + }; + T::deserialize(value).map_err(serde::de::Error::custom) +} + /// Sankey node structure #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct SankeyNode { @@ -184,6 +203,7 @@ impl SankeyData { #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct RenderSankeyParams { /// The data for the Sankey diagram + #[serde(deserialize_with = "lenient_data")] pub data: SankeyData, } @@ -232,6 +252,7 @@ impl RadarData { #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct RenderRadarParams { /// The data for the radar chart + #[serde(deserialize_with = "lenient_data")] pub data: RadarData, } @@ -309,6 +330,7 @@ fn validate_donut_charts(charts: &[SingleDonutChart]) -> Result<(), ErrorData> { #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct RenderDonutParams { /// The chart data as an array of chart objects. Use a single-element array for one chart. + #[serde(deserialize_with = "lenient_data")] pub data: Vec, } @@ -350,6 +372,7 @@ impl TreemapNode { #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct RenderTreemapParams { /// The hierarchical data for the treemap + #[serde(deserialize_with = "lenient_data")] pub data: TreemapNode, } @@ -393,6 +416,7 @@ impl ChordData { #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct RenderChordParams { /// The data for the chord diagram + #[serde(deserialize_with = "lenient_data")] pub data: ChordData, } @@ -493,6 +517,7 @@ impl MapData { #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct RenderMapParams { /// The data for the map visualization + #[serde(deserialize_with = "lenient_data")] pub data: MapData, } @@ -608,6 +633,7 @@ impl ChartData { #[derive(Debug, Serialize, Deserialize, rmcp::schemars::JsonSchema)] pub struct ShowChartParams { /// The data for the chart + #[serde(deserialize_with = "lenient_data")] pub data: ChartData, } @@ -2085,3 +2111,102 @@ mod validation_tests { assert!(err.message.contains("longitude")); } } + +#[cfg(test)] +mod lenient_data_tests { + use super::*; + use serde_json::json; + + #[test] + fn show_chart_accepts_string_encoded_data() { + let input = json!({ + "data": "{\"type\": \"bar\", \"labels\": [\"A\", \"B\"], \"datasets\": [{\"label\": \"S\", \"data\": [1, 2]}]}" + }); + let parsed: ShowChartParams = serde_json::from_value(input).unwrap(); + assert_eq!(parsed.data.datasets.len(), 1); + } + + #[test] + fn show_chart_still_accepts_object_data() { + let input = json!({ + "data": {"type": "bar", "labels": ["A", "B"], "datasets": [{"label": "S", "data": [1, 2]}]} + }); + let parsed: ShowChartParams = serde_json::from_value(input).unwrap(); + assert_eq!(parsed.data.datasets.len(), 1); + } + + #[test] + fn donut_accepts_string_encoded_array_data() { + let input = json!({ + "data": "[{\"values\": [10, 20], \"labels\": [\"A\", \"B\"]}]" + }); + let parsed: RenderDonutParams = serde_json::from_value(input).unwrap(); + assert_eq!(parsed.data.len(), 1); + } + + #[test] + fn sankey_accepts_string_encoded_data() { + let input = json!({ + "data": "{\"nodes\": [{\"name\": \"A\"}, {\"name\": \"B\"}], \"links\": [{\"source\": \"A\", \"target\": \"B\", \"value\": 1.0}]}" + }); + let parsed: RenderSankeyParams = serde_json::from_value(input).unwrap(); + assert_eq!(parsed.data.nodes.len(), 2); + } + + #[test] + fn radar_accepts_string_encoded_data() { + let input = json!({ + "data": "{\"labels\": [\"X\", \"Y\"], \"datasets\": [{\"label\": \"S\", \"data\": [1.0, 2.0]}]}" + }); + let parsed: RenderRadarParams = serde_json::from_value(input).unwrap(); + assert_eq!(parsed.data.labels.len(), 2); + } + + #[test] + fn treemap_accepts_string_encoded_data() { + let input = json!({ + "data": "{\"name\": \"root\", \"children\": [{\"name\": \"leaf\", \"value\": 5.0}]}" + }); + let parsed: RenderTreemapParams = serde_json::from_value(input).unwrap(); + assert_eq!(parsed.data.name, "root"); + } + + #[test] + fn chord_accepts_string_encoded_data() { + let input = json!({ + "data": "{\"labels\": [\"A\", \"B\"], \"matrix\": [[0.0, 1.0], [1.0, 0.0]]}" + }); + let parsed: RenderChordParams = serde_json::from_value(input).unwrap(); + assert_eq!(parsed.data.matrix.len(), 2); + } + + #[test] + fn map_accepts_string_encoded_data() { + let input = json!({ + "data": "{\"markers\": [{\"lat\": 1.0, \"lng\": 2.0}]}" + }); + let parsed: RenderMapParams = serde_json::from_value(input).unwrap(); + assert_eq!(parsed.data.markers.len(), 1); + } + + #[test] + fn invalid_json_string_gives_clear_error() { + let input = json!({"data": "not valid json"}); + let err = serde_json::from_value::(input).unwrap_err(); + assert!(err.to_string().contains("could not be parsed as JSON")); + } + + #[test] + fn string_encoded_data_with_wrong_shape_reports_field_error() { + let input = json!({"data": "{\"nope\": 1}"}); + let err = serde_json::from_value::(input).unwrap_err(); + assert!(err.to_string().contains("type")); + } + + #[test] + fn schema_still_reflects_struct_type() { + let schema = serde_json::to_value(rmcp::schemars::schema_for!(ShowChartParams)).unwrap(); + let data_schema = &schema["properties"]["data"]; + assert_ne!(data_schema["type"], json!("string")); + } +} diff --git a/crates/goose-mcp/src/computercontroller/pdf_tool.rs b/crates/goose-mcp/src/computercontroller/pdf_tool.rs index 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/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-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-provider-types/src/base.rs b/crates/goose-provider-types/src/base.rs new file mode 100644 index 0000000000..357215fdaa --- /dev/null +++ b/crates/goose-provider-types/src/base.rs @@ -0,0 +1,717 @@ +use async_trait::async_trait; +use futures::Stream; +use rmcp::model::Tool; +use serde::{Deserialize, Serialize}; +use std::pin::Pin; +use utoipa::ToSchema; + +use crate::{ + canonical::{map_to_canonical_model, CanonicalModelRegistry}, + conversation::{ + message::{Message, MessageContent}, + token_usage::{ProviderUsage, Usage}, + }, + errors::ProviderError, + goose_mode::GooseMode, + model::ModelConfig, + permission::PermissionConfirmation, + retry::RetryConfig, +}; + +/// Metadata about a provider's configuration requirements and capabilities +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ProviderMetadata { + /// The unique identifier for this provider + pub name: String, + /// Display name for the provider in UIs + pub display_name: String, + /// Description of the provider's capabilities + pub description: String, + /// The default/recommended model for this provider + pub default_model: String, + /// A list of currently known models with their capabilities + pub known_models: Vec, + /// Link to the docs where models can be found + pub model_doc_link: String, + /// Required configuration keys + pub config_keys: Vec, + /// step-by-step instructions for set up providers eg: api key + #[serde(default)] + pub setup_steps: Vec, + /// Hint shown in the model picker when this provider manages its own model selection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_selection_hint: Option, + /// The name of a fast/cheap model to use for lightweight tasks (e.g. session naming, + /// compaction). When set, fast-path callers prefer this model over the main model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fast_model: Option, +} + +impl ProviderMetadata { + pub fn new( + name: &str, + display_name: &str, + description: &str, + default_model: &str, + model_names: Vec<&str>, + model_doc_link: &str, + config_keys: Vec, + ) -> Self { + Self { + name: name.to_string(), + display_name: display_name.to_string(), + description: description.to_string(), + default_model: default_model.to_string(), + known_models: model_names + .iter() + .map(|&model_name| model_info_for_provider_model(name, model_name)) + .collect(), + model_doc_link: model_doc_link.to_string(), + config_keys, + setup_steps: vec![], + model_selection_hint: None, + fast_model: None, + } + } + + pub fn with_models( + name: &str, + display_name: &str, + description: &str, + default_model: &str, + models: Vec, + model_doc_link: &str, + config_keys: Vec, + ) -> Self { + Self { + name: name.to_string(), + display_name: display_name.to_string(), + description: description.to_string(), + default_model: default_model.to_string(), + known_models: models, + model_doc_link: model_doc_link.to_string(), + config_keys, + setup_steps: vec![], + model_selection_hint: None, + fast_model: None, + } + } + + pub fn empty() -> Self { + Self { + name: "".to_string(), + display_name: "".to_string(), + description: "".to_string(), + default_model: "".to_string(), + known_models: vec![], + model_doc_link: "".to_string(), + config_keys: vec![], + setup_steps: vec![], + model_selection_hint: None, + fast_model: None, + } + } + + pub fn with_setup_steps(mut self, steps: Vec<&str>) -> Self { + self.setup_steps = steps.into_iter().map(|s| s.to_string()).collect(); + self + } + + pub fn with_model_selection_hint(mut self, hint: &str) -> Self { + self.model_selection_hint = Some(hint.to_string()); + self + } + + pub fn with_fast_model(mut self, fast_model: &str) -> Self { + self.fast_model = Some(fast_model.to_string()); + self + } +} + +/// Configuration key metadata for provider setup +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ConfigKey { + /// The name of the configuration key (e.g., "API_KEY") + pub name: String, + /// Whether this key is required for the provider to function + pub required: bool, + /// Whether this key should be stored securely (e.g., in keychain) + pub secret: bool, + /// Optional default value for the key + pub default: Option, + /// Whether this key should be configured using an OAuth flow + /// When true, the provider's configure_oauth() method will be called instead of prompting for manual input + pub oauth_flow: bool, + /// Whether this OAuth flow uses the device code grant (RFC 8628) + /// When true, the user must enter a verification code in the browser + #[serde(default)] + pub device_code_flow: bool, + /// Whether this key should be shown prominently during provider setup + /// (onboarding, settings modal, CLI configure) + #[serde(default)] + pub primary: bool, +} + +impl ConfigKey { + /// Create a new ConfigKey + pub fn new( + name: &str, + required: bool, + secret: bool, + default: Option<&str>, + primary: bool, + ) -> Self { + Self { + name: name.to_string(), + required, + secret, + default: default.map(|s| s.to_string()), + oauth_flow: false, + device_code_flow: false, + primary, + } + } + + /// Create a new ConfigKey that uses an OAuth flow for configuration + /// + /// This is used for providers that support OAuth authentication instead of manual API key entry. + /// When oauth_flow is true, the configuration system will call the provider's configure_oauth() method. + pub fn new_oauth( + name: &str, + required: bool, + secret: bool, + default: Option<&str>, + primary: bool, + ) -> Self { + Self { + name: name.to_string(), + required, + secret, + default: default.map(|s| s.to_string()), + oauth_flow: true, + device_code_flow: false, + primary, + } + } + + /// Create a new ConfigKey that uses OAuth device code flow (RFC 8628) for configuration + /// + /// Similar to new_oauth, but indicates the provider uses the device code grant where the user + /// must enter a verification code in the browser. + pub fn new_oauth_device_code( + name: &str, + required: bool, + secret: bool, + default: Option<&str>, + primary: bool, + ) -> Self { + Self { + name: name.to_string(), + required, + secret, + default: default.map(|s| s.to_string()), + oauth_flow: true, + device_code_flow: true, + primary, + } + } +} + +/// Information about a model's capabilities +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)] +pub struct ModelInfo { + /// The name of the model + pub name: String, + /// The underlying model resolved from provider metadata, when the configured model is an alias or endpoint. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolved_model: Option, + /// The maximum context length this model supports + pub context_limit: usize, + /// Cost per token for input in USD (optional) + pub input_token_cost: Option, + /// Cost per token for output in USD (optional) + pub output_token_cost: Option, + /// Currency for the costs (default: "$") + pub currency: Option, + /// Whether this model supports cache control + pub supports_cache_control: Option, + /// Whether this model supports reasoning/thinking controls + #[serde(default)] + pub reasoning: bool, +} + +impl ModelInfo { + /// Create a new ModelInfo with just name and context limit + pub fn new(name: impl Into, context_limit: usize) -> Self { + Self { + name: name.into(), + resolved_model: None, + context_limit, + input_token_cost: None, + output_token_cost: None, + currency: None, + supports_cache_control: None, + reasoning: false, + } + } + + /// Create a new ModelInfo with cost information (per token) + pub fn with_cost( + name: impl Into, + context_limit: usize, + input_cost: f64, + output_cost: f64, + ) -> Self { + Self { + name: name.into(), + resolved_model: None, + context_limit, + input_token_cost: Some(input_cost), + output_token_cost: Some(output_cost), + currency: Some("$".to_string()), + supports_cache_control: None, + reasoning: false, + } + } +} + +pub trait ProviderDescriptor { + fn metadata() -> ProviderMetadata; +} + +/// A message stream yields partial text content but complete tool calls, all within the Message object +/// So a message with text will contain potentially just a word of a longer response, but tool calls +/// messages will only be yielded once concatenated. +pub type MessageStream = Pin< + Box, Option), ProviderError>> + Send>, +>; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PermissionRouting { + ActionRequired, + Noop, +} + +pub fn model_info_for_provider_model(provider_name: &str, model_name: &str) -> ModelInfo { + let registry = CanonicalModelRegistry::bundled().ok(); + let canonical = registry.as_ref().and_then(|registry| { + let canonical_id = map_to_canonical_model(provider_name, model_name, registry)?; + let (provider, model) = canonical_id.split_once('/')?; + registry.get(provider, model) + }); + + let reasoning = canonical + .as_ref() + .and_then(|model| model.reasoning) + .unwrap_or_else(|| ModelConfig::new(model_name).is_reasoning_model()); + + ModelInfo { + name: model_name.to_string(), + resolved_model: None, + context_limit: ModelConfig::new(model_name) + .with_canonical_limits(provider_name) + .context_limit(), + input_token_cost: None, + output_token_cost: None, + currency: None, + supports_cache_control: None, + reasoning, + } +} + +/// Collect all chunks from a MessageStream into a single Message and ProviderUsage +pub async fn collect_stream( + mut stream: MessageStream, +) -> Result<(Message, ProviderUsage), ProviderError> { + use futures::StreamExt; + + let mut final_message: Option = None; + let mut final_usage: Option = None; + + while let Some(result) = stream.next().await { + let (msg_opt, usage_opt) = result?; + + if let Some(msg) = msg_opt { + final_message = Some(match final_message { + Some(mut prev) => { + for new_content in msg.content { + match (&mut prev.content.last_mut(), &new_content) { + // Coalesce consecutive text blocks + ( + Some(MessageContent::Text(last_text)), + MessageContent::Text(new_text), + ) => { + last_text.text.push_str(&new_text.text); + } + _ => { + prev.content.push(new_content); + } + } + } + prev + } + None => msg, + }); + } + + if let Some(usage) = usage_opt { + final_usage = Some(usage); + } + } + + match final_message { + Some(msg) => { + let usage = final_usage + .unwrap_or_else(|| ProviderUsage::new("unknown".to_string(), Usage::default())); + Ok((msg, usage)) + } + None => Err(ProviderError::ExecutionError( + "Stream yielded no message".to_string(), + )), + } +} + +pub fn stream_from_single_message(message: Message, usage: ProviderUsage) -> MessageStream { + let stream = futures::stream::once(async move { Ok((Some(message), Some(usage))) }); + Box::pin(stream) +} + +/// Base trait for AI providers (OpenAI, Anthropic, etc) +#[async_trait] +pub trait Provider: Send + Sync { + /// Get the name of this provider instance + fn get_name(&self) -> &str; + + /// Primary streaming method that all providers must implement. + async fn stream( + &self, + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result; + + async fn complete( + &self, + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + let stream = self.stream(model_config, system, messages, tools).await?; + collect_stream(stream).await + } + + /// Resolve the effective context limit for a model config. + /// + /// Providers may override this to enrich the limit with provider-specific + /// metadata (e.g. cached model info or a value captured from a remote + /// session). The default returns the limit derived from the model config. + async fn get_context_limit(&self, model_config: &ModelConfig) -> Result { + Ok(model_config.context_limit()) + } + + fn retry_config(&self) -> RetryConfig { + RetryConfig::default() + } + + async fn fetch_supported_models(&self) -> Result, ProviderError> { + Ok(vec![]) + } + + async fn fetch_supported_model_info(&self) -> Result, ProviderError> { + Ok(self + .fetch_supported_models() + .await? + .iter() + .map(|model_name| model_info_for_provider_model(self.get_name(), model_name)) + .collect()) + } + + async fn fetch_model_info(&self, model_name: &str) -> Result { + Ok(model_info_for_provider_model(self.get_name(), model_name)) + } + + fn skip_canonical_filtering(&self) -> bool { + false + } + + /// Fetch inventory models filtered by canonical registry and usability. + /// + /// When `toolshim` is true, models that lack native tool-call support are + /// retained because the toolshim layer emulates tool calling. + async fn fetch_recommended_models(&self, toolshim: bool) -> Result, ProviderError> { + let all_models = self.fetch_supported_models().await?; + + if self.skip_canonical_filtering() { + return Ok(all_models); + } + + let registry = CanonicalModelRegistry::bundled().map_err(|e| { + ProviderError::ExecutionError(format!("Failed to load canonical registry: {}", e)) + })?; + + let provider_name = self.get_name(); + + // Get all text-capable models with their release dates + let mut models_with_dates: Vec<(String, Option)> = all_models + .iter() + .filter_map(|model| { + let canonical_id = map_to_canonical_model(provider_name, model, registry)?; + + let (provider, model_name) = canonical_id.split_once('/')?; + let canonical_model = registry.get(provider, model_name)?; + + if !canonical_model + .modalities + .input + .contains(&crate::canonical::Modality::Text) + { + return None; + } + + if !canonical_model.tool_call && !toolshim { + return None; + } + + let release_date = canonical_model.release_date.clone(); + + Some((model.clone(), release_date)) + }) + .collect(); + + // Sort by release date (most recent first), then alphabetically for models without dates + models_with_dates.sort_by(|a, b| match (&a.1, &b.1) { + (Some(date_a), Some(date_b)) => date_b.cmp(date_a), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.0.cmp(&b.0), + }); + + let inventory_models: Vec = models_with_dates + .into_iter() + .map(|(name, _)| name) + .collect(); + + if inventory_models.is_empty() { + Ok(all_models) + } else { + Ok(inventory_models) + } + } + + async fn fetch_recommended_model_info( + &self, + toolshim: bool, + ) -> Result, ProviderError> { + Ok(self + .fetch_recommended_models(toolshim) + .await? + .iter() + .map(|model_name| model_info_for_provider_model(self.get_name(), model_name)) + .collect()) + } + + async fn map_to_canonical_model( + &self, + provider_model: &str, + ) -> Result, ProviderError> { + let registry = CanonicalModelRegistry::bundled().map_err(|e| { + ProviderError::ExecutionError(format!("Failed to load canonical registry: {}", e)) + })?; + + Ok(map_to_canonical_model( + self.get_name(), + provider_model, + registry, + )) + } + + /// Whether the provider manages its own conversation context (e.g. CLI + /// wrappers like Claude Code or Gemini CLI). When true, goose-side + /// context management such as tool-pair summarization is skipped because + /// the provider's internal state is the source of truth. + fn manages_own_context(&self) -> bool { + false + } + + /// Configure OAuth authentication for this provider + /// + /// This method is called when a provider has configuration keys marked with oauth_flow = true. + /// Providers that support OAuth should override this method to implement their specific OAuth flow. + /// + /// # Returns + /// * `Ok(())` if OAuth configuration succeeds and credentials are saved + /// * `Err(ProviderError)` if OAuth fails or is not supported by this provider + /// + /// # Default Implementation + /// The default implementation returns an error indicating OAuth is not supported. + async fn configure_oauth(&self) -> Result<(), ProviderError> { + Err(ProviderError::ExecutionError( + "OAuth configuration not supported by this provider".to_string(), + )) + } + + async fn refresh_credentials(&self) -> Result<(), ProviderError> { + Err(ProviderError::NotImplemented( + "credential refresh not supported by this provider".to_string(), + )) + } + + async fn update_mode(&self, _session_id: &str, _mode: GooseMode) -> Result<(), ProviderError> { + Ok(()) + } + + fn permission_routing(&self) -> PermissionRouting { + PermissionRouting::Noop + } + + async fn handle_permission_confirmation( + &self, + _request_id: &str, + _confirmation: &PermissionConfirmation, + ) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_case::test_case; + + fn content_from_str(s: String) -> MessageContent { + if let Some(img_data) = s.strip_prefix("*img:") { + MessageContent::image(format!("http://example.com/{}", img_data), "image/png") + } else if let Some(tool_name) = s.strip_prefix("*tool:") { + let tool_call = Ok( + rmcp::model::CallToolRequestParams::new(tool_name.to_string()) + .with_arguments(serde_json::Map::new()), + ); + MessageContent::tool_request(format!("tool_{}", tool_name), tool_call) + } else { + MessageContent::text(s) + } + } + + fn create_test_stream( + items: Vec, + ) -> impl Stream, Option), ProviderError>> { + use futures::stream; + stream::iter(items.into_iter().map(|item| { + let content = content_from_str(item); + let message = Message::new( + rmcp::model::Role::Assistant, + chrono::Utc::now().timestamp(), + vec![content], + ); + Ok((Some(message), None)) + })) + } + + fn content_to_strings(msg: &Message) -> Vec { + msg.content + .iter() + .map(|c| match c { + MessageContent::Text(t) => t.text.clone(), + MessageContent::Image(_) => "*img".to_string(), + MessageContent::ToolRequest(tr) => { + if let Ok(call) = &tr.tool_call { + format!("*tool:{}", call.name) + } else { + "*tool:error".to_string() + } + } + _ => "*other".to_string(), + }) + .collect() + } + + #[test_case( + vec!["Hello", " ", "world"], + vec!["Hello world"] + ; "consecutive text coalesces" + )] + #[test_case( + vec!["Hello", "*img:pic1", "world"], + vec!["Hello", "*img", "world"] + ; "non-text breaks coalescing" + )] + #[test_case( + vec!["A", "B", "*img:pic1", "C", "D", "*tool:read", "E", "F"], + vec!["AB", "*img", "CD", "*tool:read", "EF"] + ; "multiple text groups" + )] + #[test_case( + vec!["Text1", "*img:pic", "Text2"], + vec!["Text1", "*img", "Text2"] + ; "mixed content in chunk" + )] + #[tokio::test] + async fn test_collect_stream_coalescing(input_items: Vec<&str>, expected: Vec<&str>) { + let items: Vec = input_items.into_iter().map(|s| s.to_string()).collect(); + let stream = create_test_stream(items); + let (msg, _) = collect_stream(Box::pin(stream)).await.unwrap(); + assert_eq!(content_to_strings(&msg), expected); + } + + #[tokio::test] + async fn test_collect_stream_defaults_usage() { + let stream = create_test_stream(vec!["Hello".to_string()]); + let (msg, usage) = collect_stream(Box::pin(stream)).await.unwrap(); + assert_eq!(content_to_strings(&msg), vec!["Hello"]); + assert_eq!(usage.model, "unknown"); + } + + #[test] + fn test_model_info_creation() { + // Test direct ModelInfo creation + let info = ModelInfo { + name: "test-model".to_string(), + resolved_model: None, + context_limit: 1000, + input_token_cost: None, + output_token_cost: None, + currency: None, + supports_cache_control: None, + reasoning: false, + }; + assert_eq!(info.context_limit, 1000); + + // Test equality + let info2 = ModelInfo { + name: "test-model".to_string(), + resolved_model: None, + context_limit: 1000, + input_token_cost: None, + output_token_cost: None, + currency: None, + supports_cache_control: None, + reasoning: false, + }; + assert_eq!(info, info2); + + // Test inequality + let info3 = ModelInfo { + name: "test-model".to_string(), + resolved_model: None, + context_limit: 2000, + input_token_cost: None, + output_token_cost: None, + currency: None, + supports_cache_control: None, + reasoning: false, + }; + assert_ne!(info, info3); + } + + #[test] + fn test_model_info_with_cost() { + let info = ModelInfo::with_cost("gpt-4o", 128000, 0.0000025, 0.00001); + assert_eq!(info.name, "gpt-4o"); + assert_eq!(info.context_limit, 128000); + assert_eq!(info.input_token_cost, Some(0.0000025)); + assert_eq!(info.output_token_cost, Some(0.00001)); + assert_eq!(info.currency, Some("$".to_string())); + } +} diff --git a/crates/goose/src/providers/canonical/mod.rs b/crates/goose-provider-types/src/canonical.rs similarity index 96% rename from crates/goose/src/providers/canonical/mod.rs rename to crates/goose-provider-types/src/canonical.rs index ab7bc81a4a..cb92b606cd 100644 --- a/crates/goose/src/providers/canonical/mod.rs +++ b/crates/goose-provider-types/src/canonical.rs @@ -1,8 +1,9 @@ +pub mod catalog; mod model; mod name_builder; mod registry; -pub use model::{CanonicalModel, Limit, Modalities, Modality, Pricing}; +pub use model::{CanonicalModel, Limit, Modalities, Modality, Pricing, ThinkingMode}; pub use name_builder::{ canonical_name, map_provider_name, map_to_canonical_model, strip_version_suffix, }; @@ -27,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 @@ -109,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/src/providers/canonical/README.md b/crates/goose-provider-types/src/canonical/README.md similarity index 63% rename from crates/goose/src/providers/canonical/README.md rename to crates/goose-provider-types/src/canonical/README.md index 410d102c28..4b00a8e09f 100644 --- a/crates/goose/src/providers/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: `src/providers/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: `src/providers/canonical/data/canonical_mapping_report.json` + - Writes to: `crates/goose-provider-types/src/canonical/data/canonical_mapping_report.json` -The script is located in this directory: `build_canonical_models.rs` +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/src/providers/catalog.rs b/crates/goose-provider-types/src/canonical/catalog.rs similarity index 87% rename from crates/goose/src/providers/catalog.rs rename to crates/goose-provider-types/src/canonical/catalog.rs index 9dda84e584..e63dfb23d6 100644 --- a/crates/goose/src/providers/catalog.rs +++ b/crates/goose-provider-types/src/canonical/catalog.rs @@ -1,13 +1,10 @@ use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; -use super::{ - base::{ConfigKey, ProviderMetadata}, - canonical::CanonicalModelRegistry, -}; +use super::CanonicalModelRegistry; -const PROVIDER_METADATA_JSON: &str = include_str!("canonical/data/provider_metadata.json"); +const PROVIDER_METADATA_JSON: &str = include_str!("data/provider_metadata.json"); #[derive(Debug, Clone, Serialize, Deserialize)] struct ProviderMetadataEntry { @@ -175,6 +172,24 @@ pub struct ProviderSetupCatalogEntry { pub show_only_when_installed: bool, } +#[derive(Debug, Clone)] +pub struct ProviderSetupMetadata { + pub name: String, + pub display_name: String, + pub description: String, + pub model_doc_link: String, + pub config_keys: Vec, +} + +#[derive(Debug, Clone)] +pub struct ProviderSetupConfigKey { + pub name: String, + pub required: bool, + pub secret: bool, + pub default: Option, + pub primary: bool, +} + #[derive(Debug, Clone, Copy)] struct CuratedSetupMetadata { provider_id: &'static str, @@ -375,6 +390,23 @@ const SETUP_METADATA: &[CuratedSetupMetadata] = &[ secret_field_default: Some(API_KEY_FIELD), field_overrides: &[], }, + CuratedSetupMetadata { + provider_id: "huggingface", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::SingleApiKey, + group: ProviderSetupGroup::Default, + display_name: Some("Hugging Face"), + description: Some("Hugging Face Inference Providers"), + docs_url: Some("https://huggingface.co/docs/inference-providers"), + aliases: &["huggingface", "hf"], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: Some(API_KEY_FIELD), + field_overrides: &[], + }, CuratedSetupMetadata { provider_id: "chatgpt_codex", category: ProviderSetupCategory::Model, @@ -661,6 +693,12 @@ const SETUP_METADATA: &[CuratedSetupMetadata] = &[ placeholder: Some("Paste your API key"), default_value: None, }, + CuratedFieldMetadata { + key: "AZURE_OPENAI_AD_TOKEN", + label: "Entra ID Token", + placeholder: Some("Optional: short-lived Microsoft Entra access token"), + default_value: None, + }, ], }, CuratedSetupMetadata { @@ -883,7 +921,7 @@ fn field_label(key: &str) -> String { fn field_override<'a>( key: &str, - config_key: &ConfigKey, + config_key: &ProviderSetupConfigKey, curated: &'a CuratedSetupMetadata, ) -> Option<&'a CuratedFieldMetadata> { if let Some(field) = curated @@ -901,7 +939,10 @@ fn field_override<'a>( None } -fn setup_field(config_key: &ConfigKey, curated: &CuratedSetupMetadata) -> ProviderSetupField { +fn setup_field( + config_key: &ProviderSetupConfigKey, + curated: &CuratedSetupMetadata, +) -> ProviderSetupField { let field_override = field_override(&config_key.name, config_key, curated); ProviderSetupField { key: config_key.name.clone(), @@ -919,7 +960,7 @@ fn setup_field(config_key: &ConfigKey, curated: &CuratedSetupMetadata) -> Provid fn setup_entry_from_metadata( curated: &CuratedSetupMetadata, - metadata: &ProviderMetadata, + metadata: &ProviderSetupMetadata, ) -> ProviderSetupCatalogEntry { ProviderSetupCatalogEntry { provider_id: curated.provider_id.to_string(), @@ -977,13 +1018,10 @@ fn synthetic_goose_setup_entry(curated: &CuratedSetupMetadata) -> ProviderSetupC } } -pub async fn get_providers_by_format(format: ProviderFormat) -> Vec { - let native_provider_ids = super::init::providers() - .await - .into_iter() - .map(|(metadata, _)| metadata.name) - .collect::>(); - +pub fn get_providers_by_format( + format: ProviderFormat, + native_provider_ids: &HashSet, +) -> Vec { let mut entries: Vec = PROVIDER_METADATA .values() .filter_map(|metadata| { @@ -1021,13 +1059,9 @@ pub async fn get_providers_by_format(format: ProviderFormat) -> Vec Vec { - let registry_metadata = super::providers() - .await - .into_iter() - .map(|(metadata, _)| (metadata.name.clone(), metadata)) - .collect::>(); - +pub fn get_setup_catalog_entries( + registry_metadata: &HashMap, +) -> Vec { SETUP_METADATA .iter() .filter_map(|curated| { @@ -1105,130 +1139,3 @@ pub fn get_provider_template(provider_id: &str) -> Option { doc_url: metadata.doc.clone().unwrap_or_default(), }) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::providers::base::ProviderType; - - #[tokio::test] - async fn test_zai_provider() { - let zai = crate::providers::get_from_registry("zai") - .await - .expect("z.ai should be registered as a declarative provider"); - assert_eq!(zai.provider_type(), ProviderType::Declarative); - - let metadata = zai.metadata(); - assert_eq!(metadata.display_name, "Z.AI"); - assert!( - !metadata.known_models.is_empty(), - "z.ai should have known models" - ); - assert!( - metadata - .config_keys - .iter() - .any(|key| key.name == "ZHIPU_API_KEY"), - "z.ai should expose its API key config" - ); - - let setup_entries = get_setup_catalog_entries().await; - let setup_entry = setup_entries - .iter() - .find(|entry| entry.provider_id == "zai") - .expect("z.ai should be in the setup catalog"); - assert_eq!(setup_entry.setup_method, ProviderSetupMethod::SingleApiKey); - - let template = get_provider_template("zai"); - assert!(template.is_some(), "z.ai should have a template"); - - let template = template.unwrap(); - println!("Z.AI template: {} models", template.models.len()); - for model in template.models.iter().take(3) { - println!( - " - {} ({}K context)", - model.name, - model.context_limit / 1000 - ); - } - assert!( - !template.models.is_empty(), - "z.ai template should have models" - ); - } - - #[tokio::test] - async fn setup_catalog_includes_goose_and_curated_fields() { - let entries = get_setup_catalog_entries().await; - - let goose = entries - .iter() - .find(|entry| entry.provider_id == "goose") - .expect("setup catalog should include synthetic goose"); - assert_eq!(goose.category, ProviderSetupCategory::Agent); - assert_eq!(goose.setup_method, ProviderSetupMethod::None); - assert!(goose.fields.is_empty()); - - let ollama = entries - .iter() - .find(|entry| entry.provider_id == "ollama") - .expect("setup catalog should include ollama"); - assert_eq!(ollama.setup_method, ProviderSetupMethod::ConfigFields); - assert_eq!(ollama.fields.len(), 1); - assert_eq!(ollama.fields[0].key, "OLLAMA_HOST"); - assert_eq!(ollama.fields[0].label, "Host"); - assert_eq!( - ollama.fields[0].default_value.as_deref(), - Some("http://localhost:11434") - ); - - let databricks = entries - .iter() - .find(|entry| entry.provider_id == "databricks") - .expect("setup catalog should include databricks"); - assert_eq!( - databricks.setup_method, - ProviderSetupMethod::HostWithOauthFallback - ); - assert_eq!( - databricks - .fields - .iter() - .map(|field| field.key.as_str()) - .collect::>(), - ["DATABRICKS_HOST", "DATABRICKS_TOKEN"] - ); - - let atomic_chat = entries - .iter() - .find(|entry| entry.provider_id == "atomic_chat") - .expect("setup catalog should include atomic_chat declarative provider"); - assert_eq!(atomic_chat.setup_method, ProviderSetupMethod::ConfigFields); - let host_field = atomic_chat - .fields - .iter() - .find(|field| field.key == "ATOMIC_CHAT_HOST") - .expect("atomic_chat should expose ATOMIC_CHAT_HOST"); - assert_eq!(host_field.label, "Host URL"); - assert_eq!( - host_field.default_value.as_deref(), - Some("http://localhost:1337") - ); - } - - #[tokio::test] - async fn setup_catalog_excludes_uncurated_deprecated_providers() { - let provider_ids = get_setup_catalog_entries() - .await - .into_iter() - .map(|entry| entry.provider_id) - .collect::>(); - - assert!(provider_ids.contains("claude-acp")); - assert!(provider_ids.contains("codex-acp")); - assert!(provider_ids.contains("atomic_chat")); - assert!(!provider_ids.contains("claude_code")); - assert!(!provider_ids.contains("codex")); - assert!(!provider_ids.contains("gemini_cli")); - } -} diff --git a/crates/goose/src/providers/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/src/providers/canonical/data/canonical_mapping_report.json rename to crates/goose-provider-types/src/canonical/data/canonical_mapping_report.json diff --git a/crates/goose/src/providers/canonical/data/canonical_models.json b/crates/goose-provider-types/src/canonical/data/canonical_models.json similarity index 87% rename from crates/goose/src/providers/canonical/data/canonical_models.json rename to crates/goose-provider-types/src/canonical/data/canonical_models.json index 50e421a0dc..793c3fa836 100644 --- a/crates/goose/src/providers/canonical/data/canonical_models.json +++ b/crates/goose-provider-types/src/canonical/data/canonical_models.json @@ -167,7 +167,7 @@ }, { "id": "302ai/claude-3.5-haiku", - "name": "claude-3-5-haiku-20241022", + "name": "claude-3-5-haiku-latest", "family": "claude-haiku", "attachment": true, "reasoning": false, @@ -320,7 +320,7 @@ }, { "id": "302ai/claude-opus-4.5", - "name": "claude-opus-4-5-20251101", + "name": "claude-opus-4-5", "family": "claude-opus", "attachment": true, "reasoning": true, @@ -2012,7 +2012,7 @@ }, { "id": "302ai/gpt-5.4-nano", - "name": "gpt-5.4-nano-2026-03-17", + "name": "gpt-5.4-nano", "family": "gpt-nano", "attachment": true, "reasoning": true, @@ -2073,10 +2073,10 @@ } }, { - "id": "302ai/grok-4-fast", - "name": "grok-4-fast-reasoning", + "id": "302ai/grok-4-fast-non-reasoning", + "name": "grok-4-fast-non-reasoning", "attachment": true, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2025-06", @@ -2102,10 +2102,10 @@ } }, { - "id": "302ai/grok-4-fast-non", - "name": "grok-4-fast-non-reasoning", + "id": "302ai/grok-4-fast-reasoning", + "name": "grok-4-fast-reasoning", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "knowledge": "2025-06", @@ -2160,36 +2160,7 @@ } }, { - "id": "302ai/grok-4.1-fast", - "name": "grok-4-1-fast-reasoning", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-06", - "release_date": "2025-11-20", - "last_updated": "2025-11-20", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.2, - "output": 0.5 - }, - "limit": { - "context": 2000000, - "output": 30000 - } - }, - { - "id": "302ai/grok-4.1-fast-non", + "id": "302ai/grok-4.1-fast-non-reasoning", "name": "grok-4-1-fast-non-reasoning", "attachment": true, "reasoning": false, @@ -2218,12 +2189,41 @@ } }, { - "id": "302ai/grok-4.20-beta", - "name": "grok-4.20-beta-0309-reasoning", + "id": "302ai/grok-4.1-fast-reasoning", + "name": "grok-4-1-fast-reasoning", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-06", + "release_date": "2025-11-20", + "last_updated": "2025-11-20", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.2, + "output": 0.5 + }, + "limit": { + "context": 2000000, + "output": 30000 + } + }, + { + "id": "302ai/grok-4.20-beta-0309-non-reasoning", + "name": "grok-4.20-beta-0309-non-reasoning", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, "release_date": "2026-03-16", "last_updated": "2026-03-16", "modalities": { @@ -2246,10 +2246,10 @@ } }, { - "id": "302ai/grok-4.20-beta-0309-non", - "name": "grok-4.20-beta-0309-non-reasoning", + "id": "302ai/grok-4.20-beta-0309-reasoning", + "name": "grok-4.20-beta-0309-reasoning", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2026-03-16", @@ -3997,7 +3997,7 @@ } }, { - "id": "abacus/grok-4-fast-non", + "id": "abacus/grok-4-fast-non-reasoning", "name": "Grok 4 Fast (Non-Reasoning)", "family": "grok", "attachment": true, @@ -4026,7 +4026,7 @@ } }, { - "id": "abacus/grok-4.1-fast-non", + "id": "abacus/grok-4.1-fast-non-reasoning", "name": "Grok 4.1 Fast (Non-Reasoning)", "family": "grok", "attachment": true, @@ -4086,7 +4086,7 @@ { "id": "abacus/kimi-k2-turbo-preview", "name": "Kimi K2 Turbo Preview", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -4114,7 +4114,7 @@ { "id": "abacus/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": true, "tool_call": true, @@ -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", @@ -5858,7 +5951,7 @@ { "id": "aihubmix/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi-k2.5", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -5890,7 +5983,7 @@ { "id": "aihubmix/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -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,10 +6810,40 @@ "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", - "family": "kimi", + "family": "kimi-thinking", "attachment": false, "reasoning": true, "tool_call": true, @@ -6687,7 +6871,7 @@ { "id": "alibaba-cn/kimi-k2.5", "name": "Moonshot Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": true, "tool_call": true, @@ -6718,7 +6902,7 @@ { "id": "alibaba-cn/kimi-k2.6", "name": "Moonshot Kimi K2.6", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -6749,7 +6933,7 @@ { "id": "alibaba-cn/kimi/kimi-k2.5", "name": "kimi/kimi-k2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": true, "tool_call": true, @@ -6781,7 +6965,7 @@ { "id": "alibaba-cn/moonshot-kimi-k2-instruct", "name": "Moonshot Kimi K2 Instruct", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -8383,6 +8567,38 @@ "output": 65536 } }, + { + "id": "alibaba-cn/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.5, + "output": 3.0, + "cache_read": 0.05, + "cache_write": 0.625 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, { "id": "alibaba-cn/qwq-32b", "name": "QwQ 32B", @@ -8676,7 +8892,7 @@ { "id": "alibaba-coding-plan-cn/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -8923,6 +9139,38 @@ "output": 65536 } }, + { + "id": "alibaba-coding-plan-cn/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, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, { "id": "alibaba-coding-plan/MiniMax-M2.5", "name": "MiniMax-M2.5", @@ -9017,7 +9265,7 @@ { "id": "alibaba-coding-plan/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -9266,6 +9514,1128 @@ "output": 65536 } }, + { + "id": "alibaba-coding-plan/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, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "alibaba-token-plan-cn/MiniMax-M2.5", + "name": "MiniMax-M2.5", + "family": "minimax", + "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.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 196608, + "output": 24576 + } + }, + { + "id": "alibaba-token-plan-cn/deepseek-v3.2", + "name": "DeepSeek V3.2", + "family": "deepseek", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2025-12-03", + "last_updated": "2025-12-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 131072, + "output": 65536 + } + }, + { + "id": "alibaba-token-plan-cn/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, + "cache_read": 0.0 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "alibaba-token-plan-cn/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, + "cache_read": 0.0 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "alibaba-token-plan-cn/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": false, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 202752, + "output": 16384 + } + }, + { + "id": "alibaba-token-plan-cn/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, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 202752, + "output": 128000 + } + }, + { + "id": "alibaba-token-plan-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": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "alibaba-token-plan-cn/kimi-k2.5", + "name": "Kimi K2.5", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-01", + "last_updated": "2026-01", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, + { + "id": "alibaba-token-plan-cn/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, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "alibaba-token-plan-cn/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, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "alibaba-token-plan-cn/qwen-image-2.0", + "name": "Qwen Image 2.0", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-03-03", + "last_updated": "2026-03-03", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 8192, + "output": 0 + } + }, + { + "id": "alibaba-token-plan-cn/qwen-image-2.0-pro", + "name": "Qwen Image 2.0 Pro", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-03-03", + "last_updated": "2026-03-03", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 8192, + "output": 0 + } + }, + { + "id": "alibaba-token-plan-cn/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.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "alibaba-token-plan-cn/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.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "alibaba-token-plan-cn/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": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "alibaba-token-plan-cn/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, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "alibaba-token-plan-cn/wan2.7-image", + "name": "Wan2.7 Image", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-05-29", + "last_updated": "2026-05-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 8192, + "output": 0 + } + }, + { + "id": "alibaba-token-plan-cn/wan2.7-image-pro", + "name": "Wan2.7 Image Pro", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-05-29", + "last_updated": "2026-05-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 8192, + "output": 0 + } + }, + { + "id": "alibaba-token-plan/MiniMax-M2.5", + "name": "MiniMax-M2.5", + "family": "minimax", + "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.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 196608, + "output": 24576 + } + }, + { + "id": "alibaba-token-plan/deepseek-v3.2", + "name": "DeepSeek V3.2", + "family": "deepseek", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2025-12-03", + "last_updated": "2025-12-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 131072, + "output": 65536 + } + }, + { + "id": "alibaba-token-plan/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, + "cache_read": 0.0 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "alibaba-token-plan/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, + "cache_read": 0.0 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "alibaba-token-plan/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": false, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 202752, + "output": 16384 + } + }, + { + "id": "alibaba-token-plan/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, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 202752, + "output": 128000 + } + }, + { + "id": "alibaba-token-plan/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, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "alibaba-token-plan/kimi-k2.5", + "name": "Kimi K2.5", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-01", + "last_updated": "2026-01", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, + { + "id": "alibaba-token-plan/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, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "alibaba-token-plan/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, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "alibaba-token-plan/qwen-image-2.0", + "name": "Qwen Image 2.0", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-03-03", + "last_updated": "2026-03-03", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 8192, + "output": 0 + } + }, + { + "id": "alibaba-token-plan/qwen-image-2.0-pro", + "name": "Qwen Image 2.0 Pro", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-03-03", + "last_updated": "2026-03-03", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 8192, + "output": 0 + } + }, + { + "id": "alibaba-token-plan/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.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "alibaba-token-plan/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.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "alibaba-token-plan/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": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "alibaba-token-plan/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, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "alibaba-token-plan/wan2.7-image", + "name": "Wan2.7 Image", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-05-29", + "last_updated": "2026-05-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 8192, + "output": 0 + } + }, + { + "id": "alibaba-token-plan/wan2.7-image-pro", + "name": "Wan2.7 Image Pro", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-05-29", + "last_updated": "2026-05-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 8192, + "output": 0 + } + }, { "id": "alibaba/qvq-max", "name": "QVQ Max", @@ -10742,6 +12112,39 @@ "output": 65536 } }, + { + "id": "alibaba/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-04", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.5, + "output": 3.0, + "cache_read": 0.05, + "cache_write": 0.625 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "alibaba/qwq-plus", "name": "QwQ Plus", @@ -10776,7 +12179,7 @@ "name": "Nova 2 Lite", "family": "nova", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2024-12-01", @@ -10895,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", @@ -11068,6 +12504,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -11158,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)", @@ -11232,6 +12702,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -11322,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", @@ -11409,6 +12913,39 @@ "output": 81920 } }, + { + "id": "amazon-bedrock/eu.anthropic.claude-fable-5", + "name": "Claude Fable 5 (EU)", + "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": 11.0, + "output": 55.0, + "cache_read": 1.1, + "cache_write": 13.75 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "amazon-bedrock/eu.anthropic.claude-haiku-4.5-20251001-v1:0", "name": "Claude Haiku 4.5 (EU)", @@ -11432,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, @@ -11465,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, @@ -11549,6 +13086,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -11639,6 +13177,72 @@ "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)", + "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/global.anthropic.claude-haiku-4.5-20251001-v1:0", "name": "Claude Haiku 4.5 (Global)", @@ -11779,6 +13383,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -11869,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", @@ -11958,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)", @@ -11999,6 +13670,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -12089,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", @@ -12749,11 +14454,75 @@ } }, { - "id": "amazon-bedrock/openai.gpt-oss-120b-1:0", + "id": "amazon-bedrock/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-06-01", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.75, + "output": 16.5, + "cache_read": 0.275 + }, + "limit": { + "context": 272000, + "output": 128000 + } + }, + { + "id": "amazon-bedrock/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-06-01", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.5, + "output": 33.0, + "cache_read": 0.55 + }, + "limit": { + "context": 272000, + "output": 128000 + } + }, + { + "id": "amazon-bedrock/openai.gpt-oss-120b", "name": "gpt-oss-120b", "family": "gpt-oss", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2025-08-05", @@ -12776,12 +14545,68 @@ "output": 16384 } }, + { + "id": "amazon-bedrock/openai.gpt-oss-120b-1:0", + "name": "gpt-oss-120b", + "family": "gpt-oss", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.15, + "output": 0.6 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, + { + "id": "amazon-bedrock/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": false, + "cost": { + "input": 0.07, + "output": 0.3 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, { "id": "amazon-bedrock/openai.gpt-oss-20b-1:0", "name": "gpt-oss-20b", "family": "gpt-oss", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2025-08-05", @@ -13061,6 +14886,39 @@ "output": 262000 } }, + { + "id": "amazon-bedrock/us.anthropic.claude-fable-5", + "name": "Claude Fable 5 (US)", + "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/us.anthropic.claude-haiku-4.5-20251001-v1:0", "name": "Claude Haiku 4.5 (US)", @@ -13234,6 +15092,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -13324,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)", @@ -13343,7 +15235,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 1.35, "output": 5.4 @@ -13469,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", @@ -13558,7 +15480,7 @@ { "id": "ambient/moonshotai/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -13587,16 +15509,49 @@ "output": 262144 } }, + { + "id": "ambient/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", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.75, + "output": 3.5, + "cache_read": 0.16, + "cache_write": 0.0 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "ambient/zai-org/GLM-5.1-FP8", - "name": "GLM-5.1", + "name": "GLM 5.1", "family": "glm", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-03-27", - "last_updated": "2026-03-27", + "release_date": "2026-04-07", + "last_updated": "2026-04-07", "modalities": { "input": [ "text" @@ -13605,7 +15560,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 1.4, "output": 4.4, @@ -13618,181 +15573,16 @@ } }, { - "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", - "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", + "id": "anthropic/claude-fable-5", + "name": "Claude Fable 5", + "family": "claude-fable", "attachment": true, "reasoning": true, + "thinking_mode": "always_on_adaptive", "tool_call": true, - "temperature": true, - "knowledge": "2024-10-31", - "release_date": "2025-02-19", - "last_updated": "2025-02-19", + "temperature": false, + "release_date": "2026-06-07", + "last_updated": "2026-06-09", "modalities": { "input": [ "text", @@ -13805,14 +15595,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": 64000 + "context": 1000000, + "output": 128000 } }, { @@ -13848,75 +15638,9 @@ "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", + "name": "Claude Opus 4.1 (latest)", "family": "claude-opus", "attachment": true, "reasoning": true, @@ -13949,15 +15673,15 @@ }, { "id": "anthropic/claude-opus-4.5", - "name": "Claude Opus 4.5", + "name": "Claude Opus 4.5 (latest)", "family": "claude-opus", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", - "release_date": "2025-11-01", - "last_updated": "2025-11-01", + "knowledge": "2025-05", + "release_date": "2025-11-24", + "last_updated": "2025-11-24", "modalities": { "input": [ "text", @@ -13986,10 +15710,11 @@ "family": "claude-opus", "attachment": true, "reasoning": true, + "thinking_mode": "adaptive", "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": [ @@ -14019,10 +15744,11 @@ "family": "claude-opus", "attachment": true, "reasoning": true, + "thinking_mode": "adaptive", "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": [ @@ -14052,8 +15778,10 @@ "family": "claude-opus", "attachment": true, "reasoning": true, + "thinking_mode": "adaptive", "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -14078,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)", @@ -14173,7 +15835,7 @@ "cache_write": 3.75 }, "limit": { - "context": 200000, + "context": 1000000, "output": 64000 } }, @@ -14183,6 +15845,7 @@ "family": "claude-sonnet", "attachment": true, "reasoning": true, + "thinking_mode": "adaptive", "tool_call": true, "temperature": true, "knowledge": "2025-08-31", @@ -14207,9 +15870,870 @@ }, "limit": { "context": 1000000, + "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 + } + }, + { + "id": "anyapi/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", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 200000, "output": 64000 } }, + { + "id": "anyapi/anthropic/claude-opus-4.6", + "name": "Claude Opus 4.6", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05-31", + "release_date": "2026-02-05", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "anyapi/anthropic/claude-opus-4.7", + "name": "Claude Opus 4.7", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "anyapi/anthropic/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5 (latest)", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-07-31", + "release_date": "2025-09-29", + "last_updated": "2025-09-29", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 200000, + "output": 64000 + } + }, + { + "id": "anyapi/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", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "anyapi/cohere/command-r-plus-08", + "name": "Command R+", + "family": "command-r", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2024-06-01", + "release_date": "2024-08-30", + "last_updated": "2024-08-30", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": {}, + "limit": { + "context": 128000, + "output": 4000 + } + }, + { + "id": "anyapi/deepseek/deepseek-chat", + "name": "DeepSeek Chat", + "family": "deepseek", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-09", + "release_date": "2025-12-01", + "last_updated": "2026-02-28", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": {}, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "anyapi/deepseek/deepseek-r1", + "name": "DeepSeek Reasoner", + "family": "deepseek-thinking", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-09", + "release_date": "2025-12-01", + "last_updated": "2026-02-28", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": {}, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "anyapi/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": {}, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "anyapi/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": {}, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "anyapi/google/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", + "audio", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "anyapi/google/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", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "anyapi/google/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", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "anyapi/google/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", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "anyapi/google/gemini-3-pro-preview", + "name": "Gemini 3 Pro Preview", + "family": "gemini-pro", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2025-11-18", + "last_updated": "2025-11-18", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "anyapi/mistralai/devstral", + "name": "Devstral 2", + "family": "devstral", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-12", + "release_date": "2025-12-09", + "last_updated": "2025-12-09", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": {}, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "anyapi/mistralai/mistral-large", + "name": "Mistral Large 3", + "family": "mistral-large", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2024-11", + "release_date": "2024-11-01", + "last_updated": "2025-12-02", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": {}, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "anyapi/openai/gpt-4.1", + "name": "GPT-4.1", + "family": "gpt", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2024-04", + "release_date": "2025-04-14", + "last_updated": "2025-04-14", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1047576, + "output": 32768 + } + }, + { + "id": "anyapi/openai/gpt-4.1-mini", + "name": "GPT-4.1 mini", + "family": "gpt-mini", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2024-04", + "release_date": "2025-04-14", + "last_updated": "2025-04-14", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1047576, + "output": 32768 + } + }, + { + "id": "anyapi/openai/gpt-5", + "name": "GPT-5", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2024-09-30", + "release_date": "2025-08-07", + "last_updated": "2025-08-07", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "anyapi/openai/gpt-5-mini", + "name": "GPT-5 Mini", + "family": "gpt-mini", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2024-05-30", + "release_date": "2025-08-07", + "last_updated": "2025-08-07", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "anyapi/openai/gpt-5.1", + "name": "GPT-5.1", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2024-09-30", + "release_date": "2025-11-13", + "last_updated": "2025-11-13", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "anyapi/openai/gpt-5.2", + "name": "GPT-5.2", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2025-12-11", + "last_updated": "2025-12-11", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "anyapi/openai/gpt-5.4", + "name": "GPT-5.4", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-05", + "last_updated": "2026-03-05", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "anyapi/openai/o3", + "name": "o3", + "family": "o", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2024-05", + "release_date": "2025-04-16", + "last_updated": "2025-04-16", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 200000, + "output": 100000 + } + }, + { + "id": "anyapi/openai/o3-mini", + "name": "o3-mini", + "family": "o-mini", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2024-05", + "release_date": "2024-12-20", + "last_updated": "2025-01-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 200000, + "output": 100000 + } + }, + { + "id": "anyapi/openai/o4-mini", + "name": "o4-mini", + "family": "o-mini", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2024-05", + "release_date": "2025-04-16", + "last_updated": "2025-04-16", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 200000, + "output": 100000 + } + }, + { + "id": "anyapi/perplexity/sonar-pro", + "name": "Sonar Pro", + "family": "sonar-pro", + "attachment": true, + "reasoning": false, + "tool_call": false, + "temperature": true, + "knowledge": "2025-09-01", + "release_date": "2024-01-01", + "last_updated": "2025-09-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 200000, + "output": 8192 + } + }, + { + "id": "anyapi/perplexity/sonar-reasoning-pro", + "name": "Sonar Reasoning Pro", + "family": "sonar-reasoning", + "attachment": true, + "reasoning": true, + "tool_call": false, + "temperature": true, + "knowledge": "2025-09-01", + "release_date": "2024-01-01", + "last_updated": "2025-09-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 128000, + "output": 4096 + } + }, + { + "id": "anyapi/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-04-17", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1000000, + "output": 30000 + } + }, { "id": "atomic-chat/Meta-Llama-3_1-8B-Instruct-GGUF", "name": "Meta Llama 3.1 8B Instruct (GGUF)", @@ -14520,8 +17044,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", @@ -14554,8 +17078,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", @@ -14621,8 +17145,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-03-27", - "last_updated": "2026-03-27", + "release_date": "2026-04-07", + "last_updated": "2026-04-07", "modalities": { "input": [ "text" @@ -14631,7 +17155,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 1.4, "output": 4.4, @@ -14676,7 +17200,7 @@ { "id": "auriko/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi-k2.5", + "family": "kimi-k2", "attachment": false, "reasoning": true, "tool_call": true, @@ -14707,7 +17231,7 @@ { "id": "auriko/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -14958,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", @@ -15224,15 +17781,15 @@ }, { "id": "azure-cognitive-services/deepseek-r1", - "name": "DeepSeek-R1-0528", + "name": "DeepSeek-R1", "family": "deepseek-thinking", "attachment": false, "reasoning": true, - "tool_call": true, + "tool_call": false, "temperature": true, "knowledge": "2024-07", - "release_date": "2025-05-28", - "last_updated": "2025-05-28", + "release_date": "2025-01-20", + "last_updated": "2025-01-20", "modalities": { "input": [ "text" @@ -15369,15 +17926,15 @@ }, { "id": "azure-cognitive-services/gpt-3.5-turbo", - "name": "GPT-3.5 Turbo 1106", + "name": "GPT-3.5 Turbo 0125", "family": "gpt", "attachment": false, "reasoning": false, "tool_call": false, "temperature": true, "knowledge": "2021-08", - "release_date": "2023-11-06", - "last_updated": "2023-11-06", + "release_date": "2024-01-25", + "last_updated": "2024-01-25", "modalities": { "input": [ "text" @@ -15388,8 +17945,8 @@ }, "open_weights": false, "cost": { - "input": 1.0, - "output": 2.0 + "input": 0.5, + "output": 1.5 }, "limit": { "context": 16384, @@ -16299,7 +18856,7 @@ } }, { - "id": "azure-cognitive-services/grok-4-fast", + "id": "azure-cognitive-services/grok-4-fast-reasoning", "name": "Grok 4 Fast (Reasoning)", "family": "grok", "attachment": true, @@ -16362,14 +18919,14 @@ { "id": "azure-cognitive-services/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2026-02-06", - "last_updated": "2026-02-06", + "release_date": "2026-01", + "last_updated": "2026-01", "modalities": { "input": [ "text", @@ -16392,14 +18949,14 @@ { "id": "azure-cognitive-services/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2026-04-22", - "last_updated": "2026-04-22", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", "modalities": { "input": [ "text", @@ -16568,35 +19125,6 @@ "output": 8192 } }, - { - "id": "azure-cognitive-services/mai-ds-r1", - "name": "MAI-DS-R1", - "family": "mai", - "attachment": false, - "reasoning": true, - "tool_call": false, - "temperature": true, - "knowledge": "2024-06", - "release_date": "2025-01-20", - "last_updated": "2025-01-20", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.35, - "output": 5.4 - }, - "limit": { - "context": 128000, - "output": 8192 - } - }, { "id": "azure-cognitive-services/meta-llama-3-70b-instruct", "name": "Meta-Llama-3-70B-Instruct", @@ -16978,36 +19506,6 @@ "output": 65536 } }, - { - "id": "azure-cognitive-services/o1-preview", - "name": "o1-preview", - "family": "o", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": false, - "knowledge": "2023-09", - "release_date": "2024-09-12", - "last_updated": "2024-09-12", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 16.5, - "output": 66.0, - "cache_read": 8.25 - }, - "limit": { - "context": 128000, - "output": 32768 - } - }, { "id": "azure-cognitive-services/o3", "name": "o3", @@ -17334,10 +19832,10 @@ }, { "id": "azure-cognitive-services/phi-4", - "name": "Phi-4-reasoning", + "name": "Phi-4", "family": "phi", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": false, "temperature": true, "knowledge": "2023-10", @@ -17357,7 +19855,7 @@ "output": 0.5 }, "limit": { - "context": 32000, + "context": 128000, "output": 4096 } }, @@ -17390,6 +19888,35 @@ "output": 4096 } }, + { + "id": "azure-cognitive-services/phi-4-mini-reasoning", + "name": "Phi-4-mini-reasoning", + "family": "phi", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2023-10", + "release_date": "2024-12-11", + "last_updated": "2024-12-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.075, + "output": 0.3 + }, + "limit": { + "context": 128000, + "output": 4096 + } + }, { "id": "azure-cognitive-services/phi-4-multimodal", "name": "Phi-4-multimodal", @@ -17421,6 +19948,35 @@ "output": 4096 } }, + { + "id": "azure-cognitive-services/phi-4-reasoning", + "name": "Phi-4-reasoning", + "family": "phi", + "attachment": false, + "reasoning": true, + "tool_call": false, + "temperature": true, + "knowledge": "2023-10", + "release_date": "2024-12-11", + "last_updated": "2024-12-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.125, + "output": 0.5 + }, + "limit": { + "context": 32000, + "output": 4096 + } + }, { "id": "azure-cognitive-services/phi-4-reasoning-plus", "name": "Phi-4-reasoning-plus", @@ -17531,6 +20087,39 @@ "output": 1536 } }, + { + "id": "azure/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": "azure/claude-haiku-4.5", "name": "Claude Haiku 4.5", @@ -17663,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", @@ -17729,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", @@ -18105,17 +20760,75 @@ "output": 128000 } }, + { + "id": "azure/deepseek-v4-flash", + "name": "DeepSeek-V4-Flash", + "family": "deepseek-flash", + "attachment": false, + "reasoning": true, + "tool_call": false, + "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.19, + "output": 0.51 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "azure/deepseek-v4-pro", + "name": "DeepSeek-V4-Pro", + "family": "deepseek-thinking", + "attachment": false, + "reasoning": true, + "tool_call": false, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.74, + "output": 3.48 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, { "id": "azure/gpt-3.5-turbo", - "name": "GPT-3.5 Turbo 0301", + "name": "GPT-3.5 Turbo 0125", "family": "gpt", "attachment": false, "reasoning": false, "tool_call": false, "temperature": true, "knowledge": "2021-08", - "release_date": "2023-03-01", - "last_updated": "2023-03-01", + "release_date": "2024-01-25", + "last_updated": "2024-01-25", "modalities": { "input": [ "text" @@ -18126,12 +20839,12 @@ }, "open_weights": false, "cost": { - "input": 1.5, - "output": 2.0 + "input": 0.5, + "output": 1.5 }, "limit": { - "context": 4096, - "output": 4096 + "context": 16384, + "output": 16384 } }, { @@ -19099,36 +21812,98 @@ } }, { - "id": "azure/grok-4-20", - "name": "Grok 4.20 (Reasoning)", - "family": "grok", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-09", - "release_date": "2026-04-08", - "last_updated": "2026-04-08", + "id": "azure/gpt-image-1", + "name": "GPT-Image-1", + "family": "gpt-image", + "attachment": true, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2025-04-24", + "last_updated": "2025-04-24", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ - "text" + "image" ] }, "open_weights": false, "cost": { - "input": 2.0, - "output": 6.0 + "input": 5.0, + "output": 40.0, + "cache_read": 1.25 }, "limit": { - "context": 262000, - "output": 8192 + "context": 0, + "output": 0 } }, { - "id": "azure/grok-4-20-non", + "id": "azure/gpt-image-1.5", + "name": "GPT-Image-1.5", + "family": "gpt-image", + "attachment": true, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2025-11-25", + "last_updated": "2025-11-25", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text", + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 32.0, + "cache_read": 1.25 + }, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "azure/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": 5.0, + "output": 30.0, + "cache_read": 1.25 + }, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "azure/grok-4-20-non-reasoning", "name": "Grok 4.20 (Non-Reasoning)", "family": "grok", "attachment": false, @@ -19157,7 +21932,36 @@ } }, { - "id": "azure/grok-4-fast", + "id": "azure/grok-4-20-reasoning", + "name": "Grok 4.20 (Reasoning)", + "family": "grok", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-09", + "release_date": "2026-04-08", + "last_updated": "2026-04-08", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 6.0 + }, + "limit": { + "context": 262000, + "output": 8192 + } + }, + { + "id": "azure/grok-4-fast-reasoning", "name": "Grok 4 Fast (Reasoning)", "family": "grok", "attachment": true, @@ -19188,11 +21992,11 @@ } }, { - "id": "azure/grok-4.1-fast", - "name": "Grok 4.1 Fast (Reasoning)", + "id": "azure/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-06-27", @@ -19218,11 +22022,11 @@ } }, { - "id": "azure/grok-4.1-fast-non", - "name": "Grok 4.1 Fast (Non-Reasoning)", + "id": "azure/grok-4.1-fast-reasoning", + "name": "Grok 4.1 Fast (Reasoning)", "family": "grok", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2025-06-27", @@ -19280,7 +22084,7 @@ { "id": "azure/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": true, "tool_call": true, @@ -19310,7 +22114,7 @@ { "id": "azure/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": true, "tool_call": true, @@ -19486,35 +22290,6 @@ "output": 8192 } }, - { - "id": "azure/mai-ds-r1", - "name": "MAI-DS-R1", - "family": "mai", - "attachment": false, - "reasoning": true, - "tool_call": false, - "temperature": true, - "knowledge": "2024-06", - "release_date": "2025-01-20", - "last_updated": "2025-01-20", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.35, - "output": 5.4 - }, - "limit": { - "context": 128000, - "output": 8192 - } - }, { "id": "azure/meta-llama-3-70b-instruct", "name": "Meta-Llama-3-70B-Instruct", @@ -19896,36 +22671,6 @@ "output": 65536 } }, - { - "id": "azure/o1-preview", - "name": "o1-preview", - "family": "o", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": false, - "knowledge": "2023-09", - "release_date": "2024-09-12", - "last_updated": "2024-09-12", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 16.5, - "output": 66.0, - "cache_read": 8.25 - }, - "limit": { - "context": 128000, - "output": 32768 - } - }, { "id": "azure/o3", "name": "o3", @@ -20281,6 +23026,35 @@ }, { "id": "azure/phi-4-mini", + "name": "Phi-4-mini", + "family": "phi", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2023-10", + "release_date": "2024-12-11", + "last_updated": "2024-12-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.075, + "output": 0.3 + }, + "limit": { + "context": 128000, + "output": 4096 + } + }, + { + "id": "azure/phi-4-mini-reasoning", "name": "Phi-4-mini-reasoning", "family": "phi", "attachment": false, @@ -20339,6 +23113,35 @@ "output": 4096 } }, + { + "id": "azure/phi-4-reasoning", + "name": "Phi-4-reasoning", + "family": "phi", + "attachment": false, + "reasoning": true, + "tool_call": false, + "temperature": true, + "knowledge": "2023-10", + "release_date": "2024-12-11", + "last_updated": "2024-12-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.125, + "output": 0.5 + }, + "limit": { + "context": 32000, + "output": 4096 + } + }, { "id": "azure/phi-4-reasoning-plus", "name": "Phi-4-reasoning-plus", @@ -20536,35 +23339,6 @@ "output": 204000 } }, - { - "id": "baseten/deepseek-ai/DeepSeek-V3", - "name": "DeepSeek V3 0324", - "family": "deepseek", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-12", - "release_date": "2025-03-24", - "last_updated": "2025-03-24", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.77, - "output": 0.77 - }, - "limit": { - "context": 164000, - "output": 131000 - } - }, { "id": "baseten/deepseek-ai/DeepSeek-V3.1", "name": "DeepSeek V3.1", @@ -20593,38 +23367,9 @@ "output": 131000 } }, - { - "id": "baseten/deepseek-ai/DeepSeek-V3.2", - "name": "DeepSeek V3.2", - "family": "deepseek", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-10", - "release_date": "2025-12-01", - "last_updated": "2026-03-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.3, - "output": 0.45 - }, - "limit": { - "context": 163800, - "output": 131100 - } - }, { "id": "baseten/deepseek-ai/DeepSeek-V4-Pro", - "name": "DeepSeek V4 Pro", + "name": "Deepseek V4 Pro", "family": "deepseek-thinking", "attachment": false, "reasoning": true, @@ -20645,65 +23390,7 @@ "cost": { "input": 1.74, "output": 3.48, - "cache_read": 0.15 - }, - "limit": { - "context": 1000000, - "output": 384000 - } - }, - { - "id": "baseten/moonshotai/Kimi-K2-Instruct", - "name": "Kimi K2 Instruct 0905", - "family": "kimi", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2025-08", - "release_date": "2025-09-05", - "last_updated": "2026-03-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.6, - "output": 2.5 - }, - "limit": { - "context": 262144, - "output": 262144 - } - }, - { - "id": "baseten/moonshotai/Kimi-K2-Thinking", - "name": "Kimi K2 Thinking", - "family": "kimi-thinking", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-08", - "release_date": "2025-11-06", - "last_updated": "2026-03-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.6, - "output": 2.5 + "cache_read": 0.145 }, "limit": { "context": 262144, @@ -20713,7 +23400,7 @@ { "id": "baseten/moonshotai/Kimi-K2.5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -20723,7 +23410,8 @@ "last_updated": "2026-02-12", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -20732,17 +23420,18 @@ "open_weights": true, "cost": { "input": 0.6, - "output": 3.0 + "output": 3.0, + "cache_read": 0.12 }, "limit": { - "context": 262144, - "output": 8192 + "context": 262000, + "output": 262000 } }, { "id": "baseten/moonshotai/Kimi-K2.6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -20753,8 +23442,7 @@ "modalities": { "input": [ "text", - "image", - "video" + "image" ], "output": [ "text" @@ -20767,13 +23455,73 @@ "cache_read": 0.16 }, "limit": { - "context": 262144, - "output": 262144 + "context": 262000, + "output": 262000 + } + }, + { + "id": "baseten/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.95, + "output": 4.0, + "cache_read": 0.16 + }, + "limit": { + "context": 262000, + "output": 262000 + } + }, + { + "id": "baseten/nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B", + "name": "Nemotron 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.6, + "output": 2.4, + "cache_read": 0.12 + }, + "limit": { + "context": 202800, + "output": 202800 } }, { "id": "baseten/nvidia/Nemotron-120B-A12B", - "name": "Nemotron 3 Super", + "name": "Nemotron Super", "family": "nemotron", "attachment": false, "reasoning": true, @@ -20793,16 +23541,17 @@ "open_weights": true, "cost": { "input": 0.3, - "output": 0.75 + "output": 0.75, + "cache_read": 0.06 }, "limit": { - "context": 262144, - "output": 32678 + "context": 202800, + "output": 202800 } }, { "id": "baseten/openai/gpt-oss-120b", - "name": "GPT OSS 120B", + "name": "OpenAI GPT 120B", "family": "gpt-oss", "attachment": false, "reasoning": true, @@ -20825,42 +23574,13 @@ "output": 0.5 }, "limit": { - "context": 128000, - "output": 128000 - } - }, - { - "id": "baseten/zai-org/GLM-4.6", - "name": "GLM 4.6", - "family": "glm", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2025-08-31", - "release_date": "2025-09-16", - "last_updated": "2025-09-16", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.6, - "output": 2.2 - }, - "limit": { - "context": 200000, - "output": 200000 + "context": 128072, + "output": 128072 } }, { "id": "baseten/zai-org/GLM-4.7", - "name": "GLM-4.7", + "name": "GLM 4.7", "family": "glm", "attachment": false, "reasoning": true, @@ -20880,16 +23600,17 @@ "open_weights": true, "cost": { "input": 0.6, - "output": 2.2 + "output": 2.2, + "cache_read": 0.12 }, "limit": { - "context": 204800, - "output": 131072 + "context": 200000, + "output": 200000 } }, { "id": "baseten/zai-org/GLM-5", - "name": "GLM-5", + "name": "GLM 5", "family": "glm", "attachment": false, "reasoning": true, @@ -20909,11 +23630,70 @@ "open_weights": true, "cost": { "input": 0.95, - "output": 3.15 + "output": 3.15, + "cache_read": 0.2 }, "limit": { - "context": 202752, - "output": 131072 + "context": 202800, + "output": 202800 + } + }, + { + "id": "baseten/zai-org/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.3, + "output": 4.3, + "cache_read": 0.26 + }, + "limit": { + "context": 202800, + "output": 202800 + } + }, + { + "id": "baseten/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.3 + }, + "limit": { + "context": 202720, + "output": 202720 } }, { @@ -21039,7 +23819,7 @@ { "id": "berget/moonshotai/Kimi-K2.6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -21089,8 +23869,8 @@ }, "open_weights": true, "cost": { - "input": 0.44, - "output": 0.99 + "input": 0.22, + "output": 0.83 }, "limit": { "context": 128000, @@ -21126,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", @@ -21135,7 +23972,7 @@ "tool_call": true, "temperature": true, "release_date": "2025-08-05", - "last_updated": "2025-08-05", + "last_updated": "2026-06-10", "modalities": { "input": [ "text" @@ -21146,52 +23983,23 @@ }, "open_weights": true, "cost": { - "input": 0.25, - "output": 0.69 + "input": 0.35, + "output": 0.75 }, "limit": { "context": 131072, - "output": 32768 - } - }, - { - "id": "cerebras/llama3.1-8b", - "name": "Llama 3.1 8B", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2023-12", - "release_date": "2025-01-01", - "last_updated": "2025-01-01", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.1, - "output": 0.1 - }, - "limit": { - "context": 32000, - "output": 8000 + "output": 40960 } }, { "id": "cerebras/zai-glm-4.7", "name": "Z.AI GLM-4.7", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-01-10", - "last_updated": "2026-01-10", + "release_date": "2026-01-07", + "last_updated": "2026-06-10", "modalities": { "input": [ "text" @@ -21209,7 +24017,7 @@ }, "limit": { "context": 131072, - "output": 40000 + "output": 40960 } }, { @@ -21220,8 +24028,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-15", - "last_updated": "2026-04-25", + "release_date": "2026-02-12", + "last_updated": "2026-02-12", "modalities": { "input": [ "text" @@ -21242,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" @@ -21323,157 +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, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "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", @@ -21482,8 +24087,9 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-04-25", - "last_updated": "2026-04-25", + "knowledge": "2025-04", + "release_date": "2025-04", + "last_updated": "2025-04", "modalities": { "input": [ "text" @@ -21494,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", @@ -21569,8 +24117,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-18", - "last_updated": "2026-04-25", + "release_date": "2026-02-15", + "last_updated": "2026-02-15", "modalities": { "input": [ "text", @@ -21582,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, @@ -21599,8 +24147,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-04-25", - "last_updated": "2026-04-25", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", "modalities": { "input": [ "text", @@ -21612,189 +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, - "release_date": "2026-04-25", - "last_updated": "2026-04-25", - "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, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "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", @@ -21803,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" @@ -21815,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, @@ -21832,8 +24206,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-04-25", - "last_updated": "2026-04-25", + "release_date": "2026-04-02", + "last_updated": "2026-04-02", "modalities": { "input": [ "text", @@ -21845,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, @@ -21857,14 +24231,14 @@ { "id": "chutes/moonshotai/Kimi-K2.5-TEE", "name": "Kimi K2.5 TEE", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2026-01-27", - "last_updated": "2026-04-25", + "release_date": "2026-01", + "last_updated": "2026-01", "modalities": { "input": [ "text", @@ -21889,14 +24263,14 @@ { "id": "chutes/moonshotai/Kimi-K2.6-TEE", "name": "Kimi K2.6 TEE", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, "knowledge": "2025-12", - "release_date": "2026-04-20", - "last_updated": "2026-04-25", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", "modalities": { "input": [ "text", @@ -21909,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, @@ -21919,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" @@ -21968,309 +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, - "release_date": "2025-12-29", - "last_updated": "2026-04-25", - "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", @@ -22279,8 +24330,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-14", - "last_updated": "2026-04-25", + "release_date": "2026-02-12", + "last_updated": "2026-02-12", "modalities": { "input": [ "text" @@ -22300,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", @@ -22337,8 +24359,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-04-08", - "last_updated": "2026-04-25", + "release_date": "2026-04-07", + "last_updated": "2026-04-07", "modalities": { "input": [ "text" @@ -22349,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", @@ -22536,7 +24587,7 @@ { "id": "clarifai/moonshotai/chat-completion/models/Kimi-K2_6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -22712,11 +24763,13 @@ "tool_call": true, "knowledge": "2026-05", "release_date": "2026-05-12", - "last_updated": "2026-05-12", + "last_updated": "2026-06-02", "modalities": { "input": [ "text", - "image" + "image", + "audio", + "video" ], "output": [ "text" @@ -22726,7 +24779,38 @@ "cost": { "input": 0.5, "output": 2.0, - "cache_read": 0.05 + "cache_read": 0.15 + }, + "limit": { + "context": 256000, + "output": 64000 + } + }, + { + "id": "claudinio/claudius", + "name": "Claudius", + "attachment": true, + "reasoning": true, + "tool_call": true, + "knowledge": "2026-05", + "release_date": "2026-05-12", + "last_updated": "2026-05-12", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 8.0, + "cache_read": 0.9 }, "limit": { "context": 256000, @@ -23040,6 +25124,39 @@ "output": 8192 } }, + { + "id": "cloudflare-ai-gateway/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": "cloudflare-ai-gateway/anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5 (latest)", @@ -23246,6 +25363,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -23369,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", @@ -24737,7 +26888,7 @@ { "id": "cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -24768,7 +26919,7 @@ { "id": "cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -25134,13 +27285,14 @@ { "id": "cloudflare-workers-ai/@cf/deepseek-ai/deepseek-r1-distill-qwen-32b", "name": "Deepseek R1 Distill Qwen 32B", - "family": "deepseek", + "family": "deepseek-thinking", "attachment": false, "reasoning": true, "tool_call": false, "temperature": true, - "release_date": "2025-01-22", - "last_updated": "2025-01-22", + "knowledge": "2024-07", + "release_date": "2025-01-20", + "last_updated": "2025-05-29", "modalities": { "input": [ "text" @@ -25167,8 +27319,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-12-15", - "last_updated": "2025-12-15", + "release_date": "2026-04-02", + "last_updated": "2026-04-02", "modalities": { "input": [ "text", @@ -25337,6 +27489,7 @@ "reasoning": false, "tool_call": true, "temperature": true, + "knowledge": "2023-12", "release_date": "2024-12-06", "last_updated": "2024-12-06", "modalities": { @@ -25365,8 +27518,9 @@ "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-04-16", - "last_updated": "2025-04-16", + "knowledge": "2024-08", + "release_date": "2025-04-05", + "last_updated": "2025-04-05", "modalities": { "input": [ "text", @@ -25445,14 +27599,14 @@ { "id": "cloudflare-workers-ai/@cf/moonshotai/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi", + "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", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", "modalities": { "input": [ "text", @@ -25473,6 +27627,37 @@ "output": 256000 } }, + { + "id": "cloudflare-workers-ai/@cf/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.95, + "output": 4.0, + "cache_read": 0.19 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "cloudflare-workers-ai/@cf/nvidia/nemotron-3-120b-a12b", "name": "Nemotron 3 Super 120B", @@ -25670,6 +27855,35 @@ "output": 131072 } }, + { + "id": "cloudflare-workers-ai/@cf/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": 262144, + "output": 262144 + } + }, { "id": "cohere/c4ai-aya-expanse-32b", "name": "Aya Expanse 32B", @@ -25797,6 +28011,36 @@ "output": 8000 } }, + { + "id": "cohere/command-a-plus-05", + "name": "Command A Plus", + "family": "command-a", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04-01", + "release_date": "2026-05-20", + "last_updated": "2026-06-09", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 2.5, + "output": 10.0 + }, + "limit": { + "context": 128000, + "output": 64000 + } + }, { "id": "cohere/command-a-reasoning-08", "name": "Command A Reasoning", @@ -25952,8 +28196,8 @@ "tool_call": true, "temperature": true, "knowledge": "2024-06-01", - "release_date": "2024-02-27", - "last_updated": "2024-02-27", + "release_date": "2024-12-02", + "last_updated": "2024-12-02", "modalities": { "input": [ "text" @@ -26001,6 +28245,35 @@ "output": 4000 } }, + { + "id": "cohere/north-mini-code-1.0", + "name": "North Mini Code", + "family": "north", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-09-23", + "release_date": "2026-06-09", + "last_updated": "2026-06-09", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 256000, + "output": 64000 + } + }, { "id": "cortecs/claude-4.5-sonnet", "name": "Claude 4.5 Sonnet", @@ -26189,6 +28462,39 @@ "output": 128000 } }, + { + "id": "cortecs/claude-opus4-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.64, + "output": 28.198, + "cache_read": 0.563, + "cache_write": 7.049 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "cortecs/claude-sonnet-4", "name": "Claude Sonnet 4", @@ -26425,35 +28731,6 @@ "output": 262000 } }, - { - "id": "cortecs/devstral-small", - "name": "Devstral Small 2 2512", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2025-12", - "release_date": "2025-12-09", - "last_updated": "2025-12-09", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 262000, - "output": 262000 - } - }, { "id": "cortecs/gemini-2.5-pro", "name": "Gemini 2.5 Pro", @@ -26628,6 +28905,36 @@ "output": 202752 } }, + { + "id": "cortecs/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": 1.235, + "output": 4.118, + "cache_read": 0.308, + "cache_write": 1.544 + }, + "limit": { + "context": 200000, + "output": 131072 + } + }, { "id": "cortecs/glm-5.1", "name": "GLM-5.1", @@ -26657,6 +28964,68 @@ "output": 131072 } }, + { + "id": "cortecs/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.44, + "output": 4.53, + "cache_read": 0.39 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "cortecs/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": [ + "text", + "image", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.235, + "output": 4.118, + "cache_read": 0.308, + "cache_write": 1.544 + }, + "limit": { + "context": 200000, + "output": 131072 + } + }, { "id": "cortecs/gpt-4.1", "name": "GPT 4.1", @@ -26687,12 +29056,44 @@ "output": 32768 } }, + { + "id": "cortecs/gpt-5.4", + "name": "GPT-5.4", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-05", + "last_updated": "2026-03-05", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 16.13, + "cache_read": 0.25 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, { "id": "cortecs/gpt-oss-120b", "name": "GPT Oss 120b", "family": "gpt-oss", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "knowledge": "2024-01", @@ -26775,7 +29176,7 @@ { "id": "cortecs/kimi-k2-instruct", "name": "Kimi K2 Instruct", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -26890,6 +29291,38 @@ "output": 256000 } }, + { + "id": "cortecs/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": 1.28, + "output": 4.63, + "cache_read": 0.32 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "cortecs/llama-3.1-405b-instruct", "name": "Llama 3.1 405B Instruct", @@ -26948,6 +29381,38 @@ "output": 131000 } }, + { + "id": "cortecs/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.124, + "output": 0.603, + "cache_read": 0.03, + "cache_write": 0.151 + }, + "limit": { + "context": 1000000, + "output": 16384 + } + }, { "id": "cortecs/minimax-m2", "name": "MiniMax-M2", @@ -27061,6 +29526,37 @@ "output": 196072 } }, + { + "id": "cortecs/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.355, + "output": 1.775, + "cache_read": 0.089 + }, + "limit": { + "context": 512000, + "output": 128000 + } + }, { "id": "cortecs/mistral-large", "name": "Mistral Large 3 2512", @@ -27123,6 +29619,7 @@ { "id": "cortecs/nemotron-3-super-120b-a12b", "name": "Nemotron 3 Super 120B A12B", + "family": "nemotron", "attachment": false, "reasoning": true, "tool_call": true, @@ -27529,7 +30026,7 @@ }, { "id": "crof/deepseek-v4-pro-lightning", - "name": "DeepSeek V4 Pro", + "name": "DeepSeek V4 Pro Lightning", "family": "deepseek-thinking", "attachment": false, "reasoning": true, @@ -27657,8 +30154,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-11", - "last_updated": "2026-02-11", + "release_date": "2026-02-12", + "last_updated": "2026-02-12", "modalities": { "input": [ "text" @@ -27687,8 +30184,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-03-27", - "last_updated": "2026-03-27", + "release_date": "2026-04-07", + "last_updated": "2026-04-07", "modalities": { "input": [ "text" @@ -27697,7 +30194,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.45, "output": 2.15, @@ -27710,14 +30207,15 @@ } }, { - "id": "crof/greg-1", - "name": "Greg 1 Normal", + "id": "crof/glm-5.2", + "name": "GLM-5.2", + "family": "glm", "attachment": false, - "reasoning": false, - "tool_call": false, + "reasoning": true, + "tool_call": true, "temperature": true, - "release_date": "2026-01-27", - "last_updated": "2026-01-27", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "modalities": { "input": [ "text" @@ -27726,15 +30224,15 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { - "input": 0.1, - "output": 0.3, - "cache_read": 0.02 + "input": 0.5, + "output": 2.2, + "cache_read": 0.08 }, "limit": { - "context": 229376, - "output": 229376 + "context": 1000000, + "output": 131072 } }, { @@ -27766,14 +30264,14 @@ } }, { - "id": "crof/greg-1-super", - "name": "Greg 1 Super", + "id": "crof/greg-2-super", + "name": "Greg 2 Super", "attachment": false, "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2026-01-27", - "last_updated": "2026-01-27", + "release_date": "2026-06-14", + "last_updated": "2026-06-14", "modalities": { "input": [ "text" @@ -27784,9 +30282,37 @@ }, "open_weights": false, "cost": { - "input": 1.0, + "input": 1.5, "output": 5.0, - "cache_read": 0.2 + "cache_read": 0.25 + }, + "limit": { + "context": 229376, + "output": 229376 + } + }, + { + "id": "crof/greg-2-ultra", + "name": "Greg 2 Ultra", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-06-14", + "last_updated": "2026-06-14", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 10.0, + "cache_read": 0.5 }, "limit": { "context": 229376, @@ -27824,7 +30350,7 @@ { "id": "crof/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi-k2.5", + "family": "kimi-k2", "attachment": false, "reasoning": true, "tool_call": true, @@ -27856,7 +30382,7 @@ { "id": "crof/kimi-k2.5-lightning", "name": "Kimi K2.5 (Lightning)", - "family": "kimi-k2.5", + "family": "kimi-k2", "attachment": false, "reasoning": true, "tool_call": true, @@ -27887,7 +30413,7 @@ { "id": "crof/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -27916,6 +30442,38 @@ "output": 262144 } }, + { + "id": "crof/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.55, + "output": 2.25, + "cache_read": 0.05 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "crof/mimo-v2.5-pro", "name": "MiMo-V2.5-Pro", @@ -27951,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", @@ -28072,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)", @@ -28146,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": { @@ -28345,8 +32081,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", @@ -28379,8 +32115,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", @@ -28877,63 +32613,6 @@ "output": 32768 } }, - { - "id": "deepinfra/MiniMaxAI/MiniMax-M2", - "name": "MiniMax M2", - "family": "minimax", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-10", - "release_date": "2025-11-13", - "last_updated": "2025-11-13", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.254, - "output": 1.02 - }, - "limit": { - "context": 262144, - "output": 32768 - } - }, - { - "id": "deepinfra/MiniMaxAI/MiniMax-M2.1", - "name": "MiniMax M2.1", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-06", - "release_date": "2025-12-23", - "last_updated": "2025-12-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.28, - "output": 1.2 - }, - "limit": { - "context": 196608, - "output": 196608 - } - }, { "id": "deepinfra/MiniMaxAI/MiniMax-M2.5", "name": "MiniMax M2.5", @@ -28955,27 +32634,25 @@ }, "open_weights": true, "cost": { - "input": 0.27, - "output": 0.95, - "cache_read": 0.03, - "cache_write": 0.375 + "input": 0.15, + "output": 1.15, + "cache_read": 0.03 }, "limit": { - "context": 204800, + "context": 196608, "output": 131072 } }, { - "id": "deepinfra/Qwen/Qwen3-Coder-480B-A35B-Instruct", - "name": "Qwen3 Coder 480B A35B Instruct", - "family": "qwen", + "id": "deepinfra/MiniMaxAI/MiniMax-M2.7", + "name": "MiniMax-M2.7", + "family": "minimax", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-04", - "release_date": "2025-07-23", - "last_updated": "2025-07-23", + "release_date": "2026-03-18", + "last_updated": "2026-03-18", "modalities": { "input": [ "text" @@ -28986,12 +32663,73 @@ }, "open_weights": true, "cost": { - "input": 0.4, - "output": 1.6 + "input": 0.25, + "output": 1.0, + "cache_read": 0.05 }, "limit": { - "context": 262144, - "output": 66536 + "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 } }, { @@ -29016,13 +32754,135 @@ "open_weights": true, "cost": { "input": 0.3, - "output": 1.2 + "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", @@ -29046,8 +32906,9 @@ }, "open_weights": true, "cost": { - "input": 0.2, - "output": 0.95 + "input": 0.14, + "output": 1.0, + "cache_read": 0.05 }, "limit": { "context": 262144, @@ -29077,14 +32938,76 @@ }, "open_weights": true, "cost": { - "input": 0.54, - "output": 3.4 + "input": 0.45, + "output": 3.0, + "cache_read": 0.22 }, "limit": { "context": 262144, "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", @@ -29107,8 +33030,8 @@ }, "open_weights": true, "cost": { - "input": 0.2, - "output": 1.0 + "input": 0.15, + "output": 0.95 }, "limit": { "context": 262144, @@ -29116,20 +33039,18 @@ } }, { - "id": "deepinfra/anthropic/claude-3.7-sonnet", - "name": "Claude Sonnet 3.7 (Latest)", - "family": "claude-sonnet", - "attachment": true, - "reasoning": true, + "id": "deepinfra/Qwen/Qwen3.7-Max", + "name": "Qwen3.7 Max", + "family": "qwen", + "attachment": false, + "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2024-10-31", - "release_date": "2025-03-13", - "last_updated": "2025-03-13", + "release_date": "2026-05-21", + "last_updated": "2026-05-21", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" @@ -29137,43 +33058,77 @@ }, "open_weights": false, "cost": { - "input": 3.3, - "output": 16.5, - "cache_read": 0.33 + "input": 2.5, + "output": 7.5, + "cache_read": 0.5 }, "limit": { - "context": 200000, - "output": 64000 + "context": 256000, + "output": 65536 } }, { - "id": "deepinfra/anthropic/claude-4-opus", - "name": "Claude Opus 4", - "family": "claude-opus", + "id": "deepinfra/XiaomiMiMo/MiMo-V2.5", + "name": "MiMo-V2.5", + "family": "mimo", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", - "release_date": "2025-06-12", - "last_updated": "2025-06-12", + "knowledge": "2024-12", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", "modalities": { "input": [ "text", - "image" + "image", + "audio", + "video" ], "output": [ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { - "input": 16.5, - "output": 82.5 + "input": 0.4, + "output": 2.0, + "cache_read": 0.08 }, "limit": { - "context": 200000, - "output": 32000 + "context": 262144, + "output": 16384 + } + }, + { + "id": "deepinfra/XiaomiMiMo/MiMo-V2.5-Pro", + "name": "MiMo-V2.5-Pro", + "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", + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.0, + "output": 3.0, + "cache_read": 0.2 + }, + "limit": { + "context": 1048576, + "output": 16384 } }, { @@ -29181,7 +33136,7 @@ "name": "DeepSeek-R1-0528", "attachment": false, "reasoning": true, - "tool_call": false, + "tool_call": true, "temperature": true, "knowledge": "2024-07", "release_date": "2025-05-28", @@ -29255,13 +33210,13 @@ }, "open_weights": true, "cost": { - "input": 0.14, - "output": 0.28, - "cache_read": 0.0028 + "input": 0.09, + "output": 0.18, + "cache_read": 0.018 }, "limit": { - "context": 1000000, - "output": 384000 + "context": 1048576, + "output": 16384 } }, { @@ -29285,13 +33240,13 @@ }, "open_weights": true, "cost": { - "input": 0.435, - "output": 0.87, - "cache_read": 0.003625 + "input": 1.3, + "output": 2.6, + "cache_read": 0.1 }, "limit": { - "context": 65536, - "output": 65536 + "context": 1048576, + "output": 16384 } }, { @@ -29336,7 +33291,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "video" ], "output": [ "text" @@ -29352,114 +33308,6 @@ "output": 32768 } }, - { - "id": "deepinfra/meta-llama/Llama-3.1-70B-Instruct", - "name": "Llama 3.1 70B", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": true, - "release_date": "2024-07-23", - "last_updated": "2024-07-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.4, - "output": 0.4 - }, - "limit": { - "context": 131072, - "output": 16384 - } - }, - { - "id": "deepinfra/meta-llama/Llama-3.1-70B-Instruct-Turbo", - "name": "Llama 3.1 70B Turbo", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": true, - "release_date": "2024-07-23", - "last_updated": "2024-07-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.4, - "output": 0.4 - }, - "limit": { - "context": 131072, - "output": 16384 - } - }, - { - "id": "deepinfra/meta-llama/Llama-3.1-8B-Instruct", - "name": "Llama 3.1 8B", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": true, - "release_date": "2024-07-23", - "last_updated": "2024-07-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.02, - "output": 0.05 - }, - "limit": { - "context": 131072, - "output": 16384 - } - }, - { - "id": "deepinfra/meta-llama/Llama-3.1-8B-Instruct-Turbo", - "name": "Llama 3.1 8B Turbo", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": true, - "release_date": "2024-07-23", - "last_updated": "2024-07-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.02, - "output": 0.03 - }, - "limit": { - "context": 131072, - "output": 16384 - } - }, { "id": "deepinfra/meta-llama/Llama-3.3-70B-Instruct-Turbo", "name": "Llama 3.3 70B Turbo", @@ -29491,9 +33339,9 @@ "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": true, + "tool_call": false, "release_date": "2025-04-05", "last_updated": "2025-04-05", "modalities": { @@ -29511,7 +33359,7 @@ "output": 0.6 }, "limit": { - "context": 1000000, + "context": 1048576, "output": 16384 } }, @@ -29519,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", @@ -29535,76 +33383,18 @@ }, "open_weights": true, "cost": { - "input": 0.08, + "input": 0.1, "output": 0.3 }, "limit": { - "context": 10000000, + "context": 327680, "output": 16384 } }, - { - "id": "deepinfra/moonshotai/Kimi-K2-Instruct", - "name": "Kimi K2", - "family": "kimi", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-10", - "release_date": "2025-07-11", - "last_updated": "2025-07-11", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.5, - "output": 2.0 - }, - "limit": { - "context": 131072, - "output": 32768 - } - }, - { - "id": "deepinfra/moonshotai/Kimi-K2-Thinking", - "name": "Kimi K2 Thinking", - "family": "kimi-thinking", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-10", - "release_date": "2025-11-06", - "last_updated": "2025-11-07", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.47, - "output": 2.0 - }, - "limit": { - "context": 131072, - "output": 32768 - } - }, { "id": "deepinfra/moonshotai/Kimi-K2.5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -29624,8 +33414,9 @@ }, "open_weights": true, "cost": { - "input": 0.5, - "output": 2.8 + "input": 0.45, + "output": 2.25, + "cache_read": 0.07 }, "limit": { "context": 262144, @@ -29635,7 +33426,7 @@ { "id": "deepinfra/moonshotai/Kimi-K2.6", "name": "Kimi K2.6", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -29664,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", @@ -29684,8 +33594,8 @@ }, "open_weights": true, "cost": { - "input": 0.05, - "output": 0.24 + "input": 0.037, + "output": 0.17 }, "limit": { "context": 131072, @@ -29720,98 +33630,6 @@ "output": 16384 } }, - { - "id": "deepinfra/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", - "audio", - "video" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.4, - "output": 2.0, - "cache_read": 0.08 - }, - "limit": { - "context": 262144, - "output": 16384 - } - }, - { - "id": "deepinfra/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": 1.0, - "output": 3.0, - "cache_read": 0.2 - }, - "limit": { - "context": 1048576, - "output": 16384 - } - }, - { - "id": "deepinfra/zai-org/GLM-4.5", - "name": "GLM-4.5", - "family": "glm", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2025-04", - "release_date": "2025-07-28", - "last_updated": "2025-07-28", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.6, - "output": 2.2 - }, - "limit": { - "context": 131072, - "output": 98304 - } - }, { "id": "deepinfra/zai-org/GLM-4.6", "name": "GLM-4.6", @@ -29838,37 +33656,7 @@ "cache_read": 0.08 }, "limit": { - "context": 204800, - "output": 131072 - } - }, - { - "id": "deepinfra/zai-org/GLM-4.6V", - "name": "GLM-4.6V", - "family": "glm", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-04", - "release_date": "2025-09-30", - "last_updated": "2025-09-30", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.3, - "output": 0.9 - }, - "limit": { - "context": 204800, + "context": 202752, "output": 131072 } }, @@ -29893,7 +33681,7 @@ }, "open_weights": true, "cost": { - "input": 0.43, + "input": 0.4, "output": 1.75, "cache_read": 0.08 }, @@ -29924,7 +33712,8 @@ "open_weights": true, "cost": { "input": 0.06, - "output": 0.4 + "output": 0.4, + "cache_read": 0.01 }, "limit": { "context": 202752, @@ -29952,9 +33741,9 @@ }, "open_weights": true, "cost": { - "input": 0.8, - "output": 2.56, - "cache_read": 0.16 + "input": 0.6, + "output": 2.08, + "cache_read": 0.12 }, "limit": { "context": 202752, @@ -29982,15 +33771,44 @@ }, "open_weights": true, "cost": { - "input": 1.4, - "output": 4.4, - "cache_read": 0.26 + "input": 1.05, + "output": 3.5, + "cache_read": 0.205 }, "limit": { "context": 202752, "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", @@ -30014,7 +33832,7 @@ "cost": { "input": 0.14, "output": 0.28, - "cache_read": 0.028 + "cache_read": 0.0028 }, "limit": { "context": 1000000, @@ -30044,7 +33862,7 @@ "cost": { "input": 0.14, "output": 0.28, - "cache_read": 0.028 + "cache_read": 0.0028 }, "limit": { "context": 1000000, @@ -30425,6 +34243,32 @@ "output": 64000 } }, + { + "id": "digitalocean/anthropic-claude-fable-5", + "name": "Anthropic Claude Fable 5", + "family": "claude-fable", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-09", + "last_updated": "2026-06-12", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "digitalocean/anthropic-claude-haiku-4.5", "name": "Claude Haiku 4.5", @@ -31059,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)", @@ -31090,7 +34992,7 @@ { "id": "digitalocean/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": true, "tool_call": true, @@ -31119,7 +35021,7 @@ { "id": "digitalocean/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -31454,6 +35356,31 @@ "output": 65536 } }, + { + "id": "digitalocean/nemotron-3-ultra-550b", + "name": "Nemotron 3 Ultra", + "family": "nemotron", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-04", + "last_updated": "2026-06-12", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 131072, + "output": 8192 + } + }, { "id": "digitalocean/nemotron-nano-12b-v2-vl", "name": "Nemotron Nano 12B v2 VL", @@ -32448,8 +36375,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-11", - "last_updated": "2026-02-11", + "release_date": "2026-02-12", + "last_updated": "2026-02-12", "modalities": { "input": [ "text" @@ -32476,8 +36403,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-03-27", - "last_updated": "2026-03-27", + "release_date": "2026-04-07", + "last_updated": "2026-04-07", "modalities": { "input": [ "text" @@ -32486,7 +36413,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 1.25, "output": 3.89 @@ -32656,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", @@ -32710,12 +36610,39 @@ }, "open_weights": true, "cost": { - "input": 0.12, - "output": 0.12 + "input": 0.115, + "output": 0.115 }, "limit": { "context": 40960, - "output": 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 } }, { @@ -32739,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", @@ -32766,8 +36783,8 @@ }, "open_weights": true, "cost": { - "input": 0.12, - "output": 0.12 + "input": 0.114, + "output": 0.114 }, "limit": { "context": 512, @@ -32775,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", @@ -32794,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 } }, { @@ -32849,8 +36840,8 @@ }, "open_weights": true, "cost": { - "input": 0.00236, - "output": 0.00236 + "input": 0.0023, + "output": 0.0023 }, "limit": { "context": 32000, @@ -32858,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", - "family": "kimi", - "attachment": false, + "id": "evroc/moonshotai/Kimi-K2.6", + "name": "Kimi K2.6", + "family": "kimi-k2", + "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", @@ -32905,8 +36871,8 @@ }, "open_weights": true, "cost": { - "input": 1.47, - "output": 5.9 + "input": 1.4375, + "output": 5.75 }, "limit": { "context": 262144, @@ -32915,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" @@ -32932,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 } }, { @@ -32959,8 +36927,8 @@ }, "open_weights": true, "cost": { - "input": 0.24, - "output": 0.94 + "input": 0.23, + "output": 0.92 }, "limit": { "context": 65536, @@ -32986,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", @@ -33027,6 +37022,37 @@ "output": 32000 } }, + { + "id": "fastrouter/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 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "fastrouter/anthropic/claude-sonnet-4", "name": "Claude Sonnet 4", @@ -33060,6 +37086,63 @@ "output": 64000 } }, + { + "id": "fastrouter/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", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 15.0 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "fastrouter/bytedance/seedance-2", + "name": "Seedance 2", + "family": "seed", + "attachment": true, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2026-04-01", + "last_updated": "2026-04-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 4096, + "output": 0 + } + }, { "id": "fastrouter/deepseek-ai/deepseek-r1-distill-llama-70b", "name": "DeepSeek R1 Distill Llama 70B", @@ -33089,6 +37172,35 @@ "output": 131072 } }, + { + "id": "fastrouter/deepseek/deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "family": "deepseek-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.74, + "output": 3.48 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, { "id": "fastrouter/google/gemini-2.5-flash", "name": "Gemini 2.5 Flash", @@ -33153,10 +37265,404 @@ "output": 65536 } }, + { + "id": "fastrouter/google/gemini-3-pro-image-preview", + "name": "Nano Banana Pro", + "family": "gemini-pro", + "attachment": true, + "reasoning": true, + "tool_call": false, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2025-11-20", + "last_updated": "2025-11-20", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text", + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 12.0 + }, + "limit": { + "context": 65536, + "output": 32768 + } + }, + { + "id": "fastrouter/google/gemini-3.1-flash-image-preview", + "name": "Nano Banana 2", + "family": "gemini-flash", + "attachment": true, + "reasoning": true, + "tool_call": false, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-02-26", + "last_updated": "2026-02-26", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text", + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.5, + "output": 3.0 + }, + "limit": { + "context": 65536, + "output": 65536 + } + }, + { + "id": "fastrouter/google/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", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 12.0 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "fastrouter/google/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 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "fastrouter/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.13, + "output": 0.38 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, + { + "id": "fastrouter/google/imagen-4.0-fast", + "name": "Imagen 4 Fast", + "family": "imagen", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2025-05-20", + "last_updated": "2025-05-20", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 480, + "output": 0 + } + }, + { + "id": "fastrouter/google/imagen-4.0-ultra", + "name": "Imagen 4 Ultra", + "family": "imagen", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2025-05-20", + "last_updated": "2025-05-20", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 480, + "output": 0 + } + }, + { + "id": "fastrouter/google/veo3.1", + "name": "Veo 3.1", + "family": "veo", + "attachment": true, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2026-05-01", + "last_updated": "2026-05-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 400000, + "output": 0 + } + }, + { + "id": "fastrouter/google/veo3.1-fast", + "name": "Veo 3.1 Fast", + "family": "veo", + "attachment": true, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2026-05-01", + "last_updated": "2026-05-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 400000, + "output": 0 + } + }, + { + "id": "fastrouter/google/veo3.1-lite", + "name": "Veo 3.1 Lite", + "family": "veo", + "attachment": true, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2026-05-01", + "last_updated": "2026-05-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 400000, + "output": 0 + } + }, + { + "id": "fastrouter/leonardo-ai/lucid-origin", + "name": "Lucid Origin", + "family": "lucid", + "attachment": true, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2025-06-01", + "last_updated": "2025-06-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 4096, + "output": 0 + } + }, + { + "id": "fastrouter/leonardo-ai/lucid-realism", + "name": "Lucid Realism", + "family": "lucid", + "attachment": true, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2025-06-01", + "last_updated": "2025-06-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 4096, + "output": 0 + } + }, + { + "id": "fastrouter/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.3, + "output": 1.2 + }, + "limit": { + "context": 204800, + "output": 131072 + } + }, + { + "id": "fastrouter/minimax/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.6, + "output": 2.4 + }, + "limit": { + "context": 204800, + "output": 131072 + } + }, { "id": "fastrouter/moonshotai/kimi-k2", "name": "Kimi K2", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -33182,6 +37688,37 @@ "output": 32768 } }, + { + "id": "fastrouter/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-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.75, + "output": 3.5 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "fastrouter/openai/gpt-4.1", "name": "GPT-4.1", @@ -33306,6 +37843,185 @@ "output": 128000 } }, + { + "id": "fastrouter/openai/gpt-5.3-codex", + "name": "GPT-5.3 Codex", + "family": "gpt-codex", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-02-05", + "last_updated": "2026-02-05", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.75, + "output": 14.0 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "fastrouter/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 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "fastrouter/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 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "fastrouter/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 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "fastrouter/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", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 30.0, + "output": 180.0 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "fastrouter/openai/gpt-image-2", + "name": "GPT Image 2", + "family": "gpt-image", + "attachment": true, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 128000, + "output": 0 + } + }, { "id": "fastrouter/openai/gpt-oss-120b", "name": "GPT OSS 120B", @@ -33362,6 +38078,37 @@ "output": 65536 } }, + { + "id": "fastrouter/openai/gpt-realtime-1.5", + "name": "GPT Realtime 1.5", + "family": "gpt", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2025-06-01", + "last_updated": "2025-06-01", + "modalities": { + "input": [ + "text", + "audio", + "image" + ], + "output": [ + "text", + "audio" + ] + }, + "open_weights": false, + "cost": { + "input": 4.0, + "output": 16.0 + }, + "limit": { + "context": 32000, + "output": 4096 + } + }, { "id": "fastrouter/qwen/qwen3-coder", "name": "Qwen3 Coder", @@ -33391,6 +38138,87 @@ "output": 66536 } }, + { + "id": "fastrouter/sarvam/sarvam-105b", + "name": "Sarvam 105B", + "family": "sarvam", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-09-01", + "last_updated": "2025-09-01", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.04, + "output": 0.16 + }, + "limit": { + "context": 131072, + "output": 131072 + } + }, + { + "id": "fastrouter/sarvam/sarvam-30b", + "name": "Sarvam 30B", + "family": "sarvam", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-18", + "last_updated": "2026-02-18", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.02, + "output": 0.1 + }, + "limit": { + "context": 128000, + "output": 128000 + } + }, + { + "id": "fastrouter/wanx/wan-v2-6", + "name": "Wan 2.6", + "attachment": true, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2025-12-01", + "last_updated": "2025-12-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "video" + ] + }, + "open_weights": true, + "cost": {}, + "limit": { + "context": 400000, + "output": 0 + } + }, { "id": "fastrouter/x-ai/grok-4", "name": "Grok 4", @@ -33422,6 +38250,66 @@ "output": 64000 } }, + { + "id": "fastrouter/x-ai/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": 1.25, + "output": 2.5 + }, + "limit": { + "context": 1000000, + "output": 30000 + } + }, + { + "id": "fastrouter/x-ai/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": 1.0, + "output": 2.0 + }, + "limit": { + "context": 256000, + "output": 256000 + } + }, { "id": "fastrouter/z-ai/glm-5", "name": "GLM-5", @@ -33451,19 +38339,18 @@ } }, { - "id": "firepass/accounts/fireworks/routers/kimi-k2p6-turbo", - "name": "Kimi K2.6 Turbo", - "family": "kimi-thinking", + "id": "fastrouter/z-ai/glm-5.1", + "name": "GLM-5.1", + "family": "glm", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-04-17", - "last_updated": "2026-04-17", + "release_date": "2026-04-07", + "last_updated": "2026-04-07", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" @@ -33471,13 +38358,12 @@ }, "open_weights": true, "cost": { - "input": 0.0, - "output": 0.0, - "cache_read": 0.0 + "input": 1.05, + "output": 3.5 }, "limit": { - "context": 262000, - "output": 262000 + "context": 200000, + "output": 131072 } }, { @@ -33490,7 +38376,7 @@ "temperature": true, "knowledge": "2025-05", "release_date": "2026-04-24", - "last_updated": "2026-04-24", + "last_updated": "2026-06-16", "modalities": { "input": [ "text" @@ -33503,7 +38389,7 @@ "cost": { "input": 0.14, "output": 0.28, - "cache_read": 0.03 + "cache_read": 0.028 }, "limit": { "context": 1000000, @@ -33569,6 +38455,35 @@ "output": 131072 } }, + { + "id": "fireworks-ai/accounts/fireworks/models/glm-5p2", + "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.14 + }, + "limit": { + "context": 1048575, + "output": 131072 + } + }, { "id": "fireworks-ai/accounts/fireworks/models/gpt-oss-120b", "name": "GPT OSS 120B", @@ -33578,7 +38493,7 @@ "tool_call": true, "temperature": true, "release_date": "2025-08-05", - "last_updated": "2025-08-05", + "last_updated": "2026-06-16", "modalities": { "input": [ "text" @@ -33627,43 +38542,11 @@ "output": 32768 } }, - { - "id": "fireworks-ai/accounts/fireworks/models/kimi-k2p5", - "name": "Kimi K2.5", - "family": "kimi-thinking", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-01", - "release_date": "2026-01-27", - "last_updated": "2026-01-27", - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.6, - "output": 3.0, - "cache_read": 0.1 - }, - "limit": { - "context": 256000, - "output": 256000 - } - }, { "id": "fireworks-ai/accounts/fireworks/models/kimi-k2p6", "name": "Kimi K2.6", "family": "kimi-thinking", - "attachment": false, + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, @@ -33690,18 +38573,19 @@ } }, { - "id": "fireworks-ai/accounts/fireworks/models/minimax-m2p5", - "name": "MiniMax-M2.5", - "family": "minimax", - "attachment": false, + "id": "fireworks-ai/accounts/fireworks/models/kimi-k2p7-code", + "name": "Kimi K2.7 Code", + "family": "kimi-k2", + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-12", - "last_updated": "2026-02-12", + "release_date": "2026-06-12", + "last_updated": "2026-06-16", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -33709,13 +38593,13 @@ }, "open_weights": true, "cost": { - "input": 0.3, - "output": 1.2, - "cache_read": 0.03 + "input": 0.95, + "output": 4.0, + "cache_read": 0.19 }, "limit": { - "context": 196608, - "output": 196608 + "context": 262000, + "output": 262000 } }, { @@ -33748,15 +38632,44 @@ } }, { - "id": "fireworks-ai/accounts/fireworks/models/qwen3p6-plus", - "name": "Qwen 3.6 Plus", + "id": "fireworks-ai/accounts/fireworks/models/minimax-m3", + "name": "MiniMax-M3", + "family": "minimax", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 + }, + "limit": { + "context": 512000, + "output": 512000 + } + }, + { + "id": "fireworks-ai/accounts/fireworks/models/qwen3p7-plus", + "name": "Qwen 3.7 Plus", "family": "qwen", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-04-04", - "last_updated": "2026-04-04", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", "modalities": { "input": [ "text", @@ -33768,13 +38681,13 @@ }, "open_weights": false, "cost": { - "input": 0.5, - "output": 3.0, - "cache_read": 0.1 + "input": 0.4, + "output": 1.6, + "cache_read": 0.08 }, "limit": { - "context": 128000, - "output": 8192 + "context": 262144, + "output": 65536 } }, { @@ -33806,11 +38719,70 @@ "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", + "family": "kimi-thinking", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-17", + "last_updated": "2026-06-05", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 2.0, + "output": 8.0, + "cache_read": 0.3 + }, + "limit": { + "context": 262000, + "output": 262000 + } + }, { "id": "fireworks-ai/accounts/fireworks/routers/kimi-k2p6-turbo", "name": "Kimi K2.6 Turbo", "family": "kimi-thinking", - "attachment": false, + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, @@ -33836,6 +38808,365 @@ "output": 262000 } }, + { + "id": "fireworks-ai/accounts/fireworks/routers/kimi-k2p7-code-fast", + "name": "Kimi K2.7 Code Fast", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-12", + "last_updated": "2026-06-16", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.9, + "output": 8.0, + "cache_read": 0.38 + }, + "limit": { + "context": 262000, + "output": 262000 + } + }, + { + "id": "freemodel/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": "freemodel/claude-haiku-4.5", + "name": "Claude Haiku 4.5", + "family": "claude-haiku", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-02-28", + "release_date": "2025-10-15", + "last_updated": "2025-10-15", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 5.0, + "cache_read": 0.1, + "cache_write": 1.25 + }, + "limit": { + "context": 200000, + "output": 64000 + } + }, + { + "id": "freemodel/claude-opus-4.6", + "name": "Claude Opus 4.6", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05-31", + "release_date": "2026-02-05", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "freemodel/claude-opus-4.7", + "name": "Claude Opus 4.7", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "freemodel/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": "freemodel/claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-08-31", + "release_date": "2026-02-17", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 15.0, + "cache_read": 0.3, + "cache_write": 3.75 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "freemodel/gpt-5.3-codex", + "name": "GPT-5.3 Codex", + "family": "gpt-codex", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-02-05", + "last_updated": "2026-02-05", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.75, + "output": 14.0, + "cache_read": 0.175, + "cache_write": 1.75 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "freemodel/gpt-5.4", + "name": "GPT-5.4", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-05", + "last_updated": "2026-03-05", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.5, + "output": 15.0, + "cache_read": 0.25, + "cache_write": 2.5 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "freemodel/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": "freemodel/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, + "cache_write": 5.0 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, { "id": "friendli/MiniMaxAI/MiniMax-M2.5", "name": "MiniMax-M2.5", @@ -33870,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": { @@ -33894,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" @@ -33969,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 } }, { @@ -34007,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", @@ -34517,11 +39851,11 @@ } }, { - "id": "frogbot/grok-4.1-fast", - "name": "Grok 4.1 Fast (Reasoning)", + "id": "frogbot/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, "knowledge": "2025-11", @@ -34548,11 +39882,11 @@ } }, { - "id": "frogbot/grok-4.1-fast-non", - "name": "Grok 4.1 Fast (Non-Reasoning)", + "id": "frogbot/grok-4.1-fast-reasoning", + "name": "Grok 4.1 Fast (Reasoning)", "family": "grok", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "knowledge": "2025-11", @@ -34819,6 +40153,39 @@ "output": 8192 } }, + { + "id": "github-copilot/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": "github-copilot/claude-haiku-4.5", "name": "Claude Haiku 4.5 (latest)", @@ -34860,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": { @@ -34882,7 +40249,7 @@ }, "limit": { "context": 200000, - "output": 64000 + "output": 32000 } }, { @@ -34914,8 +40281,8 @@ "cache_write": 6.25 }, "limit": { - "context": 1000000, - "output": 128000 + "context": 200000, + "output": 32000 } }, { @@ -34947,8 +40314,8 @@ "cache_write": 6.25 }, "limit": { - "context": 1000000, - "output": 128000 + "context": 200000, + "output": 32000 } }, { @@ -34959,6 +40326,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -34979,8 +40347,8 @@ "cache_write": 6.25 }, "limit": { - "context": 1000000, - "output": 128000 + "context": 200000, + "output": 64000 } }, { @@ -35012,8 +40380,8 @@ "cache_write": 3.75 }, "limit": { - "context": 200000, - "output": 64000 + "context": 216000, + "output": 16000 } }, { @@ -35046,7 +40414,7 @@ }, "limit": { "context": 200000, - "output": 64000 + "output": 32000 } }, { @@ -35077,9 +40445,42 @@ "cache_read": 0.3, "cache_write": 3.75 }, + "limit": { + "context": 200000, + "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": 64000 + "output": 128000 } }, { @@ -35091,8 +40492,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", @@ -35112,8 +40513,8 @@ "cache_read": 0.125 }, "limit": { - "context": 1048576, - "output": 65536 + "context": 128000, + "output": 64000 } }, { @@ -35146,8 +40547,8 @@ "cache_read": 0.05 }, "limit": { - "context": 1048576, - "output": 65536 + "context": 128000, + "output": 64000 } }, { @@ -35180,8 +40581,8 @@ "cache_read": 0.2 }, "limit": { - "context": 1048576, - "output": 65536 + "context": 200000, + "output": 64000 } }, { @@ -35214,8 +40615,8 @@ "cache_read": 0.15 }, "limit": { - "context": 1048576, - "output": 65536 + "context": 200000, + "output": 64000 } }, { @@ -35246,8 +40647,8 @@ "cache_read": 0.5 }, "limit": { - "context": 1047576, - "output": 32768 + "context": 128000, + "output": 16384 } }, { @@ -35277,8 +40678,8 @@ "cache_read": 0.025 }, "limit": { - "context": 400000, - "output": 128000 + "context": 264000, + "output": 64000 } }, { @@ -35404,7 +40805,7 @@ "cache_read": 0.25 }, "limit": { - "context": 1050000, + "context": 400000, "output": 128000 } }, @@ -35498,21 +40899,21 @@ "cache_read": 0.5 }, "limit": { - "context": 1050000, + "context": 400000, "output": 128000 } }, { - "id": "github-copilot/raptor-mini", - "name": "Raptor mini", - "family": "gpt-mini", + "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": "2024-05-30", - "release_date": "2025-08-07", - "last_updated": "2025-08-07", + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", "modalities": { "input": [ "text", @@ -35522,14 +40923,44 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { - "input": 0.25, - "output": 2.0, - "cache_read": 0.025 + "input": 0.95, + "output": 4.0, + "cache_read": 0.19 }, "limit": { - "context": 400000, + "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 } }, @@ -36440,8 +41871,8 @@ } }, { - "id": "github-models/microsoft/phi-4-mini", - "name": "Phi-4-mini-reasoning", + "id": "github-models/microsoft/phi-4-mini-instruct", + "name": "Phi-4-mini-instruct", "family": "phi", "attachment": false, "reasoning": true, @@ -36469,8 +41900,8 @@ } }, { - "id": "github-models/microsoft/phi-4-mini-instruct", - "name": "Phi-4-mini-instruct", + "id": "github-models/microsoft/phi-4-mini-reasoning", + "name": "Phi-4-mini-reasoning", "family": "phi", "attachment": false, "reasoning": true, @@ -36528,6 +41959,35 @@ "output": 4096 } }, + { + "id": "github-models/microsoft/phi-4-reasoning", + "name": "Phi-4-Reasoning", + "family": "phi", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2023-10", + "release_date": "2024-12-11", + "last_updated": "2024-12-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 128000, + "output": 4096 + } + }, { "id": "github-models/mistral-ai/codestral", "name": "Codestral 25.01", @@ -37091,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)", @@ -37626,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", @@ -37686,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", @@ -37779,7 +43367,7 @@ { "id": "gmicloud/moonshotai/Kimi-K2.6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -37806,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", @@ -37814,8 +43464,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-11", - "last_updated": "2026-02-11", + "release_date": "2026-02-12", + "last_updated": "2026-02-12", "modalities": { "input": [ "text" @@ -37843,8 +43493,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-03-27", - "last_updated": "2026-03-27", + "release_date": "2026-04-07", + "last_updated": "2026-04-07", "modalities": { "input": [ "text" @@ -37853,7 +43503,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.98, "output": 3.08, @@ -37864,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", @@ -37897,72 +43576,6 @@ "output": 8192 } }, - { - "id": "google-vertex-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": "google-vertex-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": "google-vertex-anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5", @@ -38070,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": { @@ -38169,6 +43782,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -38292,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", @@ -38325,72 +43972,6 @@ "output": 8192 } }, - { - "id": "google-vertex/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": "google-vertex/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": "google-vertex/claude-haiku-4.5", "name": "Claude Haiku 4.5", @@ -38498,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": { @@ -38597,6 +44178,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -38720,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", @@ -38779,73 +44394,6 @@ "output": 65536 } }, - { - "id": "google-vertex/gemini-2.0-flash", - "name": "Gemini 2.0 Flash", - "family": "gemini-flash", - "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.15, - "output": 0.6, - "cache_read": 0.025 - }, - "limit": { - "context": 1048576, - "output": 8192 - } - }, - { - "id": "google-vertex/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 - } - }, { "id": "google-vertex/gemini-2.5-flash", "name": "Gemini 2.5 Flash", @@ -38916,9 +44464,38 @@ } }, { - "id": "google-vertex/gemini-2.5-flash-lite-preview-06-17", - "name": "Gemini 2.5 Flash Lite Preview 06-17", - "family": "gemini-flash-lite", + "id": "google-vertex/gemini-2.5-flash-tts", + "name": "Gemini 2.5 Flash TTS", + "family": "gemini-flash", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "knowledge": "2025-01", + "release_date": "2025-09-30", + "last_updated": "2025-12-10", + "modalities": { + "input": [ + "text" + ], + "output": [ + "audio" + ] + }, + "open_weights": false, + "cost": { + "input": 0.5, + "output": 10.0 + }, + "limit": { + "context": 32768, + "output": 16384 + } + }, + { + "id": "google-vertex/gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "family": "gemini-pro", "attachment": true, "reasoning": true, "tool_call": true, @@ -38939,75 +44516,6 @@ ] }, "open_weights": false, - "cost": { - "input": 0.1, - "output": 0.4, - "cache_read": 0.025 - }, - "limit": { - "context": 65536, - "output": 65536 - } - }, - { - "id": "google-vertex/gemini-2.5-flash-preview-09", - "name": "Gemini 2.5 Flash Preview 09-25", - "family": "gemini-flash", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-01", - "release_date": "2025-09-25", - "last_updated": "2025-09-25", - "modalities": { - "input": [ - "text", - "image", - "audio", - "video", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.3, - "output": 2.5, - "cache_read": 0.075, - "cache_write": 0.383 - }, - "limit": { - "context": 1048576, - "output": 65536 - } - }, - { - "id": "google-vertex/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-03-20", - "last_updated": "2025-06-05", - "modalities": { - "input": [ - "text", - "image", - "audio", - "video", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, "cost": { "input": 1.25, "output": 10.0, @@ -39018,6 +44526,35 @@ "output": 65536 } }, + { + "id": "google-vertex/gemini-2.5-pro-tts", + "name": "Gemini 2.5 Pro TTS", + "family": "gemini-pro", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "knowledge": "2025-01", + "release_date": "2025-09-30", + "last_updated": "2025-12-10", + "modalities": { + "input": [ + "text" + ], + "output": [ + "audio" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 20.0 + }, + "limit": { + "context": 32768, + "output": 16384 + } + }, { "id": "google-vertex/gemini-3-flash-preview", "name": "Gemini 3 Flash Preview", @@ -39052,40 +44589,6 @@ "output": 65536 } }, - { - "id": "google-vertex/gemini-3-pro-preview", - "name": "Gemini 3 Pro Preview", - "family": "gemini-pro", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-01", - "release_date": "2025-11-18", - "last_updated": "2025-11-18", - "modalities": { - "input": [ - "text", - "image", - "video", - "audio", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 2.0, - "output": 12.0, - "cache_read": 0.2 - }, - "limit": { - "context": 1048576, - "output": 65536 - } - }, { "id": "google-vertex/gemini-3.1-flash-lite", "name": "Gemini 3.1 Flash Lite", @@ -39661,8 +45164,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", @@ -39790,8 +45293,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", @@ -39878,6 +45381,37 @@ "output": 65536 } }, + { + "id": "google/gemini-3-pro-image-preview", + "name": "Nano Banana Pro", + "family": "gemini-pro", + "attachment": true, + "reasoning": true, + "tool_call": false, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2025-11-20", + "last_updated": "2025-11-20", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text", + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 120.0 + }, + "limit": { + "context": 131072, + "output": 32768 + } + }, { "id": "google/gemini-3-pro-preview", "name": "Gemini 3 Pro Preview", @@ -40211,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", @@ -40263,44 +45827,14 @@ "output": 32768 } }, - { - "id": "groq/allam-2-7b", - "name": "ALLaM-2-7b", - "family": "allam", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "knowledge": "2024-09", - "release_date": "2024-09", - "last_updated": "2024-09", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 4096, - "output": 4096 - } - }, { "id": "groq/canopylabs/orpheus-arabic-saudi", - "name": "Orpheus Arabic Saudi", + "name": "Canopy Labs Orpheus Arabic Saudi", "family": "canopylabs", "attachment": false, "reasoning": false, "tool_call": false, - "temperature": true, - "knowledge": "2025-12-16", + "temperature": false, "release_date": "2025-12-16", "last_updated": "2025-12-16", "modalities": { @@ -40312,10 +45846,7 @@ ] }, "open_weights": false, - "cost": { - "input": 40.0, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 4000, "output": 50000 @@ -40323,13 +45854,12 @@ }, { "id": "groq/canopylabs/orpheus-v1-english", - "name": "Orpheus V1 English", + "name": "Canopy Labs Orpheus V1 English", "family": "canopylabs", "attachment": false, "reasoning": false, "tool_call": false, - "temperature": true, - "knowledge": "2025-12-19", + "temperature": false, "release_date": "2025-12-19", "last_updated": "2025-12-19", "modalities": { @@ -40341,82 +45871,20 @@ ] }, "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 4000, "output": 50000 } }, - { - "id": "groq/deepseek-r1-distill-llama-70b", - "name": "DeepSeek R1 Distill Llama 70B", - "family": "deepseek-thinking", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-07", - "release_date": "2025-01-20", - "last_updated": "2025-01-20", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.75, - "output": 0.99 - }, - "limit": { - "context": 131072, - "output": 8192 - } - }, - { - "id": "groq/gemma2-9b-it", - "name": "Gemma 2 9B", - "family": "gemma", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-06", - "release_date": "2024-06-27", - "last_updated": "2024-06-27", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.2, - "output": 0.2 - }, - "limit": { - "context": 8192, - "output": 8192 - } - }, { "id": "groq/groq/compound", "name": "Compound", "family": "groq", "attachment": false, - "reasoning": true, - "tool_call": true, + "reasoning": false, + "tool_call": false, "temperature": true, - "knowledge": "2025-09-04", "release_date": "2025-09-04", "last_updated": "2025-09-04", "modalities": { @@ -40428,10 +45896,7 @@ ] }, "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 131072, "output": 8192 @@ -40442,10 +45907,9 @@ "name": "Compound Mini", "family": "groq", "attachment": false, - "reasoning": true, - "tool_call": true, + "reasoning": false, + "tool_call": false, "temperature": true, - "knowledge": "2025-09-04", "release_date": "2025-09-04", "last_updated": "2025-09-04", "modalities": { @@ -40457,10 +45921,7 @@ ] }, "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 131072, "output": 8192 @@ -40468,7 +45929,7 @@ }, { "id": "groq/llama-3.1-8b-instant", - "name": "Llama 3.1 8B Instant", + "name": "Llama 3.1 8B", "family": "llama", "attachment": false, "reasoning": false, @@ -40497,7 +45958,7 @@ }, { "id": "groq/llama-3.3-70b-versatile", - "name": "Llama 3.3 70B Versatile", + "name": "Llama 3.3 70B", "family": "llama", "attachment": false, "reasoning": false, @@ -40524,127 +45985,11 @@ "output": 32768 } }, - { - "id": "groq/llama-guard-3-8b", - "name": "Llama Guard 3 8B", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "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": 8192, - "output": 8192 - } - }, - { - "id": "groq/llama3-70b", - "name": "Llama 3 70B", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2023-03", - "release_date": "2024-04-18", - "last_updated": "2024-04-18", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.59, - "output": 0.79 - }, - "limit": { - "context": 8192, - "output": 8192 - } - }, - { - "id": "groq/llama3-8b", - "name": "Llama 3 8B", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2023-03", - "release_date": "2024-04-18", - "last_updated": "2024-04-18", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.05, - "output": 0.08 - }, - "limit": { - "context": 8192, - "output": 8192 - } - }, - { - "id": "groq/meta-llama/llama-4-maverick-17b-128e-instruct", - "name": "Llama 4 Maverick 17B", - "family": "llama", - "attachment": false, - "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.2, - "output": 0.6 - }, - "limit": { - "context": 131072, - "output": 8192 - } - }, { "id": "groq/meta-llama/llama-4-scout-17b-16e-instruct", - "name": "Llama 4 Scout 17B", + "name": "Llama 4 Scout 17B 16E", "family": "llama", - "attachment": false, + "attachment": true, "reasoning": false, "tool_call": true, "temperature": true, @@ -40670,35 +46015,6 @@ "output": 8192 } }, - { - "id": "groq/meta-llama/llama-guard-4-12b", - "name": "Llama Guard 4 12B", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-04-05", - "last_updated": "2025-04-05", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.2, - "output": 0.2 - }, - "limit": { - "context": 131072, - "output": 1024 - } - }, { "id": "groq/meta-llama/llama-prompt-guard-2-22m", "name": "Llama Prompt Guard 2 22M", @@ -40706,10 +46022,9 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": true, - "knowledge": "2024-10", - "release_date": "2024-10-01", - "last_updated": "2024-10-01", + "temperature": false, + "release_date": "2025-05-29", + "last_updated": "2025-05-29", "modalities": { "input": [ "text" @@ -40730,15 +46045,14 @@ }, { "id": "groq/meta-llama/llama-prompt-guard-2-86m", - "name": "Llama Prompt Guard 2 86M", + "name": "Prompt Guard 2 86M", "family": "llama", "attachment": false, "reasoning": false, "tool_call": false, - "temperature": true, - "knowledge": "2024-10", - "release_date": "2024-10-01", - "last_updated": "2024-10-01", + "temperature": false, + "release_date": "2025-05-29", + "last_updated": "2025-05-29", "modalities": { "input": [ "text" @@ -40757,65 +46071,6 @@ "output": 512 } }, - { - "id": "groq/mistral-saba-24b", - "name": "Mistral Saba 24B", - "family": "mistral", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-08", - "release_date": "2025-02-06", - "last_updated": "2025-02-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.79, - "output": 0.79 - }, - "limit": { - "context": 32768, - "output": 32768 - } - }, - { - "id": "groq/moonshotai/kimi-k2-instruct", - "name": "Kimi K2 Instruct 0905", - "family": "kimi", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-10", - "release_date": "2025-09-05", - "last_updated": "2026-05-27", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 1.0, - "output": 3.0, - "cache_read": 0.5 - }, - "limit": { - "context": 262144, - "output": 16384 - } - }, { "id": "groq/openai/gpt-oss-120b", "name": "GPT OSS 120B", @@ -40825,7 +46080,7 @@ "tool_call": true, "temperature": true, "release_date": "2025-08-05", - "last_updated": "2026-05-27", + "last_updated": "2025-10-21", "modalities": { "input": [ "text" @@ -40854,7 +46109,7 @@ "tool_call": true, "temperature": true, "release_date": "2025-08-05", - "last_updated": "2026-05-27", + "last_updated": "2025-09-25", "modalities": { "input": [ "text" @@ -40882,8 +46137,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-03-05", - "last_updated": "2025-03-05", + "release_date": "2025-10-29", + "last_updated": "2026-06-29", "modalities": { "input": [ "text" @@ -40895,54 +46150,23 @@ "open_weights": true, "cost": { "input": 0.075, - "output": 0.3, - "cache_read": 0.037 + "output": 0.3 }, "limit": { "context": 131072, "output": 65536 } }, - { - "id": "groq/qwen-qwq-32b", - "name": "Qwen QwQ 32B", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-09", - "release_date": "2024-11-27", - "last_updated": "2024-11-27", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.29, - "output": 0.39 - }, - "limit": { - "context": 131072, - "output": 16384 - } - }, { "id": "groq/qwen/qwen3-32b", - "name": "Qwen3 32B", + "name": "Qwen3-32B", "family": "qwen", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2024-11-08", - "release_date": "2024-12-23", - "last_updated": "2024-12-23", + "release_date": "2025-06-11", + "last_updated": "2025-06-12", "modalities": { "input": [ "text" @@ -40963,13 +46187,12 @@ }, { "id": "groq/whisper-large-v3", - "name": "Whisper Large V3", + "name": "Whisper", "family": "whisper", "attachment": false, "reasoning": false, "tool_call": false, "temperature": true, - "knowledge": "2023-09", "release_date": "2023-09-01", "last_updated": "2025-09-05", "modalities": { @@ -40981,24 +46204,20 @@ ] }, "open_weights": true, - "cost": { - "input": 0.0, - "output": 0.0 - }, + "cost": {}, "limit": { - "context": 448, - "output": 448 + "context": 0, + "output": 0 } }, { "id": "groq/whisper-large-v3-turbo", - "name": "Whisper Large v3 Turbo", + "name": "Whisper Large V3 Turbo", "family": "whisper", "attachment": false, "reasoning": false, "tool_call": false, "temperature": true, - "knowledge": "2024-10", "release_date": "2024-10-01", "last_updated": "2024-10-01", "modalities": { @@ -41010,13 +46229,10 @@ ] }, "open_weights": true, - "cost": { - "input": 0.0, - "output": 0.0 - }, + "cost": {}, "limit": { - "context": 448, - "output": 448 + "context": 0, + "output": 0 } }, { @@ -41340,7 +46556,7 @@ }, { "id": "helicone/claude-opus-4.1", - "name": "Anthropic: Claude Opus 4.1 (20250805)", + "name": "Anthropic: Claude Opus 4.1", "family": "claude-opus", "attachment": false, "reasoning": true, @@ -42472,38 +47688,7 @@ } }, { - "id": "helicone/grok-4-fast", - "name": "xAI: Grok 4 Fast Reasoning", - "family": "grok", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-09", - "release_date": "2025-09-01", - "last_updated": "2025-09-01", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.19999999999999998, - "output": 0.5, - "cache_read": 0.049999999999999996 - }, - "limit": { - "context": 2000000, - "output": 2000000 - } - }, - { - "id": "helicone/grok-4-fast-non", + "id": "helicone/grok-4-fast-non-reasoning", "name": "xAI Grok 4 Fast Non-Reasoning", "family": "grok", "attachment": false, @@ -42535,16 +47720,16 @@ } }, { - "id": "helicone/grok-4.1-fast", - "name": "xAI Grok 4.1 Fast Reasoning", + "id": "helicone/grok-4-fast-reasoning", + "name": "xAI: Grok 4 Fast Reasoning", "family": "grok", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-11", - "release_date": "2025-11-17", - "last_updated": "2025-11-17", + "knowledge": "2025-09", + "release_date": "2025-09-01", + "last_updated": "2025-09-01", "modalities": { "input": [ "text", @@ -42566,7 +47751,7 @@ } }, { - "id": "helicone/grok-4.1-fast-non", + "id": "helicone/grok-4.1-fast-non-reasoning", "name": "xAI Grok 4.1 Fast Non-Reasoning", "family": "grok", "attachment": false, @@ -42597,6 +47782,37 @@ "output": 30000 } }, + { + "id": "helicone/grok-4.1-fast-reasoning", + "name": "xAI Grok 4.1 Fast Reasoning", + "family": "grok", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-11", + "release_date": "2025-11-17", + "last_updated": "2025-11-17", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.19999999999999998, + "output": 0.5, + "cache_read": 0.049999999999999996 + }, + "limit": { + "context": 2000000, + "output": 2000000 + } + }, { "id": "helicone/grok-code-fast-1", "name": "xAI Grok Code Fast 1", @@ -42659,7 +47875,7 @@ { "id": "helicone/kimi-k2", "name": "Kimi K2 (09/05)", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -43523,10 +48739,10 @@ }, { "id": "helicone/sonar", - "name": "Perplexity Sonar Reasoning", - "family": "sonar-reasoning", + "name": "Perplexity Sonar", + "family": "sonar", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": false, "temperature": true, "knowledge": "2025-01", @@ -43543,7 +48759,7 @@ "open_weights": false, "cost": { "input": 1.0, - "output": 5.0 + "output": 1.0 }, "limit": { "context": 127000, @@ -43608,6 +48824,35 @@ "output": 4096 } }, + { + "id": "helicone/sonar-reasoning", + "name": "Perplexity Sonar Reasoning", + "family": "sonar-reasoning", + "attachment": false, + "reasoning": true, + "tool_call": false, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2025-01-27", + "last_updated": "2025-01-27", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 5.0 + }, + "limit": { + "context": 127000, + "output": 4096 + } + }, { "id": "helicone/sonar-reasoning-pro", "name": "Perplexity Sonar Reasoning Pro", @@ -43669,7 +48914,7 @@ { "id": "hpc-ai/moonshotai/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": true, "tool_call": true, @@ -43727,6 +48972,34 @@ "output": 202000 } }, + { + "id": "huggingface/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.3, + "output": 1.2 + }, + "limit": { + "context": 204800, + "output": 128000 + } + }, { "id": "huggingface/MiniMaxAI/MiniMax-M2.1", "name": "MiniMax-M2.1", @@ -43814,6 +49087,64 @@ "output": 131072 } }, + { + "id": "huggingface/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" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 1.2 + }, + "limit": { + "context": 524288, + "output": 128000 + } + }, + { + "id": "huggingface/Qwen/Qwen3-235B-A22B", + "name": "Qwen3 235B-A22B", + "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.2, + "output": 0.8 + }, + "limit": { + "context": 40960, + "output": 16384 + } + }, { "id": "huggingface/Qwen/Qwen3-235B-A22B-Thinking", "name": "Qwen3-235B-A22B-Thinking-2507", @@ -43843,6 +49174,64 @@ "output": 131072 } }, + { + "id": "huggingface/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.29, + "output": 0.59 + }, + "limit": { + "context": 131072, + "output": 16384 + } + }, + { + "id": "huggingface/Qwen/Qwen3-Coder-30B-A3B-Instruct", + "name": "Qwen3-Coder 30B-A3B Instruct", + "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.07, + "output": 0.26 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, { "id": "huggingface/Qwen/Qwen3-Coder-480B-A35B-Instruct", "name": "Qwen3-Coder-480B-A35B-Instruct", @@ -44017,6 +49406,93 @@ "output": 131072 } }, + { + "id": "huggingface/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" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.4, + "output": 3.2 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, + { + "id": "huggingface/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" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 2.4 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, + { + "id": "huggingface/Qwen/Qwen3.5-35B-A3B", + "name": "Qwen3.5 35B-A3B", + "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" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.25, + "output": 2.0 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, { "id": "huggingface/Qwen/Qwen3.5-397B-A17B", "name": "Qwen3.5-397B-A17B", @@ -44047,6 +49523,93 @@ "output": 32768 } }, + { + "id": "huggingface/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" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.17, + "output": 0.25 + }, + "limit": { + "context": 262144, + "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", + "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" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.15, + "output": 0.95 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, { "id": "huggingface/XiaomiMiMo/MiMo-V2-Flash", "name": "MiMo-V2-Flash", @@ -44077,16 +49640,16 @@ } }, { - "id": "huggingface/deepseek-ai/DeepSeek-R1", - "name": "DeepSeek-R1-0528", - "family": "deepseek-thinking", + "id": "huggingface/XiaomiMiMo/MiMo-V2.5-Pro", + "name": "MiMo-V2.5-Pro", + "family": "mimo", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-05", - "release_date": "2025-05-28", - "last_updated": "2025-05-28", + "knowledge": "2024-12", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", "modalities": { "input": [ "text" @@ -44097,12 +49660,41 @@ }, "open_weights": true, "cost": { - "input": 3.0, - "output": 5.0 + "input": 1.0, + "output": 3.0 }, "limit": { - "context": 163840, - "output": 163840 + "context": 1048576, + "output": 131072 + } + }, + { + "id": "huggingface/deepseek-ai/DeepSeek-R1", + "name": "DeepSeek-R1", + "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.7, + "output": 2.5 + }, + "limit": { + "context": 64000, + "output": 32768 } }, { @@ -44134,6 +49726,35 @@ "output": 65536 } }, + { + "id": "huggingface/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 + }, + "limit": { + "context": 1048576, + "output": 384000 + } + }, { "id": "huggingface/deepseek-ai/DeepSeek-V4-Pro", "name": "DeepSeek V4 Pro", @@ -44164,10 +49785,97 @@ "output": 393216 } }, + { + "id": "huggingface/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.13, + "output": 0.4 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, + { + "id": "huggingface/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 + } + }, + { + "id": "huggingface/meta-llama/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.59, + "output": 0.79 + }, + "limit": { + "context": 131072, + "output": 4096 + } + }, { "id": "huggingface/moonshotai/Kimi-K2-Instruct", "name": "Kimi-K2-Instruct", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -44226,7 +49934,7 @@ { "id": "huggingface/moonshotai/Kimi-K2.5", "name": "Kimi-K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -44258,7 +49966,7 @@ { "id": "huggingface/moonshotai/Kimi-K2.6", "name": "Kimi-K2.6", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -44287,6 +49995,266 @@ "output": 262144 } }, + { + "id": "huggingface/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.95, + "output": 4.0 + }, + "limit": { + "context": 262144, + "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", + "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.1, + "output": 0.3 + }, + "limit": { + "context": 262144, + "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", + "family": "glm", + "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": true, + "cost": { + "input": 0.6, + "output": 2.2 + }, + "limit": { + "context": 131072, + "output": 98304 + } + }, + { + "id": "huggingface/zai-org/GLM-4.5-Air", + "name": "GLM-4.5-Air", + "family": "glm-air", + "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": true, + "cost": { + "input": 0.13, + "output": 0.85 + }, + "limit": { + "context": 131072, + "output": 98304 + } + }, + { + "id": "huggingface/zai-org/GLM-4.5V", + "name": "GLM-4.5V", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2025-08-11", + "last_updated": "2025-08-11", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.6, + "output": 1.8 + }, + "limit": { + "context": 65536, + "output": 16384 + } + }, + { + "id": "huggingface/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.2 + }, + "limit": { + "context": 204800, + "output": 131072 + } + }, { "id": "huggingface/zai-org/GLM-4.7", "name": "GLM-4.7", @@ -44404,6 +50372,34 @@ "output": 131072 } }, + { + "id": "huggingface/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": 262144, + "output": 131072 + } + }, { "id": "iflowcn/deepseek-r1", "name": "DeepSeek-R1", @@ -44523,7 +50519,7 @@ { "id": "iflowcn/kimi-k2", "name": "Kimi-K2", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -44860,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": { @@ -44873,7 +50869,7 @@ { "id": "inceptron/moonshotai/Kimi-K2.6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -44892,7 +50888,7 @@ }, "open_weights": true, "cost": { - "input": 0.78, + "input": 0.66, "output": 3.5, "cache_read": 0.2, "cache_write": 0.0 @@ -44903,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" @@ -44923,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 } }, { @@ -44941,8 +50970,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-03-27", - "last_updated": "2026-03-27", + "release_date": "2026-04-07", + "last_updated": "2026-04-07", "modalities": { "input": [ "text" @@ -44951,7 +50980,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 1.4, "output": 4.4, @@ -44963,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", @@ -45606,7 +51665,7 @@ { "id": "io-net/moonshotai/Kimi-K2-Instruct", "name": "Kimi K2 Instruct", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -46108,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", @@ -46139,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", @@ -46170,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", @@ -46232,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", @@ -46263,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", @@ -46294,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", @@ -46760,36 +52819,7 @@ } }, { - "id": "jiekou/grok-4-fast", - "name": "grok-4-fast-reasoning", - "family": "grok", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2026-01", - "last_updated": "2026-01", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.18, - "output": 0.45 - }, - "limit": { - "context": 2000000, - "output": 2000000 - } - }, - { - "id": "jiekou/grok-4-fast-non", + "id": "jiekou/grok-4-fast-non-reasoning", "name": "grok-4-fast-non-reasoning", "family": "grok", "attachment": true, @@ -46818,8 +52848,8 @@ } }, { - "id": "jiekou/grok-4.1-fast", - "name": "grok-4-1-fast-reasoning", + "id": "jiekou/grok-4-fast-reasoning", + "name": "grok-4-fast-reasoning", "family": "grok", "attachment": true, "reasoning": false, @@ -46847,7 +52877,7 @@ } }, { - "id": "jiekou/grok-4.1-fast-non", + "id": "jiekou/grok-4.1-fast-non-reasoning", "name": "grok-4-1-fast-non-reasoning", "family": "grok", "attachment": true, @@ -46875,6 +52905,35 @@ "output": 2000000 } }, + { + "id": "jiekou/grok-4.1-fast-reasoning", + "name": "grok-4-1-fast-reasoning", + "family": "grok", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-01", + "last_updated": "2026-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.18, + "output": 0.45 + }, + "limit": { + "context": 2000000, + "output": 2000000 + } + }, { "id": "jiekou/grok-code-fast-1", "name": "grok-code-fast-1", @@ -46963,7 +53022,7 @@ { "id": "jiekou/moonshotai/kimi-k2", "name": "Kimi K2 0905", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -46991,7 +53050,7 @@ { "id": "jiekou/moonshotai/kimi-k2-instruct", "name": "Kimi K2 Instruct", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -47019,7 +53078,7 @@ { "id": "jiekou/moonshotai/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -47526,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", @@ -48309,7 +55050,7 @@ } }, { - "id": "kilo/arcee-ai/maestro", + "id": "kilo/arcee-ai/maestro-reasoning", "name": "Arcee AI: Maestro Reasoning", "attachment": false, "reasoning": false, @@ -48612,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", @@ -48868,8 +55609,8 @@ "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2024-02-27", - "last_updated": "2024-02-27", + "release_date": "2024-12-02", + "last_updated": "2024-12-02", "modalities": { "input": [ "text" @@ -48947,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", @@ -51115,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", @@ -51337,17 +58109,16 @@ }, { "id": "kilo/mistralai/mistral-large", - "name": "Mistral: Mistral Large 3 2512", - "attachment": true, + "name": "Mistral Large", + "attachment": false, "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2024-11-01", - "last_updated": "2025-12-16", + "release_date": "2024-07-24", + "last_updated": "2025-12-02", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" @@ -51355,12 +58126,12 @@ }, "open_weights": true, "cost": { - "input": 0.5, - "output": 1.5 + "input": 2.0, + "output": 6.0 }, "limit": { - "context": 262144, - "output": 52429 + "context": 128000, + "output": 25600 } }, { @@ -52030,6 +58801,7 @@ { "id": "kilo/nvidia/llama-3.3-nemotron-super-49b-v1.5", "name": "NVIDIA: Llama 3.3 Nemotron Super 49B V1.5", + "family": "nemotron", "attachment": false, "reasoning": true, "tool_call": true, @@ -52057,6 +58829,7 @@ { "id": "kilo/nvidia/nemotron-3-nano-30b-a3b", "name": "NVIDIA: Nemotron 3 Nano 30B A3B", + "family": "nemotron", "attachment": false, "reasoning": true, "tool_call": true, @@ -52084,6 +58857,7 @@ { "id": "kilo/nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", "name": "NVIDIA: Nemotron 3 Nano Omni (free)", + "family": "nemotron", "attachment": true, "reasoning": true, "tool_call": true, @@ -52114,6 +58888,7 @@ { "id": "kilo/nvidia/nemotron-3-super-120b-a12b", "name": "NVIDIA: Nemotron 3 Super", + "family": "nemotron", "attachment": false, "reasoning": true, "tool_call": true, @@ -52142,6 +58917,7 @@ { "id": "kilo/nvidia/nemotron-3-super-120b-a12b:free", "name": "NVIDIA: Nemotron 3 Super (free)", + "family": "nemotron", "attachment": false, "reasoning": true, "tool_call": true, @@ -52169,6 +58945,7 @@ { "id": "kilo/nvidia/nemotron-nano-9b-v2", "name": "NVIDIA: Nemotron Nano 9B V2", + "family": "nemotron", "attachment": false, "reasoning": true, "tool_call": true, @@ -52195,13 +58972,13 @@ }, { "id": "kilo/openai/gpt-3.5-turbo", - "name": "OpenAI: GPT-3.5 Turbo (older v0613)", + "name": "OpenAI: GPT-3.5 Turbo", "attachment": false, "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2023-06-13", - "last_updated": "2023-06-13", + "release_date": "2023-03-01", + "last_updated": "2023-11-06", "modalities": { "input": [ "text" @@ -52212,11 +58989,11 @@ }, "open_weights": false, "cost": { - "input": 1.0, - "output": 2.0 + "input": 0.5, + "output": 1.5 }, "limit": { - "context": 4095, + "context": 16385, "output": 4096 } }, @@ -52475,7 +59252,7 @@ }, { "id": "kilo/openai/gpt-4o", - "name": "OpenAI: GPT-4o (2024-05-13)", + "name": "OpenAI: GPT-4o", "attachment": true, "reasoning": false, "tool_call": true, @@ -52494,12 +59271,13 @@ }, "open_weights": false, "cost": { - "input": 5.0, - "output": 15.0 + "input": 2.5, + "output": 10.0, + "cache_read": 1.25 }, "limit": { "context": 128000, - "output": 4096 + "output": 16384 } }, { @@ -54158,7 +60936,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-04-28", - "last_updated": "2026-05-01", + "last_updated": "2026-06-13", "modalities": { "input": [ "text" @@ -54173,8 +60951,8 @@ "output": 0.0 }, "limit": { - "context": 131072, - "output": 8192 + "context": 262144, + "output": 32768 } }, { @@ -54185,7 +60963,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-04-28", - "last_updated": "2026-05-01", + "last_updated": "2026-06-13", "modalities": { "input": [ "text" @@ -54200,8 +60978,8 @@ "output": 0.0 }, "limit": { - "context": 131072, - "output": 8192 + "context": 262144, + "output": 32768 } }, { @@ -54427,13 +61205,13 @@ }, { "id": "kilo/qwen/qwen3-235b-a22b", - "name": "Qwen: Qwen3 235B A22B Instruct 2507", + "name": "Qwen: Qwen3 235B A22B", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-04", - "last_updated": "2026-01", + "release_date": "2024-12-01", + "last_updated": "2026-03-15", "modalities": { "input": [ "text" @@ -54444,12 +61222,13 @@ }, "open_weights": true, "cost": { - "input": 0.071, - "output": 0.1 + "input": 0.455, + "output": 1.82, + "cache_read": 0.15 }, "limit": { - "context": 262144, - "output": 52429 + "context": 131072, + "output": 8192 } }, { @@ -57034,6 +63813,39 @@ "output": 32768 } }, + { + "id": "kimi-for-coding/k2p7", + "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, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, { "id": "kimi-for-coding/kimi-k2-thinking", "name": "Kimi K2 Thinking", @@ -57128,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" @@ -57148,19 +63961,19 @@ }, "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 } }, { "id": "lilac/moonshotai/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -57189,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-03-27", - "last_updated": "2026-03-27", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "modalities": { "input": [ "text" @@ -57214,8 +64026,8 @@ "cache_read": 0.27 }, "limit": { - "context": 202800, - "output": 131072 + "context": 524288, + "output": 524288 } }, { @@ -57270,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, @@ -57278,78 +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", - "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": "llmgateway/claude-3.7-sonnet", - "name": "Claude Sonnet 3.7", - "family": "claude-sonnet", + "id": "llmgateway/claude-fable-5", + "name": "Claude Fable 5", + "family": "claude-fable", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, - "knowledge": "2024-10-31", - "release_date": "2025-02-19", - "last_updated": "2025-02-19", + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-09", + "last_updated": "2026-06-09", "modalities": { "input": [ "text", @@ -57362,14 +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": 64000 + "context": 1000000, + "output": 128000 } }, { @@ -57406,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" @@ -57428,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 } }, { @@ -57479,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": { @@ -57578,6 +64326,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -57602,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)", @@ -57701,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", @@ -57758,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", @@ -57809,7 +64529,7 @@ "cost": { "input": 0.56, "output": 1.68, - "cache_read": 0.07 + "cache_read": 0.112 }, "limit": { "context": 128000, @@ -57837,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, @@ -57872,7 +64592,7 @@ "cache_read": 0.0028 }, "limit": { - "context": 1000000, + "context": 1050000, "output": 384000 } }, @@ -57902,7 +64622,7 @@ "cache_read": 0.003625 }, "limit": { - "context": 1000000, + "context": 1050000, "output": 384000 } }, @@ -57960,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" @@ -57989,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 } }, { @@ -58040,8 +64722,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", @@ -58108,8 +64790,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", @@ -58194,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, @@ -58296,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, @@ -58334,71 +64984,15 @@ } }, { - "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", + "id": "llmgateway/gemma-4-26b-a4b-it", + "name": "Gemma 4 26B A4B IT", "family": "gemma", "attachment": true, - "reasoning": false, - "tool_call": false, + "reasoning": true, + "tool_call": true, "temperature": true, - "release_date": "2025-03-12", - "last_updated": "2025-03-12", + "release_date": "2026-04-02", + "last_updated": "2026-04-02", "modalities": { "input": [ "text", @@ -58410,12 +65004,41 @@ }, "open_weights": true, "cost": { - "input": 0.27, - "output": 0.27 + "input": 0.07, + "output": 0.34 }, "limit": { - "context": 128000, - "output": 16384 + "context": 262144, + "output": 32768 + } + }, + { + "id": "llmgateway/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.13, + "output": 0.38 + }, + "limit": { + "context": 262144, + "output": 32768 } }, { @@ -58473,7 +65096,7 @@ "cache_write": 0.0 }, "limit": { - "context": 131072, + "context": 131000, "output": 98304 } }, @@ -58498,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 } }, @@ -58537,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": true, - "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", @@ -58621,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 } }, @@ -58649,7 +65242,7 @@ }, "open_weights": true, "cost": { - "input": 0.6, + "input": 0.55, "output": 2.2, "cache_read": 0.11, "cache_write": 0.0 @@ -58683,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", @@ -58770,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": { @@ -58801,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": { @@ -58850,8 +65415,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-11", - "last_updated": "2026-02-11", + "release_date": "2026-02-12", + "last_updated": "2026-02-12", "modalities": { "input": [ "text" @@ -58862,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 } }, @@ -58880,8 +65445,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-03-27", - "last_updated": "2026-03-27", + "release_date": "2026-04-07", + "last_updated": "2026-04-07", "modalities": { "input": [ "text" @@ -58890,15 +65455,44 @@ "text" ] }, - "open_weights": false, + "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 + } + }, + { + "id": "llmgateway/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": 1024000, "output": 131072 } }, @@ -59019,7 +65613,7 @@ "cache_read": 0.5 }, "limit": { - "context": 1047576, + "context": 1000000, "output": 32768 } }, @@ -59051,7 +65645,7 @@ "cache_read": 0.1 }, "limit": { - "context": 1047576, + "context": 1000000, "output": 32768 } }, @@ -59082,7 +65676,7 @@ "cache_read": 0.025 }, "limit": { - "context": 1047576, + "context": 1000000, "output": 32768 } }, @@ -59887,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, @@ -59897,7 +66491,8 @@ "last_updated": "2025-07-09", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -59906,7 +66501,8 @@ "open_weights": false, "cost": { "input": 3.0, - "output": 15.0 + "output": 15.0, + "cache_read": 0.75 }, "limit": { "context": 256000, @@ -59914,7 +66510,38 @@ } }, { - "id": "llmgateway/grok-4-20", + "id": "llmgateway/grok-4-20-beta-0309-non-reasoning", + "name": "Grok 4.20 (Non-Reasoning)", + "family": "grok", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-09", + "last_updated": "2026-03-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 6.0, + "cache_read": 0.2 + }, + "limit": { + "context": 2000000, + "output": 30000 + } + }, + { + "id": "llmgateway/grok-4-20-beta-0309-reasoning", "name": "Grok 4.20 (Reasoning)", "family": "grok", "attachment": true, @@ -59935,8 +66562,8 @@ }, "open_weights": false, "cost": { - "input": 1.25, - "output": 2.5, + "input": 2.0, + "output": 6.0, "cache_read": 0.2 }, "limit": { @@ -59945,38 +66572,7 @@ } }, { - "id": "llmgateway/grok-4-20-beta", - "name": "Grok 4.20 (Reasoning)", - "family": "grok", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-03-09", - "last_updated": "2026-03-09", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.25, - "output": 2.5, - "cache_read": 0.2 - }, - "limit": { - "context": 2000000, - "output": 30000 - } - }, - { - "id": "llmgateway/grok-4-20-beta-0309-non", + "id": "llmgateway/grok-4-20-non-reasoning", "name": "Grok 4.20 (Non-Reasoning)", "family": "grok", "attachment": true, @@ -60007,11 +66603,11 @@ } }, { - "id": "llmgateway/grok-4-20-non", - "name": "Grok 4.20 (Non-Reasoning)", + "id": "llmgateway/grok-4-20-reasoning", + "name": "Grok 4.20 (Reasoning)", "family": "grok", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2026-03-09", @@ -60038,15 +66634,15 @@ } }, { - "id": "llmgateway/grok-4-fast", - "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", @@ -60064,11 +66660,11 @@ }, "limit": { "context": 2000000, - "output": 30000 + "output": 2000000 } }, { - "id": "llmgateway/grok-4.1-fast", + "id": "llmgateway/grok-4.1-fast-reasoning", "name": "Grok 4.1 Fast Reasoning", "family": "grok", "attachment": true, @@ -60121,7 +66717,7 @@ "cost": { "input": 1.25, "output": 2.5, - "cache_read": 0.2 + "cache_read": 0.3125 }, "limit": { "context": 1000000, @@ -60129,37 +66725,70 @@ } }, { - "id": "llmgateway/hermes-2-pro-llama-3-8b", - "name": "Hermes 2 Pro Llama 3 8B", - "family": "hermes", - "attachment": false, - "reasoning": false, - "tool_call": false, + "id": "llmgateway/grok-4.5", + "name": "Grok 4.5", + "family": "grok", + "attachment": true, + "reasoning": true, + "tool_call": true, "temperature": true, - "release_date": "2024-05-27", - "last_updated": "2024-05-27", + "release_date": "2026-07-08", + "last_updated": "2026-07-08", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { - "input": 0.14, - "output": 0.14 + "input": 2.0, + "output": 6.0, + "cache_read": 0.5 }, "limit": { - "context": 8192, - "output": 8192 + "context": 500000, + "output": 500000 + } + }, + { + "id": "llmgateway/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": 1.0, + "output": 2.0, + "cache_read": 0.2 + }, + "limit": { + "context": 256000, + "output": 256000 } }, { "id": "llmgateway/kimi-k2", "name": "Kimi K2", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -60176,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 } }, @@ -60208,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, @@ -60248,7 +66847,7 @@ { "id": "llmgateway/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi-k2.5", + "family": "kimi-k2", "attachment": false, "reasoning": true, "tool_call": true, @@ -60268,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, @@ -60280,7 +66879,7 @@ { "id": "llmgateway/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -60299,10 +66898,74 @@ ] }, "open_weights": true, + "cost": { + "input": 0.4, + "output": 2.2, + "cache_read": 0.08 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "llmgateway/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.16 + "cache_read": 0.19 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "llmgateway/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", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.9, + "output": 8.0, + "cache_read": 0.38 }, "limit": { "context": 262144, @@ -60393,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, @@ -60526,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 } }, @@ -60555,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", @@ -60612,50 +67219,20 @@ }, "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", "family": "mimo", "attachment": true, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2024-12", @@ -60680,7 +67257,7 @@ "cache_read": 0.08 }, "limit": { - "context": 262144, + "context": 256000, "output": 131072 } }, @@ -60710,7 +67287,7 @@ "cache_read": 0.2 }, "limit": { - "context": 1048576, + "context": 1000000, "output": 131072 } }, @@ -60738,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 } }, @@ -60768,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 } }, @@ -60797,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, @@ -60825,8 +67403,8 @@ }, "open_weights": true, "cost": { - "input": 0.3, - "output": 1.2 + "input": 0.27, + "output": 1.1 }, "limit": { "context": 204800, @@ -60887,7 +67465,7 @@ "cache_write": 0.375 }, "limit": { - "context": 204800, + "context": 228700, "output": 131072 } }, @@ -60913,7 +67491,7 @@ "cost": { "input": 0.6, "output": 2.4, - "cache_read": 0.06, + "cache_read": 0.03, "cache_write": 0.375 }, "limit": { @@ -60981,6 +67559,37 @@ "output": 131072 } }, + { + "id": "llmgateway/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.6, + "output": 2.4, + "cache_read": 0.12 + }, + "limit": { + "context": 512000, + "output": 128000 + } + }, { "id": "llmgateway/minimax-text-01", "name": "MiniMax Text 01", @@ -61098,7 +67707,7 @@ }, { "id": "llmgateway/mistral-large", - "name": "Mistral Large (latest)", + "name": "Mistral Large 3", "family": "mistral-large", "attachment": true, "reasoning": false, @@ -61156,6 +67765,35 @@ "output": 16384 } }, + { + "id": "llmgateway/nemotron-3-ultra-550b", + "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.5, + "output": 2.5, + "cache_read": 0.15 + }, + "limit": { + "context": 262144, + "output": 128000 + } + }, { "id": "llmgateway/o1", "name": "o1", @@ -61303,8 +67941,8 @@ }, "open_weights": true, "cost": { - "input": 2.0, - "output": 6.0 + "input": 4.0, + "output": 12.0 }, "limit": { "context": 128000, @@ -61361,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, @@ -61422,8 +68062,8 @@ }, "open_weights": false, "cost": { - "input": 0.07, - "output": 0.27 + "input": 0.2, + "output": 0.8 }, "limit": { "context": 32768, @@ -61452,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 } }, @@ -61541,7 +68183,7 @@ "open_weights": false, "cost": { "input": 0.21, - "output": 0.63 + "output": 0.64 }, "limit": { "context": 131072, @@ -61599,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 } }, @@ -61659,7 +68273,7 @@ "output": 0.8 }, "limit": { - "context": 131072, + "context": 40960, "output": 8192 } }, @@ -61687,7 +68301,7 @@ "output": 0.58 }, "limit": { - "context": 131072, + "context": 262144, "output": 8192 } }, @@ -61715,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 } }, @@ -61771,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 } }, @@ -61823,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 } }, { @@ -61884,7 +68414,7 @@ "output": 0.03 }, "limit": { - "context": 131072, + "context": 128000, "output": 8192 } }, @@ -61909,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 } }, @@ -61938,8 +68468,8 @@ }, "open_weights": true, "cost": { - "input": 1.5, - "output": 7.5 + "input": 0.3, + "output": 1.3 }, "limit": { "context": 262144, @@ -61968,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, @@ -61996,7 +68528,8 @@ "open_weights": false, "cost": { "input": 0.108, - "output": 0.675 + "output": 0.675, + "cache_read": 0.06 }, "limit": { "context": 262144, @@ -62022,13 +68555,15 @@ "text" ] }, - "open_weights": true, + "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 } }, @@ -62053,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, @@ -62082,8 +68619,8 @@ }, "open_weights": true, "cost": { - "input": 0.5, - "output": 2.0 + "input": 0.15, + "output": 1.2 }, "limit": { "context": 131072, @@ -62111,8 +68648,8 @@ }, "open_weights": true, "cost": { - "input": 0.5, - "output": 6.0 + "input": 0.15, + "output": 1.2 }, "limit": { "context": 131072, @@ -62169,8 +68706,8 @@ }, "open_weights": true, "cost": { - "input": 0.5, - "output": 2.0 + "input": 0.98, + "output": 3.95 }, "limit": { "context": 131072, @@ -62285,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 } }, @@ -62317,13 +68854,43 @@ "open_weights": false, "cost": { "input": 0.2, - "output": 1.6 + "output": 1.6, + "cache_read": 0.04, + "cache_write": 0.25 }, "limit": { "context": 262144, "output": 32768 } }, + { + "id": "llmgateway/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.1, + "output": 0.15 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, { "id": "llmgateway/qwen3.6-35b-a3b", "name": "Qwen3.6 35B-A3B", @@ -62355,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", @@ -62415,7 +69013,7 @@ "cache_write": 0.625 }, "limit": { - "context": 1000000, + "context": 262144, "output": 65536 } }, @@ -62439,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": { @@ -62449,6 +69047,38 @@ "output": 65536 } }, + { + "id": "llmgateway/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.4, + "output": 1.6, + "cache_read": 0.08, + "cache_write": 0.5 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, { "id": "llmgateway/qwen35-397b-a17b", "name": "Qwen3.5 397B-A17B", @@ -62654,7 +69284,7 @@ "output": 1.0 }, "limit": { - "context": 128000, + "context": 130000, "output": 4096 } }, @@ -62718,6 +69348,174 @@ "output": 4096 } }, + { + "id": "llmtr/gemma-4", + "name": "Gemma 4", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 5.0, + "output": 10.0 + }, + "limit": { + "context": 32768, + "output": 8192 + } + }, + { + "id": "llmtr/magibu-11b-v8", + "name": "Magibu 11B v8", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-06-05", + "last_updated": "2026-06-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 8192, + "output": 4096 + } + }, + { + "id": "llmtr/medgemma-4b", + "name": "MedGemma 4B", + "attachment": true, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-04-26", + "last_updated": "2026-04-26", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 3.0, + "output": 5.0 + }, + "limit": { + "context": 8192, + "output": 4096 + } + }, + { + "id": "llmtr/qwen3-6-35b", + "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": 5.0, + "output": 10.0 + }, + "limit": { + "context": 16384, + "output": 65536 + } + }, + { + "id": "llmtr/sincap", + "name": "Sincap", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-05-05", + "last_updated": "2026-05-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 128000, + "output": 8192 + } + }, + { + "id": "llmtr/trendyol-7b", + "name": "Trendyol 7B", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-06-06", + "last_updated": "2026-06-06", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 32768, + "output": 8192 + } + }, { "id": "lmstudio/openai/gpt-oss-20b", "name": "GPT OSS 20B", @@ -62804,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", @@ -62833,6 +69660,66 @@ "output": 8000 } }, + { + "id": "lucidquery/lucidquery-agi-01-frontier", + "name": "AGI-01 Frontier", + "family": "agi", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2026-06-05", + "release_date": "2026-06-16", + "last_updated": "2026-06-16", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 4.5, + "output": 22.0 + }, + "limit": { + "context": 300000, + "output": 120000 + } + }, + { + "id": "lucidquery/lucidquery-agi-01-swift", + "name": "AGI-01 Swift", + "family": "agi", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2026-06-05", + "release_date": "2026-06-16", + "last_updated": "2026-06-16", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.5, + "output": 15.0 + }, + "limit": { + "context": 300000, + "output": 120000 + } + }, { "id": "lucidquery/lucidquery-nexus-coder", "name": "LucidQuery Nexus Coder", @@ -63294,7 +70181,7 @@ { "id": "meganova/moonshotai/Kimi-K2.5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": true, "tool_call": true, @@ -63407,6 +70294,65 @@ "output": 131072 } }, + { + "id": "merge-gateway/alibaba/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.5, + "output": 3.0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "merge-gateway/alibaba/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.65, + "output": 4.95 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "merge-gateway/anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5", @@ -63514,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": { @@ -63605,6 +70551,37 @@ "output": 128000 } }, + { + "id": "merge-gateway/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 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "merge-gateway/anthropic/claude-sonnet-4", "name": "Claude Sonnet 4", @@ -63800,8 +70777,8 @@ "tool_call": true, "temperature": true, "knowledge": "2024-06-01", - "release_date": "2024-02-27", - "last_updated": "2024-02-27", + "release_date": "2024-12-02", + "last_updated": "2024-12-02", "modalities": { "input": [ "text" @@ -63889,8 +70866,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", @@ -63957,8 +70934,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", @@ -64516,6 +71493,36 @@ "output": 131072 } }, + { + "id": "merge-gateway/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.6, + "output": 2.4 + }, + "limit": { + "context": 512000, + "output": 128000 + } + }, { "id": "merge-gateway/mistral/codestral", "name": "Codestral (latest)", @@ -64593,7 +71600,7 @@ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { "input": 0.4, "output": 2.0 @@ -64651,7 +71658,7 @@ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { "input": 2.0, "output": 5.0 @@ -64663,7 +71670,7 @@ }, { "id": "merge-gateway/mistral/mistral-large", - "name": "Mistral Large (latest)", + "name": "Mistral Large 3", "family": "mistral-large", "attachment": true, "reasoning": false, @@ -64781,6 +71788,159 @@ "output": 128000 } }, + { + "id": "merge-gateway/moonshotai/kimi-k2-thinking", + "name": "Kimi K2 Thinking", + "family": "kimi-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-08", + "release_date": "2025-11-06", + "last_updated": "2025-11-06", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.6, + "output": 2.5 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "merge-gateway/moonshotai/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", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.6, + "output": 3.0 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "merge-gateway/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-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.95, + "output": 4.0 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "merge-gateway/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.95, + "output": 4.0 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "merge-gateway/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", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.9, + "output": 8.0 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "merge-gateway/openai/gpt-4.1", "name": "GPT-4.1", @@ -64878,7 +72038,7 @@ }, { "id": "merge-gateway/openai/gpt-4o", - "name": "GPT-4o (2024-05-13)", + "name": "GPT-4o", "family": "gpt", "attachment": true, "reasoning": false, @@ -64886,11 +72046,12 @@ "temperature": true, "knowledge": "2023-09", "release_date": "2024-05-13", - "last_updated": "2024-05-13", + "last_updated": "2024-08-06", "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -64898,12 +72059,13 @@ }, "open_weights": false, "cost": { - "input": 5.0, - "output": 15.0 + "input": 2.5, + "output": 10.0, + "cache_read": 1.25 }, "limit": { "context": 128000, - "output": 4096 + "output": 16384 } }, { @@ -65469,7 +72631,7 @@ } }, { - "id": "merge-gateway/xai/grok-4.20", + "id": "merge-gateway/xai/grok-4.20-0309-reasoning", "name": "Grok 4.20 (Reasoning)", "family": "grok", "attachment": true, @@ -65495,7 +72657,7 @@ "cache_read": 0.2 }, "limit": { - "context": 2000000, + "context": 1000000, "output": 30000 } }, @@ -65693,8 +72855,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-11", - "last_updated": "2026-02-11", + "release_date": "2026-02-12", + "last_updated": "2026-02-12", "modalities": { "input": [ "text" @@ -65753,8 +72915,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-03-27", - "last_updated": "2026-03-27", + "release_date": "2026-04-07", + "last_updated": "2026-04-07", "modalities": { "input": [ "text" @@ -65763,7 +72925,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 1.4, "output": 4.4, @@ -65775,6 +72937,34 @@ "output": 131072 } }, + { + "id": "merge-gateway/zai/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": 1000000, + "output": 131072 + } + }, { "id": "meta-llama/cerebras-llama-4-maverick-17b-128e-instruct", "name": "Cerebras-Llama-4-Maverick-17B-128E-Instruct", @@ -65980,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", @@ -66156,6 +73377,38 @@ "output": 131072 } }, + { + "id": "minimax-cn-coding-plan/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-25", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "minimax-cn/MiniMax-M2", "name": "MiniMax-M2", @@ -66332,6 +73585,37 @@ "output": 131072 } }, + { + "id": "minimax-cn/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-25", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "minimax-coding-plan/MiniMax-M2", "name": "MiniMax-M2", @@ -66508,6 +73792,38 @@ "output": 131072 } }, + { + "id": "minimax-coding-plan/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-25", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "minimax/MiniMax-M2", "name": "MiniMax-M2", @@ -66684,6 +74000,37 @@ "output": 131072 } }, + { + "id": "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-25", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "mistralai/codestral", "name": "Codestral (latest)", @@ -66773,15 +74120,15 @@ }, { "id": "mistralai/devstral-small", - "name": "Devstral Small 2505", + "name": "Devstral Small", "family": "devstral", "attachment": false, "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2025-05", - "release_date": "2025-05-07", - "last_updated": "2025-05-07", + "release_date": "2025-07-10", + "last_updated": "2025-07-10", "modalities": { "input": [ "text" @@ -66976,7 +74323,7 @@ }, { "id": "mistralai/mistral-large", - "name": "Mistral Large (latest)", + "name": "Mistral Large 3", "family": "mistral-large", "attachment": true, "reasoning": false, @@ -67006,7 +74353,7 @@ }, { "id": "mistralai/mistral-medium", - "name": "Mistral Medium (latest)", + "name": "Mistral Medium 3.5", "family": "mistral-medium", "attachment": true, "reasoning": true, @@ -67064,7 +74411,7 @@ }, { "id": "mistralai/mistral-small", - "name": "Mistral Small (latest)", + "name": "Mistral Small 4", "family": "mistral-small", "attachment": true, "reasoning": true, @@ -67121,6 +74468,35 @@ "output": 8000 } }, + { + "id": "mistralai/open-mistral-nemo", + "name": "Open Mistral Nemo", + "family": "mistral-nemo", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2024-07", + "release_date": "2024-07-01", + "last_updated": "2024-07-01", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.15, + "output": 0.15 + }, + "limit": { + "context": 128000, + "output": 128000 + } + }, { "id": "mistralai/open-mixtral-8x22b", "name": "Mixtral 8x22B", @@ -67642,7 +75018,7 @@ { "id": "moonshotai-cn/kimi-k2-0711-preview", "name": "Kimi K2 0711", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -67672,7 +75048,7 @@ { "id": "moonshotai-cn/kimi-k2-0905-preview", "name": "Kimi K2 0905", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -67762,7 +75138,7 @@ { "id": "moonshotai-cn/kimi-k2-turbo-preview", "name": "Kimi K2 Turbo", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -67792,7 +75168,7 @@ { "id": "moonshotai-cn/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi-k2.5", + "family": "kimi-k2", "attachment": false, "reasoning": true, "tool_call": true, @@ -67824,7 +75200,7 @@ { "id": "moonshotai-cn/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -67853,10 +75229,74 @@ "output": 262144 } }, + { + "id": "moonshotai-cn/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": "moonshotai-cn/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", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.9, + "output": 8.0, + "cache_read": 0.38 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "moonshotai/kimi-k2-0711-preview", "name": "Kimi K2 0711", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -67886,7 +75326,7 @@ { "id": "moonshotai/kimi-k2-0905-preview", "name": "Kimi K2 0905", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -67976,7 +75416,7 @@ { "id": "moonshotai/kimi-k2-turbo-preview", "name": "Kimi K2 Turbo", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -68006,7 +75446,7 @@ { "id": "moonshotai/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi-k2.5", + "family": "kimi-k2", "attachment": false, "reasoning": true, "tool_call": true, @@ -68038,7 +75478,7 @@ { "id": "moonshotai/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -68067,6 +75507,70 @@ "output": 262144 } }, + { + "id": "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.95, + "output": 4.0, + "cache_read": 0.19 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "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", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.9, + "output": 8.0, + "cache_read": 0.38 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "morph/auto", "name": "Auto", @@ -68256,33 +75760,6 @@ "output": 32768 } }, - { - "id": "nano-gpt/CrucibleLab/L3.3-70B-Loki-V2.0", - "name": "L3.3 70B Loki v2.0", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2026-01-22", - "last_updated": "2026-01-22", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.49299999999999994, - "output": 0.49299999999999994 - }, - "limit": { - "context": 16384, - "output": 16384 - } - }, { "id": "nano-gpt/Doctor-Shotgun/MS3.2-24B-Magnum-Diamond", "name": "MS3.2 24B Magnum Diamond", @@ -68472,188 +75949,6 @@ "output": 8192 } }, - { - "id": "nano-gpt/GLM-4.5-Air-Derestricted", - "name": "GLM 4.5 Air Derestricted", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-07-28", - "last_updated": "2025-07-28", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 202600, - "output": 98304 - } - }, - { - "id": "nano-gpt/GLM-4.5-Air-Derestricted-Iceblink", - "name": "GLM 4.5 Air Derestricted Iceblink", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-07-28", - "last_updated": "2025-07-28", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 131072, - "output": 98304 - } - }, - { - "id": "nano-gpt/GLM-4.5-Air-Derestricted-Iceblink-ReExtract", - "name": "GLM 4.5 Air Derestricted Iceblink ReExtract", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-12-12", - "last_updated": "2025-12-12", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 131072, - "output": 98304 - } - }, - { - "id": "nano-gpt/GLM-4.5-Air-Derestricted-Iceblink-v2", - "name": "GLM 4.5 Air Derestricted Iceblink v2", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-07-28", - "last_updated": "2025-07-28", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 158600, - "output": 65536 - } - }, - { - "id": "nano-gpt/GLM-4.5-Air-Derestricted-Iceblink-v2-ReExtract", - "name": "GLM 4.5 Air Derestricted Iceblink v2 ReExtract", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-12-12", - "last_updated": "2025-12-12", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 131072, - "output": 65536 - } - }, - { - "id": "nano-gpt/GLM-4.5-Air-Derestricted-Steam", - "name": "GLM 4.5 Air Derestricted Steam", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-07-28", - "last_updated": "2025-07-28", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 220600, - "output": 65536 - } - }, - { - "id": "nano-gpt/GLM-4.5-Air-Derestricted-Steam-ReExtract", - "name": "GLM 4.5 Air Derestricted Steam ReExtract", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-12-12", - "last_updated": "2025-12-12", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 131072, - "output": 65536 - } - }, { "id": "nano-gpt/GLM-4.6-Derestricted-v5", "name": "GLM 4.6 Derestricted v5", @@ -68708,16 +76003,18 @@ } }, { - "id": "nano-gpt/Gemma-3-27B-ArliAI-RPMax-v3", - "name": "Gemma 3 27B RPMax v3", - "attachment": false, - "reasoning": false, + "id": "nano-gpt/Gemma-4-31B-Claude-4.6-Opus-Reasoning-Distilled", + "name": "Gemma 4 31B Claude 4.6 Opus Reasoning Distilled", + "attachment": true, + "reasoning": true, "tool_call": false, - "release_date": "2025-07-03", - "last_updated": "2025-07-03", + "release_date": "2026-05-01", + "last_updated": "2026-05-01", "modalities": { "input": [ - "text" + "text", + "image", + "video" ], "output": [ "text" @@ -68726,24 +76023,27 @@ "open_weights": false, "cost": { "input": 0.306, - "output": 0.306 + "output": 0.306, + "cache_read": 0.0306 }, "limit": { - "context": 32768, + "context": 262144, "output": 16384 } }, { - "id": "nano-gpt/Gemma-3-27B-Big-Tiger-v3", - "name": "Gemma 3 27B Big Tiger v3", - "attachment": false, - "reasoning": false, + "id": "nano-gpt/Gemma-4-31B-Cognitive-Unshackled", + "name": "Gemma 4 31B Cognitive Unshackled", + "attachment": true, + "reasoning": true, "tool_call": false, - "release_date": "2025-08-08", - "last_updated": "2025-08-08", + "release_date": "2026-05-01", + "last_updated": "2026-05-01", "modalities": { "input": [ - "text" + "text", + "image", + "video" ], "output": [ "text" @@ -68755,21 +76055,23 @@ "output": 0.306 }, "limit": { - "context": 32768, + "context": 262144, "output": 16384 } }, { - "id": "nano-gpt/Gemma-3-27B-CardProjector-v4", - "name": "Gemma 3 27B CardProjector v4", - "attachment": false, - "reasoning": false, + "id": "nano-gpt/Gemma-4-31B-DarkIdol", + "name": "Gemma 4 31B DarkIdol", + "attachment": true, + "reasoning": true, "tool_call": false, - "release_date": "2025-03-10", - "last_updated": "2025-03-10", + "release_date": "2026-05-01", + "last_updated": "2026-05-01", "modalities": { "input": [ - "text" + "text", + "image", + "video" ], "output": [ "text" @@ -68781,21 +76083,23 @@ "output": 0.306 }, "limit": { - "context": 32768, + "context": 262144, "output": 16384 } }, { - "id": "nano-gpt/Gemma-3-27B-Glitter", - "name": "Gemma 3 27B Glitter", - "attachment": false, - "reasoning": false, + "id": "nano-gpt/Gemma-4-31B-GarnetV2", + "name": "Gemma 4 31B Garnet V2", + "attachment": true, + "reasoning": true, "tool_call": false, - "release_date": "2025-03-10", - "last_updated": "2025-03-10", + "release_date": "2026-05-01", + "last_updated": "2026-05-01", "modalities": { "input": [ - "text" + "text", + "image", + "video" ], "output": [ "text" @@ -68807,21 +76111,23 @@ "output": 0.306 }, "limit": { - "context": 32768, + "context": 262144, "output": 16384 } }, { - "id": "nano-gpt/Gemma-3-27B-Nidum-Uncensored", - "name": "Gemma 3 27B Nidum Uncensored", - "attachment": false, - "reasoning": false, + "id": "nano-gpt/Gemma-4-31B-Gemopus", + "name": "Gemma 4 31B Gemopus", + "attachment": true, + "reasoning": true, "tool_call": false, - "release_date": "2025-08-08", - "last_updated": "2025-08-08", + "release_date": "2026-05-01", + "last_updated": "2026-05-01", "modalities": { "input": [ - "text" + "text", + "image", + "video" ], "output": [ "text" @@ -68833,47 +76139,23 @@ "output": 0.306 }, "limit": { - "context": 32768, - "output": 96000 - } - }, - { - "id": "nano-gpt/Gemma-3-27B-it", - "name": "Gemma 3 27B IT", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-03-10", - "last_updated": "2025-03-10", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, + "context": 262144, "output": 16384 } }, { - "id": "nano-gpt/Gemma-3-27B-it-Abliterated", - "name": "Gemma 3 27B IT Abliterated", - "attachment": false, - "reasoning": false, + "id": "nano-gpt/Gemma-4-31B-Musica-v1", + "name": "Gemma 4 31B Musica v1", + "attachment": true, + "reasoning": true, "tool_call": false, - "release_date": "2025-07-03", - "last_updated": "2025-07-03", + "release_date": "2026-05-01", + "last_updated": "2026-05-01", "modalities": { "input": [ - "text" + "text", + "image", + "video" ], "output": [ "text" @@ -68881,12 +76163,68 @@ }, "open_weights": false, "cost": { - "input": 0.42, - "output": 0.42 + "input": 0.306, + "output": 0.306 }, "limit": { - "context": 32768, - "output": 96000 + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Gemma-4-31B-Queen", + "name": "Gemma 4 31B Queen", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-05-01", + "last_updated": "2026-05-01", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Gemma-4-31B-it", + "name": "Gemma 4 31B IT", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-09", + "last_updated": "2026-04-09", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 } }, { @@ -68995,32 +76333,6 @@ "output": 32768 } }, - { - "id": "nano-gpt/KAT-Coder-Pro-V1", - "name": "KAT Coder Pro V1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-10-28", - "last_updated": "2025-10-28", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.5, - "output": 6.0 - }, - "limit": { - "context": 256000, - "output": 32768 - } - }, { "id": "nano-gpt/LLM360/K2-Think", "name": "K2-Think", @@ -69075,1124 +76387,6 @@ "output": 16384 } }, - { - "id": "nano-gpt/Llama-3.3+(3.1v3.3)-70B-Hanami-x1", - "name": "Llama 3.3+ 70B Hanami x1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3+(3.1v3.3)-70B-New-Dawn-v1.1", - "name": "Llama 3.3+ 70B New Dawn v1.1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3+(3v3.3)-70B-TenyxChat-DaybreakStorywriter", - "name": "Llama 3.3+ 70B TenyxChat DaybreakStorywriter", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Anthrobomination", - "name": "Llama 3.3 70B Anthrobomination", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Argunaut-1-SFT", - "name": "Llama 3.3 70B Argunaut 1 SFT", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-ArliAI-RPMax-v1.4", - "name": "Llama 3.3 70B RPMax v1.4", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-ArliAI-RPMax-v2", - "name": "Llama 3.3 70B ArliAI RPMax v2", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-08-08", - "last_updated": "2025-08-08", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-ArliAI-RPMax-v3", - "name": "Llama 3.3 70B ArliAI RPMax v3", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Aurora-Borealis", - "name": "Llama 3.3 70B Aurora Borealis", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Bigger-Body", - "name": "Llama 3.3 70B Bigger Body", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Cirrus-x1", - "name": "Llama 3.3 70B Cirrus x1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Cu-Mai-R1", - "name": "Llama 3.3 70B Cu Mai R1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Damascus-R1", - "name": "Damascus R1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Dark-Ages-v0.1", - "name": "Llama 3.3 70B Dark Ages v0.1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Electra-R1", - "name": "Llama 3.3 70B Electra R1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Electranova-v1.0", - "name": "Llama 3.3 70B Electranova v1.0", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Fallen-R1-v1", - "name": "Llama 3.3 70B Fallen R1 v1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Fallen-v1", - "name": "Llama 3.3 70B Fallen v1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Forgotten-Abomination-v5.0", - "name": "Llama 3.3 70B Forgotten Abomination v5.0", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Forgotten-Safeword-3.6", - "name": "Llama 3.3 70B Forgotten Safeword 3.6", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-GeneticLemonade-Opus", - "name": "Llama 3.3 70B GeneticLemonade Opus", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-GeneticLemonade-Unleashed-v3", - "name": "Llama 3.3 70B GeneticLemonade Unleashed v3", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Ignition-v0.1", - "name": "Llama 3.3 70B Ignition v0.1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Incandescent-Malevolence", - "name": "Llama 3.3 70B Incandescent Malevolence", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Legion-V2.1", - "name": "Llama 3.3 70B Legion V2.1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-MS-Nevoria", - "name": "Llama 3.3 70B MS Nevoria", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Magnum-v4-SE", - "name": "Llama 3.3 70B Magnum v4 SE", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Magnum-v4-SE-Cirrus-x1-SLERP", - "name": "Llama 3.3 70B Magnum v4 SE Cirrus x1 SLERP", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-07-26", - "last_updated": "2025-07-26", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Mhnnn-x1", - "name": "Llama 3.3 70B Mhnnn x1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-MiraiFanfare", - "name": "Llama 3.3 70b Mirai Fanfare", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-07-26", - "last_updated": "2025-07-26", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.493, - "output": 0.493 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Mokume-Gane-R1", - "name": "Llama 3.3 70B Mokume Gane R1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Nova", - "name": "Llama 3.3 70B Nova", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Predatorial-Extasy", - "name": "Llama 3.3 70B Predatorial Extasy", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Progenitor-V3.3", - "name": "Llama 3.3 70B Progenitor V3.3", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-07-26", - "last_updated": "2025-07-26", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-RAWMAW", - "name": "Llama 3.3 70B RAWMAW", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Sapphira-0.1", - "name": "Llama 3.3 70B Sapphira 0.1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Sapphira-0.2", - "name": "Llama 3.3 70B Sapphira 0.2", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Shakudo", - "name": "Llama 3.3 70B Shakudo", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-StrawberryLemonade-v1.0", - "name": "Llama 3.3 70B StrawberryLemonade v1.0", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Strawberrylemonade-v1.2", - "name": "Llama 3.3 70B StrawberryLemonade v1.2", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-The-Omega-Directive-Unslop-v2.0", - "name": "Llama 3.3 70B Omega Directive Unslop v2.0", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-The-Omega-Directive-Unslop-v2.1", - "name": "Llama 3.3 70B Omega Directive Unslop v2.1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, - { - "id": "nano-gpt/Llama-3.3-70B-Vulpecula-R1", - "name": "Llama 3.3 70B Vulpecula R1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.306, - "output": 0.306 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, { "id": "nano-gpt/Magistral-Small", "name": "Magistral Small 2506", @@ -70351,61 +76545,6 @@ "output": 131072 } }, - { - "id": "nano-gpt/Mistral-Nemo-12B-Instruct", - "name": "Mistral Nemo 12B Instruct 2407", - "attachment": true, - "reasoning": false, - "tool_call": false, - "release_date": "2024-07-18", - "last_updated": "2024-07-18", - "modalities": { - "input": [ - "text", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.01, - "output": 0.01 - }, - "limit": { - "context": 16384, - "output": 16384 - } - }, - { - "id": "nano-gpt/NeverSleep/Llama-3-Lumimaid-70B-v0.1", - "name": "Lumimaid 70b", - "family": "llama", - "attachment": true, - "reasoning": false, - "tool_call": false, - "release_date": "2024-07-01", - "last_updated": "2024-07-01", - "modalities": { - "input": [ - "text", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 2.006, - "output": 2.006 - }, - "limit": { - "context": 16384, - "output": 8192 - } - }, { "id": "nano-gpt/NeverSleep/Lumimaid-v0.2-70B", "name": "Lumimaid v0.2", @@ -70434,9 +76573,8 @@ } }, { - "id": "nano-gpt/NousResearch 2/DeepHermes-3-Mistral-24B-Preview", + "id": "nano-gpt/NousResearch/DeepHermes-3-Mistral-24B-Preview", "name": "DeepHermes-3 Mistral 24B (Preview)", - "family": "nousresearch", "attachment": false, "reasoning": false, "tool_call": false, @@ -70461,9 +76599,8 @@ } }, { - "id": "nano-gpt/NousResearch 2/Hermes-4-70B:thinking", + "id": "nano-gpt/NousResearch/Hermes-4-70B:thinking", "name": "Hermes 4 (Thinking)", - "family": "nousresearch", "attachment": false, "reasoning": false, "tool_call": false, @@ -70480,7 +76617,7 @@ "open_weights": false, "cost": { "input": 0.2006, - "output": 0.39949999999999997 + "output": 0.3995 }, "limit": { "context": 128000, @@ -70488,9 +76625,8 @@ } }, { - "id": "nano-gpt/NousResearch 2/hermes-3-llama-3.1-70b", + "id": "nano-gpt/NousResearch/hermes-3-llama-3.1-70b", "name": "Hermes 3 70B", - "family": "nousresearch", "attachment": false, "reasoning": false, "tool_call": false, @@ -70515,12 +76651,11 @@ } }, { - "id": "nano-gpt/NousResearch 2/hermes-4-405b", + "id": "nano-gpt/NousResearch/hermes-4-405b", "name": "Hermes 4 Large", - "family": "nousresearch", "attachment": false, "reasoning": false, - "tool_call": true, + "tool_call": false, "release_date": "2025-08-26", "last_updated": "2025-08-26", "modalities": { @@ -70542,14 +76677,13 @@ } }, { - "id": "nano-gpt/NousResearch 2/hermes-4-405b:thinking", + "id": "nano-gpt/NousResearch/hermes-4-405b:thinking", "name": "Hermes 4 Large (Thinking)", - "family": "nousresearch", "attachment": false, "reasoning": false, - "tool_call": true, - "release_date": "2025-01-01", - "last_updated": "2025-01-01", + "tool_call": false, + "release_date": "2024-01-01", + "last_updated": "2024-01-01", "modalities": { "input": [ "text" @@ -70569,9 +76703,8 @@ } }, { - "id": "nano-gpt/NousResearch 2/hermes-4-70b", + "id": "nano-gpt/NousResearch/hermes-4-70b", "name": "Hermes 4 Medium", - "family": "nousresearch", "attachment": false, "reasoning": false, "tool_call": false, @@ -70588,39 +76721,13 @@ "open_weights": false, "cost": { "input": 0.2006, - "output": 0.39949999999999997 + "output": 0.3995 }, "limit": { "context": 128000, "output": 8192 } }, - { - "id": "nano-gpt/QwQ-32B-ArliAI-RpR-v1", - "name": "QwQ 32b Arli V1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-02-17", - "last_updated": "2025-02-17", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.2, - "output": 0.2 - }, - "limit": { - "context": 32768, - "output": 32768 - } - }, { "id": "nano-gpt/Qwen2.5-32B-EVA-v0.2", "name": "Qwen 2.5 32b EVA", @@ -70647,6 +76754,846 @@ "output": 8192 } }, + { + "id": "nano-gpt/Qwen3.5-27B-Anko", + "name": "Qwen3.5 27B Anko", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-30", + "last_updated": "2026-04-30", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-BlueStar-Derestricted", + "name": "Qwen3.5 27B BlueStar Derestricted", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-06", + "last_updated": "2026-04-06", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-BlueStar-Derestricted-Lite", + "name": "Qwen3.5 27B BlueStar Derestricted Lite", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-06", + "last_updated": "2026-04-06", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-BlueStar-v2-Derestricted", + "name": "Qwen3.5 27B BlueStar v2 Derestricted", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-06", + "last_updated": "2026-04-06", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-BlueStar-v2-Derestricted-Lite", + "name": "Qwen3.5 27B BlueStar v2 Derestricted Lite", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-06", + "last_updated": "2026-04-06", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-BlueStar-v3-Derestricted", + "name": "Qwen3.5 27B BlueStar v3 Derestricted", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-30", + "last_updated": "2026-04-30", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-BlueStar-v3-Derestricted-Lite", + "name": "Qwen3.5 27B BlueStar v3 Derestricted Lite", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-30", + "last_updated": "2026-04-30", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-Derestricted", + "name": "Qwen3.5 27B Derestricted", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-03-17", + "last_updated": "2026-03-17", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-Infracelestial", + "name": "Qwen3.5 27B Infracelestial", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-30", + "last_updated": "2026-04-30", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-Marvin-DPO-V2-Derestricted", + "name": "Qwen3.5 27B Marvin DPO V2 Derestricted", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-30", + "last_updated": "2026-04-30", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-Marvin-DPO-V2-Derestricted-Lite", + "name": "Qwen3.5 27B Marvin DPO V2 Derestricted Lite", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-30", + "last_updated": "2026-04-30", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-Marvin-V2-Derestricted", + "name": "Qwen3.5 27B Marvin V2 Derestricted", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-30", + "last_updated": "2026-04-30", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-Marvin-V2-Derestricted-Lite", + "name": "Qwen3.5 27B Marvin V2 Derestricted Lite", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-30", + "last_updated": "2026-04-30", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-Musica-v1", + "name": "Qwen3.5 27B Musica v1", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-03-27", + "last_updated": "2026-03-27", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-NaNovel-Derestricted", + "name": "Qwen3.5 27B NaNovel Derestricted", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-30", + "last_updated": "2026-04-30", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-NaNovel-Derestricted-Lite", + "name": "Qwen3.5 27B NaNovel Derestricted Lite", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-30", + "last_updated": "2026-04-30", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-Omega-Evolution-v2.0-Derestricted", + "name": "Qwen3.5 27B Omega Evolution v2.0 Derestricted", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-06", + "last_updated": "2026-04-06", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-Omega-Evolution-v2.0-Derestricted-Lite", + "name": "Qwen3.5 27B Omega Evolution v2.0 Derestricted Lite", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-06", + "last_updated": "2026-04-06", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-Omega-Evolution-v2.2-Derestricted", + "name": "Qwen3.5 27B Omega Evolution v2.2 Derestricted", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-05-02", + "last_updated": "2026-05-02", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-Omega-Evolution-v2.2-Derestricted-Lite", + "name": "Qwen3.5 27B Omega Evolution v2.2 Derestricted Lite", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-05-02", + "last_updated": "2026-05-02", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-Queen-Derestricted", + "name": "Qwen3.5 27B Queen Derestricted", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-30", + "last_updated": "2026-04-30", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-Queen-Derestricted-Lite", + "name": "Qwen3.5 27B Queen Derestricted Lite", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-30", + "last_updated": "2026-04-30", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-RpRMax-v1", + "name": "Qwen3.5 27B RpRMax v1", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-30", + "last_updated": "2026-04-30", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-Vivid-Durian", + "name": "Qwen3.5 27B Vivid Durian", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-03-18", + "last_updated": "2026-03-18", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-Writer-Derestricted", + "name": "Qwen3.5 27B Writer Derestricted", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-06", + "last_updated": "2026-04-06", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-Writer-Derestricted-Lite", + "name": "Qwen3.5 27B Writer Derestricted Lite", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-06", + "last_updated": "2026-04-06", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-Writer-V2-Derestricted", + "name": "Qwen3.5 27B Writer V2 Derestricted", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-06", + "last_updated": "2026-04-06", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-Writer-V2-Derestricted-Lite", + "name": "Qwen3.5 27B Writer V2 Derestricted Lite", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-06", + "last_updated": "2026-04-06", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-earica-Derestricted", + "name": "Qwen3.5 27B earica Derestricted", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-30", + "last_updated": "2026-04-30", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/Qwen3.5-27B-earica-Derestricted-Lite", + "name": "Qwen3.5 27B earica Derestricted Lite", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-30", + "last_updated": "2026-04-30", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, { "id": "nano-gpt/ReadyArt/MS3.2-The-Omega-Directive-24B-Unslop-v2.0", "name": "Omega Directive 24B Unslop v2.0", @@ -70674,33 +77621,6 @@ "output": 32768 } }, - { - "id": "nano-gpt/ReadyArt/The-Omega-Abomination-L-70B-v1.0", - "name": "The Omega Abomination V1", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-01", - "last_updated": "2024-12-01", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.7, - "output": 0.95 - }, - "limit": { - "context": 16384, - "output": 16384 - } - }, { "id": "nano-gpt/Salesforce/Llama-xLAM-2-70b-fc-r", "name": "Llama-xLAM-2 70B fc-r", @@ -70998,33 +77918,6 @@ "output": 16384 } }, - { - "id": "nano-gpt/TEE/deepseek-r1", - "name": "DeepSeek R1 0528 TEE", - "family": "deepseek", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-05-28", - "last_updated": "2025-05-28", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 2.0, - "output": 2.0 - }, - "limit": { - "context": 128000, - "output": 65536 - } - }, { "id": "nano-gpt/TEE/deepseek-v3.1", "name": "DeepSeek V3.1 TEE", @@ -71079,6 +77972,60 @@ "output": 65536 } }, + { + "id": "nano-gpt/TEE/deepseek-v4-pro", + "name": "DeepSeek V4 Pro TEE", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-25", + "last_updated": "2026-04-25", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.5, + "output": 5.25, + "cache_read": 0.15 + }, + "limit": { + "context": 800000, + "output": 65536 + } + }, + { + "id": "nano-gpt/TEE/deepseek-v4-pro:thinking", + "name": "DeepSeek V4 Pro Thinking TEE", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-29", + "last_updated": "2026-04-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.5, + "output": 5.25, + "cache_read": 0.15 + }, + "limit": { + "context": 800000, + "output": 65536 + } + }, { "id": "nano-gpt/TEE/gemma-3-27b-it", "name": "Gemma 3 27B TEE", @@ -71107,14 +78054,40 @@ } }, { - "id": "nano-gpt/TEE/glm-4.6", - "name": "GLM 4.6 TEE", - "family": "glm", - "attachment": false, + "id": "nano-gpt/TEE/gemma-4-26b-a4b-uncensored", + "name": "Gemma 4 26B A4B Uncensored TEE", + "attachment": true, "reasoning": false, - "tool_call": false, - "release_date": "2025-09-30", - "last_updated": "2025-09-30", + "tool_call": true, + "release_date": "2026-05-23", + "last_updated": "2026-05-23", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.15, + "output": 0.7 + }, + "limit": { + "context": 65536, + "output": 65536 + } + }, + { + "id": "nano-gpt/TEE/gemma-4-31b-it", + "name": "Gemma 4 31B IT TEE", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-26", + "last_updated": "2026-05-26", "modalities": { "input": [ "text" @@ -71125,12 +78098,64 @@ }, "open_weights": false, "cost": { - "input": 0.75, - "output": 2.0 + "input": 0.15, + "output": 0.46 }, "limit": { - "context": 203000, - "output": 65535 + "context": 262144, + "output": 262144 + } + }, + { + "id": "nano-gpt/TEE/gemma4-31b", + "name": "Gemma 4 31B", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2026-04-04", + "last_updated": "2026-04-04", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.45, + "output": 1.0 + }, + "limit": { + "context": 262144, + "output": 131072 + } + }, + { + "id": "nano-gpt/TEE/gemma4-31b:thinking", + "name": "Gemma 4 31B Thinking TEE", + "attachment": false, + "reasoning": true, + "tool_call": false, + "release_date": "2026-05-02", + "last_updated": "2026-05-02", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.45, + "output": 1.0 + }, + "limit": { + "context": 262144, + "output": 131072 } }, { @@ -71214,6 +78239,60 @@ "output": 65535 } }, + { + "id": "nano-gpt/TEE/glm-5.1", + "name": "GLM 5.1 TEE", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2026-04-20", + "last_updated": "2026-04-20", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.5, + "output": 5.25, + "cache_read": 0.3 + }, + "limit": { + "context": 202752, + "output": 65535 + } + }, + { + "id": "nano-gpt/TEE/glm-5.1-thinking", + "name": "GLM 5.1 Thinking TEE", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-20", + "last_updated": "2026-04-20", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.5, + "output": 5.25, + "cache_read": 0.3 + }, + "limit": { + "context": 202752, + "output": 65535 + } + }, { "id": "nano-gpt/TEE/gpt-oss-120b", "name": "GPT-OSS 120B TEE", @@ -71268,37 +78347,10 @@ "output": 8192 } }, - { - "id": "nano-gpt/TEE/kimi-k2-thinking", - "name": "Kimi K2 Thinking TEE", - "family": "kimi-thinking", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-11-06", - "last_updated": "2025-11-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 2.0, - "output": 2.0 - }, - "limit": { - "context": 128000, - "output": 65535 - } - }, { "id": "nano-gpt/TEE/kimi-k2.5", "name": "Kimi K2.5 TEE", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": false, @@ -71349,6 +78401,34 @@ "output": 65535 } }, + { + "id": "nano-gpt/TEE/kimi-k2.6", + "name": "Kimi K2.6 TEE", + "attachment": true, + "reasoning": false, + "tool_call": true, + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.5, + "output": 5.25, + "cache_read": 0.375 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, { "id": "nano-gpt/TEE/llama3-3-70b", "name": "Llama 3.3 70B", @@ -71377,14 +78457,13 @@ } }, { - "id": "nano-gpt/TEE/minimax-m2.1", - "name": "MiniMax M2.1 TEE", - "family": "minimax", + "id": "nano-gpt/TEE/minimax-m2.5", + "name": "MiniMax M2.5 TEE", "attachment": false, "reasoning": true, "tool_call": true, - "release_date": "2025-12-23", - "last_updated": "2025-12-23", + "release_date": "2026-04-20", + "last_updated": "2026-04-20", "modalities": { "input": [ "text" @@ -71395,11 +78474,11 @@ }, "open_weights": false, "cost": { - "input": 0.3, - "output": 1.2 + "input": 0.2, + "output": 1.38 }, "limit": { - "context": 200000, + "context": 196608, "output": 131072 } }, @@ -71459,14 +78538,13 @@ } }, { - "id": "nano-gpt/TEE/qwen3-coder", - "name": "Qwen3 Coder 480B TEE", - "family": "qwen", + "id": "nano-gpt/TEE/qwen3.5-122b-a10b", + "name": "Qwen3.5 122B A10B TEE", "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-07-23", - "last_updated": "2025-07-23", + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-26", + "last_updated": "2026-05-26", "modalities": { "input": [ "text" @@ -71477,12 +78555,40 @@ }, "open_weights": false, "cost": { - "input": 1.5, - "output": 2.0 + "input": 0.46, + "output": 3.68 }, "limit": { - "context": 128000, - "output": 32768 + "context": 262144, + "output": 262144 + } + }, + { + "id": "nano-gpt/TEE/qwen3.5-27b", + "name": "Qwen3.5 27B TEE", + "attachment": true, + "reasoning": false, + "tool_call": false, + "release_date": "2026-03-13", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 2.4 + }, + "limit": { + "context": 262144, + "output": 65536 } }, { @@ -71512,6 +78618,33 @@ "output": 65536 } }, + { + "id": "nano-gpt/TEE/qwen3.6-35b-a3b-uncensored", + "name": "Qwen3.6 35B A3B Uncensored TEE", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-23", + "last_updated": "2026-05-23", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 1.5 + }, + "limit": { + "context": 131072, + "output": 131072 + } + }, { "id": "nano-gpt/THUDM/GLM-4-32B", "name": "GLM 4 32B 0414", @@ -71621,41 +78754,13 @@ } }, { - "id": "nano-gpt/THUDM/GLM-Z1-Rumination-32B", - "name": "GLM Z1 Rumination 32B 0414", - "family": "glm-z", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-04-15", - "last_updated": "2025-04-15", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.2, - "output": 0.2 - }, - "limit": { - "context": 32000, - "output": 65536 - } - }, - { - "id": "nano-gpt/TheDrummer 2/Anubis-70B-v1", + "id": "nano-gpt/TheDrummer/Anubis-70B-v1", "name": "Anubis 70B v1", - "family": "llama", "attachment": false, "reasoning": false, "tool_call": false, - "release_date": "2024-07-01", - "last_updated": "2024-07-01", + "release_date": "2024-01-01", + "last_updated": "2024-01-01", "modalities": { "input": [ "text" @@ -71675,14 +78780,13 @@ } }, { - "id": "nano-gpt/TheDrummer 2/Anubis-70B-v1.1", + "id": "nano-gpt/TheDrummer/Anubis-70B-v1.1", "name": "Anubis 70B v1.1", - "family": "llama", "attachment": false, "reasoning": false, "tool_call": false, - "release_date": "2024-07-01", - "last_updated": "2024-07-01", + "release_date": "2024-01-01", + "last_updated": "2024-01-01", "modalities": { "input": [ "text" @@ -71702,9 +78806,8 @@ } }, { - "id": "nano-gpt/TheDrummer 2/Cydonia-24B-v2", + "id": "nano-gpt/TheDrummer/Cydonia-24B-v2", "name": "The Drummer Cydonia 24B v2", - "family": "llama", "attachment": false, "reasoning": false, "tool_call": false, @@ -71729,9 +78832,8 @@ } }, { - "id": "nano-gpt/TheDrummer 2/Cydonia-24B-v4", + "id": "nano-gpt/TheDrummer/Cydonia-24B-v4", "name": "The Drummer Cydonia 24B v4", - "family": "llama", "attachment": false, "reasoning": false, "tool_call": false, @@ -71756,9 +78858,8 @@ } }, { - "id": "nano-gpt/TheDrummer 2/Cydonia-24B-v4.1", + "id": "nano-gpt/TheDrummer/Cydonia-24B-v4.1", "name": "The Drummer Cydonia 24B v4.1", - "family": "llama", "attachment": false, "reasoning": false, "tool_call": false, @@ -71783,9 +78884,8 @@ } }, { - "id": "nano-gpt/TheDrummer 2/Cydonia-24B-v4.3", + "id": "nano-gpt/TheDrummer/Cydonia-24B-v4.3", "name": "The Drummer Cydonia 24B v4.3", - "family": "llama", "attachment": false, "reasoning": false, "tool_call": false, @@ -71810,9 +78910,8 @@ } }, { - "id": "nano-gpt/TheDrummer 2/Magidonia-24B-v4.3", + "id": "nano-gpt/TheDrummer/Magidonia-24B-v4.3", "name": "The Drummer Magidonia 24B v4.3", - "family": "llama", "attachment": false, "reasoning": false, "tool_call": false, @@ -71837,14 +78936,13 @@ } }, { - "id": "nano-gpt/TheDrummer 2/Rocinante-12B-v1.1", + "id": "nano-gpt/TheDrummer/Rocinante-12B-v1.1", "name": "Rocinante 12b", - "family": "llama", "attachment": false, "reasoning": false, "tool_call": false, - "release_date": "2024-07-01", - "last_updated": "2024-07-01", + "release_date": "2024-01-01", + "last_updated": "2024-01-01", "modalities": { "input": [ "text" @@ -71864,14 +78962,13 @@ } }, { - "id": "nano-gpt/TheDrummer 2/UnslopNemo-12B-v4.1", - "name": "UnslopNemo 12b v4", - "family": "llama", + "id": "nano-gpt/TheDrummer/Skyfall-31B-v4.2", + "name": "TheDrummer Skyfall 31B v4.2", "attachment": true, "reasoning": false, "tool_call": false, - "release_date": "2024-07-01", - "last_updated": "2024-07-01", + "release_date": "2026-03-26", + "last_updated": "2026-03-26", "modalities": { "input": [ "text", @@ -71883,8 +78980,35 @@ }, "open_weights": false, "cost": { - "input": 0.49299999999999994, - "output": 0.49299999999999994 + "input": 0.55, + "output": 0.8 + }, + "limit": { + "context": 131072, + "output": 16384 + } + }, + { + "id": "nano-gpt/TheDrummer/UnslopNemo-12B-v4.1", + "name": "UnslopNemo 12b v4", + "attachment": true, + "reasoning": false, + "tool_call": false, + "release_date": "2024-01-01", + "last_updated": "2024-01-01", + "modalities": { + "input": [ + "text", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.493, + "output": 0.493 }, "limit": { "context": 32768, @@ -71892,9 +79016,8 @@ } }, { - "id": "nano-gpt/TheDrummer 2/skyfall-36b-v2", + "id": "nano-gpt/TheDrummer/skyfall-36b-v2", "name": "TheDrummer Skyfall 36B V2", - "family": "llama", "attachment": true, "reasoning": false, "tool_call": false, @@ -71911,8 +79034,8 @@ }, "open_weights": false, "cost": { - "input": 0.49299999999999994, - "output": 0.49299999999999994 + "input": 0.493, + "output": 0.493 }, "limit": { "context": 64000, @@ -71946,6 +79069,32 @@ "output": 40960 } }, + { + "id": "nano-gpt/Unbabel/M-Prometheus-14B", + "name": "M-Prometheus 14B", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2026-05-29", + "last_updated": "2026-05-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.2, + "output": 0.2 + }, + "limit": { + "context": 32768, + "output": 8192 + } + }, { "id": "nano-gpt/VongolaChouko/Starcannon-Unleashed-12B-v1.0", "name": "Mistral Nemo Starcannon 12b v1", @@ -72054,6 +79203,59 @@ "output": 8192 } }, + { + "id": "nano-gpt/aion-labs/aion-2.0", + "name": "AionLabs: Aion-2.0", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2026-02-23", + "last_updated": "2026-02-23", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.8, + "output": 1.6 + }, + "limit": { + "context": 131072, + "output": 32768 + } + }, + { + "id": "nano-gpt/aion-labs/aion-2.5", + "name": "AionLabs: Aion-2.5", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2026-03-20", + "last_updated": "2026-03-20", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 3.0, + "cache_read": 0.35 + }, + "limit": { + "context": 131072, + "output": 32768 + } + }, { "id": "nano-gpt/aion-labs/aion-rp-llama-3.1-8b", "name": "Llama 3.1 8b (uncensored)", @@ -72081,6 +79283,62 @@ "output": 16384 } }, + { + "id": "nano-gpt/alibaba/qwen3.6-27b", + "name": "Qwen3.6 27B", + "attachment": true, + "reasoning": false, + "tool_call": false, + "release_date": "2026-04-23", + "last_updated": "2026-04-23", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.203, + "output": 2.24 + }, + "limit": { + "context": 260096, + "output": 65536 + } + }, + { + "id": "nano-gpt/alibaba/qwen3.6-27b:thinking", + "name": "Qwen3.6 27B Thinking", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-23", + "last_updated": "2026-04-23", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.203, + "output": 2.24 + }, + "limit": { + "context": 260096, + "output": 65536 + } + }, { "id": "nano-gpt/alibaba/qwen3.6-flash", "name": "Qwen3.6 Flash", @@ -72110,34 +79368,6 @@ "output": 65536 } }, - { - "id": "nano-gpt/allenai/molmo-2-8b", - "name": "Molmo 2 8B", - "family": "allenai", - "attachment": true, - "reasoning": false, - "tool_call": false, - "release_date": "2026-02-14", - "last_updated": "2026-02-14", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.2, - "output": 0.2 - }, - "limit": { - "context": 36864, - "output": 36864 - } - }, { "id": "nano-gpt/allenai/olmo-3-32b-think", "name": "Olmo 3 32B Think", @@ -72165,60 +79395,6 @@ "output": 8192 } }, - { - "id": "nano-gpt/allenai/olmo-3.1-32b-instruct", - "name": "Olmo 3.1 32B Instruct", - "family": "allenai", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2026-01-25", - "last_updated": "2026-01-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.2, - "output": 0.6 - }, - "limit": { - "context": 65536, - "output": 8192 - } - }, - { - "id": "nano-gpt/allenai/olmo-3.1-32b-think", - "name": "Olmo 3.1 32B Think", - "family": "allenai", - "attachment": false, - "reasoning": true, - "tool_call": false, - "release_date": "2026-01-25", - "last_updated": "2026-01-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.15, - "output": 0.5 - }, - "limit": { - "context": 65536, - "output": 8192 - } - }, { "id": "nano-gpt/amazon/nova-2-lite-v1", "name": "Amazon Nova 2 Lite", @@ -72382,6 +79558,64 @@ "output": 8192 } }, + { + "id": "nano-gpt/anthropic/claude-haiku", + "name": "Claude Haiku Latest", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-03-29", + "last_updated": "2026-03-29", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 5.0, + "cache_read": 0.1 + }, + "limit": { + "context": 200000, + "output": 64000 + } + }, + { + "id": "nano-gpt/anthropic/claude-opus", + "name": "Claude Opus Latest", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-03-29", + "last_updated": "2026-03-29", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 4.998, + "output": 25.007, + "cache_read": 0.4998 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "nano-gpt/anthropic/claude-opus-4.6", "name": "Claude 4.6 Opus", @@ -72527,6 +79761,151 @@ "output": 128000 } }, + { + "id": "nano-gpt/anthropic/claude-opus-4.7", + "name": "Claude 4.7 Opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 4.998, + "output": 25.007, + "cache_read": 0.4998 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "nano-gpt/anthropic/claude-opus-4.7:thinking", + "name": "Claude 4.7 Opus Thinking", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 4.998, + "output": 25.007, + "cache_read": 0.4998 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "nano-gpt/anthropic/claude-opus-4.8", + "name": "Claude Opus 4.8", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-28", + "last_updated": "2026-05-28", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 4.998, + "output": 25.007, + "cache_read": 0.4998 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "nano-gpt/anthropic/claude-opus-4.8:thinking", + "name": "Claude Opus 4.8 Thinking", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-28", + "last_updated": "2026-05-28", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 4.998, + "output": 25.007, + "cache_read": 0.4998 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "nano-gpt/anthropic/claude-sonnet", + "name": "Claude Sonnet Latest", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-03-01", + "last_updated": "2026-03-01", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.992, + "output": 14.994, + "cache_read": 0.2992 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "nano-gpt/anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6", @@ -72588,14 +79967,13 @@ } }, { - "id": "nano-gpt/arcee-ai/trinity-large", - "name": "Trinity Large", - "family": "trinity", + "id": "nano-gpt/arcee-ai/trinity-large-thinking", + "name": "Trinity Large Thinking", "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-12-01", - "last_updated": "2025-12-01", + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-01", + "last_updated": "2026-04-01", "modalities": { "input": [ "text" @@ -72607,11 +79985,11 @@ "open_weights": false, "cost": { "input": 0.25, - "output": 1.0 + "output": 0.9 }, "limit": { - "context": 131072, - "output": 8192 + "context": 262144, + "output": 80000 } }, { @@ -72904,33 +80282,6 @@ "output": 65536 } }, - { - "id": "nano-gpt/baidu/ernie-4.5-300b-a47b", - "name": "ERNIE 4.5 300B", - "family": "ernie", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-06-30", - "last_updated": "2025-06-30", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.35, - "output": 1.15 - }, - "limit": { - "context": 131072, - "output": 16384 - } - }, { "id": "nano-gpt/baidu/ernie-4.5-vl-28b-a3b", "name": "ERNIE 4.5 VL 28B", @@ -72962,7 +80313,7 @@ { "id": "nano-gpt/baseten/Kimi-K2-Instruct-FP4", "name": "Kimi K2 0711 Instruct FP4", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": false, @@ -73065,27 +80416,29 @@ } }, { - "id": "nano-gpt/chroma", - "name": "Chroma", - "attachment": true, + "id": "nano-gpt/bytedance-seed/seed-2.0-lite", + "name": "ByteDance Seed 2.0 Lite", + "attachment": false, "reasoning": false, "tool_call": false, - "temperature": true, - "release_date": "2025-08-12", - "last_updated": "2025-08-12", + "release_date": "2026-03-10", + "last_updated": "2026-03-10", "modalities": { "input": [ "text" ], "output": [ - "image" + "text" ] }, "open_weights": false, - "cost": {}, + "cost": { + "input": 0.25, + "output": 2.0 + }, "limit": { - "context": 0, - "output": 0 + "context": 262144, + "output": 131072 } }, { @@ -73143,228 +80496,6 @@ "output": 8192 } }, - { - "id": "nano-gpt/claude-3.5-sonnet", - "name": "Claude 3.5 Sonnet", - "attachment": true, - "reasoning": false, - "tool_call": true, - "release_date": "2025-08-26", - "last_updated": "2025-08-26", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 2.992, - "output": 14.994 - }, - "limit": { - "context": 200000, - "output": 8192 - } - }, - { - "id": "nano-gpt/claude-3.7-sonnet", - "name": "Claude 3.7 Sonnet", - "attachment": true, - "reasoning": false, - "tool_call": true, - "release_date": "2025-02-19", - "last_updated": "2025-02-19", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 2.992, - "output": 14.994 - }, - "limit": { - "context": 200000, - "output": 16000 - } - }, - { - "id": "nano-gpt/claude-3.7-sonnet-reasoner", - "name": "Claude 3.7 Sonnet Reasoner", - "attachment": true, - "reasoning": false, - "tool_call": false, - "release_date": "2025-03-29", - "last_updated": "2025-03-29", - "modalities": { - "input": [ - "text", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 3.0, - "output": 15.0 - }, - "limit": { - "context": 128000, - "output": 8192 - } - }, - { - "id": "nano-gpt/claude-3.7-sonnet-thinking", - "name": "Claude 3.7 Sonnet Thinking", - "attachment": true, - "reasoning": true, - "tool_call": true, - "release_date": "2025-02-24", - "last_updated": "2025-02-24", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 2.992, - "output": 14.994 - }, - "limit": { - "context": 200000, - "output": 16000 - } - }, - { - "id": "nano-gpt/claude-3.7-sonnet-thinking:1024", - "name": "Claude 3.7 Sonnet Thinking (1K)", - "attachment": true, - "reasoning": true, - "tool_call": true, - "release_date": "2025-02-24", - "last_updated": "2025-02-24", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 2.992, - "output": 14.994 - }, - "limit": { - "context": 200000, - "output": 64000 - } - }, - { - "id": "nano-gpt/claude-3.7-sonnet-thinking:128000", - "name": "Claude 3.7 Sonnet Thinking (128K)", - "attachment": true, - "reasoning": true, - "tool_call": true, - "release_date": "2025-02-24", - "last_updated": "2025-02-24", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 2.992, - "output": 14.994 - }, - "limit": { - "context": 200000, - "output": 64000 - } - }, - { - "id": "nano-gpt/claude-3.7-sonnet-thinking:32768", - "name": "Claude 3.7 Sonnet Thinking (32K)", - "attachment": true, - "reasoning": true, - "tool_call": true, - "release_date": "2025-07-15", - "last_updated": "2025-07-15", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 2.992, - "output": 14.994 - }, - "limit": { - "context": 200000, - "output": 64000 - } - }, - { - "id": "nano-gpt/claude-3.7-sonnet-thinking:8192", - "name": "Claude 3.7 Sonnet Thinking (8K)", - "attachment": true, - "reasoning": true, - "tool_call": true, - "release_date": "2025-02-24", - "last_updated": "2025-02-24", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 2.992, - "output": 14.994 - }, - "limit": { - "context": 200000, - "output": 64000 - } - }, { "id": "nano-gpt/claude-haiku-4.5", "name": "Claude Haiku 4.5", @@ -73393,6 +80524,35 @@ "output": 64000 } }, + { + "id": "nano-gpt/claude-haiku-4.5-20251001-thinking", + "name": "Claude Haiku 4.5 Thinking", + "attachment": true, + "reasoning": true, + "tool_call": true, + "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 + }, + "limit": { + "context": 200000, + "output": 64000 + } + }, { "id": "nano-gpt/claude-opus-4", "name": "Claude 4 Opus", @@ -74009,6 +81169,89 @@ "output": 64000 } }, + { + "id": "nano-gpt/claw-high", + "name": "Claw High", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-11", + "last_updated": "2026-05-11", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 4.998, + "output": 25.007 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "nano-gpt/claw-low", + "name": "Claw Low", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-11", + "last_updated": "2026-05-11", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.25, + "output": 1.5, + "cache_read": 0.025 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "nano-gpt/claw-medium", + "name": "Claw Medium", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-11", + "last_updated": "2026-05-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 1.2 + }, + "limit": { + "context": 204800, + "output": 131072 + } + }, { "id": "nano-gpt/cognitivecomputations/dolphin-2.9.2-qwen2-72b", "name": "Dolphin 72b", @@ -74090,6 +81333,33 @@ "output": 4096 } }, + { + "id": "nano-gpt/command-a-plus-05", + "name": "Cohere Command A+ (05/2026)", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-05-22", + "last_updated": "2026-05-22", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.5, + "output": 10.0 + }, + "limit": { + "context": 128000, + "output": 64000 + } + }, { "id": "nano-gpt/command-a-reasoning-08", "name": "Cohere Command A (08/2025)", @@ -74170,33 +81440,6 @@ "output": 32768 } }, - { - "id": "nano-gpt/deepcogito/cogito-v2.1-671b", - "name": "Cogito v2.1 671B MoE", - "family": "cogito", - "attachment": false, - "reasoning": true, - "tool_call": false, - "release_date": "2025-11-19", - "last_updated": "2025-11-19", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.25, - "output": 1.25 - }, - "limit": { - "context": 128000, - "output": 16384 - } - }, { "id": "nano-gpt/deepseek-ai/DeepSeek-R1", "name": "DeepSeek R1 0528", @@ -74597,6 +81840,33 @@ "output": 8192 } }, + { + "id": "nano-gpt/deepseek/deepseek", + "name": "DeepSeek Latest", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-03", + "last_updated": "2026-05-03", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.1, + "output": 2.2, + "cache_read": 0.11 + }, + "limit": { + "context": 1048576, + "output": 384000 + } + }, { "id": "nano-gpt/deepseek/deepseek-prover-v2-671b", "name": "DeepSeek Prover v2 671B", @@ -74708,6 +81978,168 @@ "output": 65536 } }, + { + "id": "nano-gpt/deepseek/deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.14, + "output": 0.28, + "cache_read": 0.028 + }, + "limit": { + "context": 1048576, + "output": 384000 + } + }, + { + "id": "nano-gpt/deepseek/deepseek-v4-flash:thinking", + "name": "DeepSeek V4 Flash (Thinking)", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.14, + "output": 0.28, + "cache_read": 0.028 + }, + "limit": { + "context": 1048576, + "output": 384000 + } + }, + { + "id": "nano-gpt/deepseek/deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.1, + "output": 2.2, + "cache_read": 0.11 + }, + "limit": { + "context": 1048576, + "output": 384000 + } + }, + { + "id": "nano-gpt/deepseek/deepseek-v4-pro-cheaper", + "name": "DeepSeek V4 Pro Cheaper", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-25", + "last_updated": "2026-04-25", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.435, + "output": 0.87, + "cache_read": 0.003625 + }, + "limit": { + "context": 1048576, + "output": 384000 + } + }, + { + "id": "nano-gpt/deepseek/deepseek-v4-pro-cheaper:thinking", + "name": "DeepSeek V4 Pro Cheaper (Thinking)", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-25", + "last_updated": "2026-04-25", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.435, + "output": 0.87, + "cache_read": 0.003625 + }, + "limit": { + "context": 1048576, + "output": 384000 + } + }, + { + "id": "nano-gpt/deepseek/deepseek-v4-pro:thinking", + "name": "DeepSeek V4 Pro (Thinking)", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.1, + "output": 2.2, + "cache_read": 0.11 + }, + "limit": { + "context": 1048576, + "output": 384000 + } + }, { "id": "nano-gpt/dmind/dmind-1", "name": "DMind-1", @@ -75130,32 +82562,6 @@ "output": 128000 } }, - { - "id": "nano-gpt/doubao-seed-code-preview", - "name": "Doubao Seed Code Preview", - "attachment": false, - "reasoning": true, - "tool_call": false, - "release_date": "2025-11-13", - "last_updated": "2025-11-13", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.1, - "output": 0.4 - }, - "limit": { - "context": 256000, - "output": 16384 - } - }, { "id": "nano-gpt/ernie-4.5-8k-preview", "name": "Ernie 4.5 8k Preview", @@ -75237,8 +82643,8 @@ } }, { - "id": "nano-gpt/ernie-5.0-thinking", - "name": "Ernie 5.0 Thinking", + "id": "nano-gpt/ernie-5.0-thinking-preview", + "name": "Ernie 5.0 Thinking Preview", "attachment": true, "reasoning": true, "tool_call": false, @@ -75264,17 +82670,18 @@ } }, { - "id": "nano-gpt/ernie-5.0-thinking-preview", - "name": "Ernie 5.0 Thinking Preview", + "id": "nano-gpt/ernie-5.1", + "name": "ERNIE 5.1", "attachment": true, - "reasoning": true, + "reasoning": false, "tool_call": false, - "release_date": "2025-11-18", - "last_updated": "2025-11-18", + "release_date": "2026-05-10", + "last_updated": "2026-05-10", "modalities": { "input": [ "text", - "image" + "image", + "video" ], "output": [ "text" @@ -75282,12 +82689,42 @@ }, "open_weights": false, "cost": { - "input": 1.1, - "output": 2.0 + "input": 0.75, + "output": 3.0, + "cache_read": 0.75 }, "limit": { - "context": 128000, - "output": 16384 + "context": 119000, + "output": 64000 + } + }, + { + "id": "nano-gpt/ernie-5.1:thinking", + "name": "ERNIE 5.1 Thinking", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-05-10", + "last_updated": "2026-05-10", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.75, + "output": 3.0, + "cache_read": 0.75 + }, + "limit": { + "context": 119000, + "output": 64000 } }, { @@ -75636,33 +83073,6 @@ "output": 8192 } }, - { - "id": "nano-gpt/gemini-2.0-flash-lite", - "name": "Gemini 2.0 Flash Lite", - "attachment": true, - "reasoning": false, - "tool_call": false, - "release_date": "2024-12-11", - "last_updated": "2024-12-11", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0748, - "output": 0.306 - }, - "limit": { - "context": 1000000, - "output": 8192 - } - }, { "id": "nano-gpt/gemini-2.0-flash-thinking-exp", "name": "Gemini 2.0 Flash Thinking 1219", @@ -76260,60 +83670,6 @@ "output": 65536 } }, - { - "id": "nano-gpt/gemini-3-pro-preview", - "name": "Gemini 3 Pro", - "attachment": true, - "reasoning": true, - "tool_call": true, - "release_date": "2025-11-18", - "last_updated": "2025-11-18", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 2.0, - "output": 12.0 - }, - "limit": { - "context": 1048756, - "output": 65536 - } - }, - { - "id": "nano-gpt/gemini-3-pro-preview-thinking", - "name": "Gemini 3 Pro Thinking", - "attachment": true, - "reasoning": true, - "tool_call": true, - "release_date": "2025-11-18", - "last_updated": "2025-11-18", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 2.0, - "output": 12.0 - }, - "limit": { - "context": 1048756, - "output": 65536 - } - }, { "id": "nano-gpt/gemini-exp", "name": "Gemini 2.0 Pro 1206", @@ -76341,6 +83697,146 @@ "output": 8192 } }, + { + "id": "nano-gpt/gemma-4-31B-Fabled", + "name": "Gemma 4 31B Fabled", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-05-02", + "last_updated": "2026-05-02", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/gemma-4-31B-Garnet", + "name": "Gemma 4 31B Garnet", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-05-02", + "last_updated": "2026-05-02", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/gemma-4-31B-K1-v5", + "name": "Gemma 4 31B K1 v5", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-05-02", + "last_updated": "2026-05-02", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/gemma-4-31B-Larkspur-v0.5", + "name": "Gemma 4 31B Larkspur v0.5", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-05-02", + "last_updated": "2026-05-02", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/gemma-4-31B-MeroMero", + "name": "Gemma 4 31B MeroMero", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-05-02", + "last_updated": "2026-05-02", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.306, + "output": 0.306 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, { "id": "nano-gpt/glm-4", "name": "GLM-4", @@ -76369,12 +83865,12 @@ }, { "id": "nano-gpt/glm-4-air", - "name": "GLM 4 Air 0111", + "name": "GLM-4 Air", "attachment": false, "reasoning": false, "tool_call": false, - "release_date": "2025-01-11", - "last_updated": "2025-01-11", + "release_date": "2024-06-05", + "last_updated": "2024-06-05", "modalities": { "input": [ "text" @@ -76385,8 +83881,8 @@ }, "open_weights": false, "cost": { - "input": 0.1394, - "output": 0.1394 + "input": 0.2006, + "output": 0.2006 }, "limit": { "context": 128000, @@ -76473,12 +83969,12 @@ }, { "id": "nano-gpt/glm-4-plus", - "name": "GLM 4 Plus 0111", + "name": "GLM-4 Plus", "attachment": false, "reasoning": false, "tool_call": false, - "release_date": "2025-02-19", - "last_updated": "2025-02-19", + "release_date": "2024-08-01", + "last_updated": "2024-08-01", "modalities": { "input": [ "text" @@ -76489,8 +83985,8 @@ }, "open_weights": false, "cost": { - "input": 9.996, - "output": 9.996 + "input": 7.497, + "output": 7.497 }, "limit": { "context": 128000, @@ -76685,6 +84181,232 @@ "output": 65536 } }, + { + "id": "nano-gpt/google/gemini-3.1-flash-lite", + "name": "Gemini 3.1 Flash Lite", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-03-03", + "last_updated": "2026-03-03", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.25, + "output": 1.5, + "cache_read": 0.025 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "nano-gpt/google/gemini-3.1-pro-preview", + "name": "Gemini 3.1 Pro (Preview)", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-02-19", + "last_updated": "2026-02-19", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 12.0, + "cache_read": 0.2 + }, + "limit": { + "context": 1048756, + "output": 65536 + } + }, + { + "id": "nano-gpt/google/gemini-3.1-pro-preview-customtools", + "name": "Gemini 3.1 Pro (Preview Custom Tools)", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-02-27", + "last_updated": "2026-02-27", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 12.0, + "cache_read": 0.2 + }, + "limit": { + "context": 1048756, + "output": 65536 + } + }, + { + "id": "nano-gpt/google/gemini-3.1-pro-preview-high", + "name": "Gemini 3.1 Pro (Preview High)", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-02-21", + "last_updated": "2026-02-21", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 12.0, + "cache_read": 0.2 + }, + "limit": { + "context": 1048756, + "output": 65536 + } + }, + { + "id": "nano-gpt/google/gemini-3.1-pro-preview-low", + "name": "Gemini 3.1 Pro (Preview Low)", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-02-21", + "last_updated": "2026-02-21", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 12.0, + "cache_read": 0.2 + }, + "limit": { + "context": 1048756, + "output": 65536 + } + }, + { + "id": "nano-gpt/google/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-19", + "last_updated": "2026-05-19", + "modalities": { + "input": [ + "text", + "image", + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.5, + "output": 9.0, + "cache_read": 0.15 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "nano-gpt/google/gemini-3.5-flash-thinking", + "name": "Gemini 3.5 Flash Thinking", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-05-19", + "last_updated": "2026-05-19", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.5, + "output": 9.0, + "cache_read": 0.15 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "nano-gpt/google/gemini-flash", + "name": "Gemini Flash Latest", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-03-29", + "last_updated": "2026-03-29", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.5, + "output": 9.0, + "cache_read": 0.15 + }, + "limit": { + "context": 1048756, + "output": 65536 + } + }, { "id": "nano-gpt/google/gemini-flash-1.5", "name": "Gemini 1.5 Flash", @@ -76713,42 +84435,17 @@ } }, { - "id": "nano-gpt/grok-3-beta", - "name": "Grok 3 Beta", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-09-29", - "last_updated": "2025-09-29", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 3.0, - "output": 15.0 - }, - "limit": { - "context": 131072, - "output": 131072 - } - }, - { - "id": "nano-gpt/grok-3-fast-beta", - "name": "Grok 3 Fast Beta", + "id": "nano-gpt/google/gemini-flash-lite", + "name": "Gemini Flash Lite Latest", "attachment": true, - "reasoning": false, - "tool_call": false, - "release_date": "2025-02-17", - "last_updated": "2025-02-17", + "reasoning": true, + "tool_call": true, + "release_date": "2026-03-29", + "last_updated": "2026-03-29", "modalities": { "input": [ "text", + "image", "pdf" ], "output": [ @@ -76757,22 +84454,216 @@ }, "open_weights": false, "cost": { - "input": 5.0, - "output": 25.0 + "input": 0.25, + "output": 1.5, + "cache_read": 0.025 }, "limit": { - "context": 131072, + "context": 1048576, + "output": 65536 + } + }, + { + "id": "nano-gpt/google/gemini-pro", + "name": "Gemini Pro Latest", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-03-29", + "last_updated": "2026-03-29", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 12.0, + "cache_read": 0.2 + }, + "limit": { + "context": 1048756, + "output": 65536 + } + }, + { + "id": "nano-gpt/google/gemma-4-26b-a4b-it", + "name": "Gemma 4 26B A4B", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.13, + "output": 0.4 + }, + "limit": { + "context": 262144, "output": 131072 } }, { - "id": "nano-gpt/grok-3-mini-beta", - "name": "Grok 3 Mini Beta", - "attachment": false, - "reasoning": false, + "id": "nano-gpt/google/gemma-4-26b-a4b-it:thinking", + "name": "Gemma 4 26B A4B Thinking", + "attachment": true, + "reasoning": true, "tool_call": false, - "release_date": "2025-02-17", - "last_updated": "2025-02-17", + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.13, + "output": 0.4 + }, + "limit": { + "context": 262144, + "output": 131072 + } + }, + { + "id": "nano-gpt/google/gemma-4-31b-it", + "name": "Gemma 4 31B", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.1, + "output": 0.35 + }, + "limit": { + "context": 262144, + "output": 131072 + } + }, + { + "id": "nano-gpt/google/gemma-4-31b-it:thinking", + "name": "Gemma 4 31B Thinking", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.1, + "output": 0.35 + }, + "limit": { + "context": 262144, + "output": 131072 + } + }, + { + "id": "nano-gpt/hermes-high", + "name": "Hermes High", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-11", + "last_updated": "2026-05-11", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 4.998, + "output": 25.007 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "nano-gpt/hermes-low", + "name": "Hermes Low", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-11", + "last_updated": "2026-05-11", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.25, + "output": 1.5, + "cache_read": 0.025 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "nano-gpt/hermes-medium", + "name": "Hermes Medium", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-11", + "last_updated": "2026-05-11", "modalities": { "input": [ "text" @@ -76784,24 +84675,25 @@ "open_weights": false, "cost": { "input": 0.3, - "output": 0.5 + "output": 1.2 }, "limit": { - "context": 131072, + "context": 204800, "output": 131072 } }, { - "id": "nano-gpt/grok-3-mini-fast-beta", - "name": "Grok 3 Mini Fast Beta", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-02-17", - "last_updated": "2025-02-17", + "id": "nano-gpt/holo3-35b-a3b", + "name": "Holo3-35B-A3B", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2024-01-01", + "last_updated": "2024-01-01", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -76809,36 +84701,39 @@ }, "open_weights": false, "cost": { - "input": 0.6, - "output": 4.0 + "input": 0.25, + "output": 1.8 }, "limit": { - "context": 131072, - "output": 131072 + "context": 65536, + "output": 65536 } }, { - "id": "nano-gpt/hidream", - "name": "Hidream", + "id": "nano-gpt/holo3-35b-a3b:thinking", + "name": "Holo3-35B-A3B Thinking", "attachment": true, - "reasoning": false, - "tool_call": false, - "temperature": true, + "reasoning": true, + "tool_call": true, "release_date": "2024-01-01", "last_updated": "2024-01-01", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ - "image" + "text" ] }, "open_weights": false, - "cost": {}, + "cost": { + "input": 0.25, + "output": 1.8 + }, "limit": { - "context": 0, - "output": 0 + "context": 65536, + "output": 65536 } }, { @@ -76895,33 +84790,6 @@ "output": 8192 } }, - { - "id": "nano-gpt/huihui-ai/Llama-3.1-Nemotron-70B-Instruct-HF-abliterated", - "name": "Nemotron 3.1 70B abliterated", - "family": "nemotron", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2024-07-23", - "last_updated": "2024-07-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.7, - "output": 0.7 - }, - "limit": { - "context": 16384, - "output": 16384 - } - }, { "id": "nano-gpt/huihui-ai/Llama-3.3-70B-Instruct-abliterated", "name": "Llama 3.3 70B Instruct abliterated", @@ -76976,32 +84844,6 @@ "output": 8192 } }, - { - "id": "nano-gpt/hunyuan-t1", - "name": "Hunyuan T1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-03-22", - "last_updated": "2025-03-22", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.17, - "output": 0.66 - }, - "limit": { - "context": 256000, - "output": 16384 - } - }, { "id": "nano-gpt/hunyuan-turbos", "name": "Hunyuan Turbo S", @@ -77028,6 +84870,112 @@ "output": 8192 } }, + { + "id": "nano-gpt/ibm-granite/granite-4.1-8b", + "name": "Granite 4.1 8B", + "attachment": false, + "reasoning": false, + "tool_call": true, + "release_date": "2026-04-29", + "last_updated": "2026-04-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.05, + "output": 0.1, + "cache_read": 0.05 + }, + "limit": { + "context": 131072, + "output": 131072 + } + }, + { + "id": "nano-gpt/inclusionai/ling-2.6-1t", + "name": "Ling 2.6 1T", + "attachment": false, + "reasoning": false, + "tool_call": true, + "release_date": "2026-04-23", + "last_updated": "2026-04-23", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 2.5, + "cache_read": 0.06 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, + { + "id": "nano-gpt/inclusionai/ling-2.6-flash", + "name": "Ling 2.6 Flash", + "attachment": false, + "reasoning": false, + "tool_call": true, + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.08, + "output": 0.24 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, + { + "id": "nano-gpt/inclusionai/ring-2.6-1t", + "name": "Ring 2.6 1T", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-08", + "last_updated": "2026-05-08", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 3.0 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, { "id": "nano-gpt/inflatebot/MN-12B-Mag-Mell-R1", "name": "Mag Mell R1", @@ -77319,6 +85267,32 @@ "output": 16384 } }, + { + "id": "nano-gpt/kwaipilot/kat-coder-pro-v2", + "name": "KAT Coder Pro V2", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2026-03-28", + "last_updated": "2026-03-28", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 1.2 + }, + "limit": { + "context": 256000, + "output": 80000 + } + }, { "id": "nano-gpt/learnlm-1.5-pro-experimental", "name": "Gemini LearnLM Experimental", @@ -77345,6 +85319,32 @@ "output": 8192 } }, + { + "id": "nano-gpt/liquid/lfm-2-24b-a2b", + "name": "LFM2 24B A2B", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2025-12-20", + "last_updated": "2025-12-20", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.03, + "output": 0.12 + }, + "limit": { + "context": 32768, + "output": 32768 + } + }, { "id": "nano-gpt/meganova-ai/manta-flash-1.0", "name": "Manta Flash 1.0", @@ -77427,14 +85427,13 @@ } }, { - "id": "nano-gpt/meituan-longcat/LongCat-Flash-Chat-FP8", - "name": "LongCat Flash", - "family": "longcat", + "id": "nano-gpt/mercury-2", + "name": "Mercury 2", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, - "release_date": "2025-08-31", - "last_updated": "2025-08-31", + "release_date": "2024-01-01", + "last_updated": "2024-01-01", "modalities": { "input": [ "text" @@ -77445,12 +85444,13 @@ }, "open_weights": false, "cost": { - "input": 0.15, - "output": 0.7 + "input": 0.25, + "output": 0.75, + "cache_read": 0.025 }, "limit": { "context": 128000, - "output": 32768 + "output": 50000 } }, { @@ -77508,33 +85508,6 @@ "output": 8192 } }, - { - "id": "nano-gpt/meta-llama/llama-3.2-90b-vision-instruct", - "name": "Llama 3.2 Medium", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-09-25", - "last_updated": "2025-09-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.9009999999999999, - "output": 0.9009999999999999 - }, - "limit": { - "context": 131072, - "output": 16384 - } - }, { "id": "nano-gpt/meta-llama/llama-3.3-70b-instruct", "name": "Llama 3.3 70b Instruct", @@ -77618,34 +85591,6 @@ "output": 65536 } }, - { - "id": "nano-gpt/microsoft/MAI-DS-R1-FP8", - "name": "Microsoft DeepSeek R1", - "family": "deepseek", - "attachment": true, - "reasoning": false, - "tool_call": false, - "release_date": "2025-09-25", - "last_updated": "2025-09-25", - "modalities": { - "input": [ - "text", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.3, - "output": 0.3 - }, - "limit": { - "context": 128000, - "output": 8192 - } - }, { "id": "nano-gpt/microsoft/wizardlm-2-8x22b", "name": "WizardLM-2 8x22B", @@ -77674,6 +85619,34 @@ "output": 8192 } }, + { + "id": "nano-gpt/minimax/minimax", + "name": "MiniMax Latest", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-03", + "last_updated": "2026-05-03", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 + }, + "limit": { + "context": 512000, + "output": 80000 + } + }, { "id": "nano-gpt/minimax/minimax-01", "name": "MiniMax 01", @@ -77811,14 +85784,147 @@ } }, { - "id": "nano-gpt/miromind-ai/mirothinker-v1.5-235b", - "name": "MiroThinker v1.5 235B", - "family": "gpt", + "id": "nano-gpt/minimax/minimax-m2.7-turbo", + "name": "MiniMax M2.7 Turbo", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-03-18", + "last_updated": "2026-03-18", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.6, + "output": 2.4 + }, + "limit": { + "context": 204800, + "output": 131072 + } + }, + { + "id": "nano-gpt/minimax/minimax-m3", + "name": "MiniMax M3", + "attachment": true, + "reasoning": false, + "tool_call": true, + "release_date": "2026-06-01", + "last_updated": "2026-06-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 + }, + "limit": { + "context": 512000, + "output": 80000 + } + }, + { + "id": "nano-gpt/minimax/minimax-m3:thinking", + "name": "MiniMax M3 Thinking", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-06-01", + "last_updated": "2026-06-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 + }, + "limit": { + "context": 512000, + "output": 80000 + } + }, + { + "id": "nano-gpt/mirothinker-1.7-deepresearch", + "name": "MiroThinker 1.7 Deep Research", + "attachment": false, + "reasoning": true, + "tool_call": false, + "release_date": "2026-05-11", + "last_updated": "2026-05-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 4.0, + "output": 25.0 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/mirothinker-1.7-deepresearch-mini", + "name": "MiroThinker 1.7 Deep Research Mini", + "attachment": false, + "reasoning": true, + "tool_call": false, + "release_date": "2026-05-11", + "last_updated": "2026-05-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 10.0 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/mistral-code", + "name": "Mistral Code Latest", "attachment": false, "reasoning": false, - "tool_call": false, - "release_date": "2026-01-07", - "last_updated": "2026-01-07", + "tool_call": true, + "release_date": "2026-06-02", + "last_updated": "2026-06-02", "modalities": { "input": [ "text" @@ -77830,11 +85936,37 @@ "open_weights": false, "cost": { "input": 0.3, - "output": 1.2 + "output": 0.9 }, "limit": { - "context": 32768, - "output": 4000 + "context": 256000, + "output": 32768 + } + }, + { + "id": "nano-gpt/mistral-code-agent", + "name": "Mistral Code Agent Latest", + "attachment": false, + "reasoning": false, + "tool_call": true, + "release_date": "2026-06-02", + "last_updated": "2026-06-02", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.4, + "output": 2.0 + }, + "limit": { + "context": 262144, + "output": 32768 } }, { @@ -77864,6 +85996,60 @@ "output": 131072 } }, + { + "id": "nano-gpt/mistral/mistral-medium-3.5", + "name": "Mistral Medium 3.5", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-29", + "last_updated": "2026-04-29", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.5, + "output": 7.5 + }, + "limit": { + "context": 256000, + "output": 32768 + } + }, + { + "id": "nano-gpt/mistral/mistral-medium-3.5:thinking", + "name": "Mistral Medium 3.5 Thinking", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-30", + "last_updated": "2026-04-30", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.5, + "output": 7.5 + }, + "limit": { + "context": 256000, + "output": 32768 + } + }, { "id": "nano-gpt/mistralai/Devstral-Small", "name": "Mistral Devstral Small 2505", @@ -78081,34 +86267,6 @@ "output": 32768 } }, - { - "id": "nano-gpt/mistralai/mistral-7b-instruct", - "name": "Mistral 7B Instruct", - "family": "mistral", - "attachment": true, - "reasoning": false, - "tool_call": false, - "release_date": "2024-05-27", - "last_updated": "2024-05-27", - "modalities": { - "input": [ - "text", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0544, - "output": 0.0544 - }, - "limit": { - "context": 32768, - "output": 8192 - } - }, { "id": "nano-gpt/mistralai/mistral-large", "name": "Mistral Large 2411", @@ -78247,17 +86405,17 @@ } }, { - "id": "nano-gpt/mistralai/mistral-small-creative", - "name": "Mistral Small Creative", - "family": "mistral-small", - "attachment": false, - "reasoning": false, + "id": "nano-gpt/mistralai/mistral-small-4-119b", + "name": "Mistral Small 4 119B", + "attachment": true, + "reasoning": true, "tool_call": true, - "release_date": "2025-12-16", - "last_updated": "2025-12-16", + "release_date": "2026-03-16", + "last_updated": "2026-03-16", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -78265,26 +86423,26 @@ }, "open_weights": false, "cost": { - "input": 0.1, - "output": 0.3 + "input": 0.4, + "output": 1.4 }, "limit": { - "context": 32768, - "output": 32768 + "context": 262144, + "output": 16384 } }, { - "id": "nano-gpt/mistralai/mistral-tiny", - "name": "Mistral Tiny", - "family": "mistral", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2023-12-11", - "last_updated": "2024-01-01", + "id": "nano-gpt/mistralai/mistral-small-4-119b-2603:thinking", + "name": "Mistral Small 4 119B Thinking", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-03-17", + "last_updated": "2026-03-17", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -78292,12 +86450,12 @@ }, "open_weights": false, "cost": { - "input": 0.25499999999999995, - "output": 0.25499999999999995 + "input": 0.4, + "output": 1.4 }, "limit": { - "context": 32000, - "output": 8192 + "context": 262144, + "output": 16384 } }, { @@ -78381,38 +86539,10 @@ "output": 8192 } }, - { - "id": "nano-gpt/moonshotai/Kimi-Dev-72B", - "name": "Kimi Dev 72B", - "family": "kimi", - "attachment": true, - "reasoning": false, - "tool_call": false, - "release_date": "2025-04-15", - "last_updated": "2025-04-15", - "modalities": { - "input": [ - "text", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.4, - "output": 0.4 - }, - "limit": { - "context": 128000, - "output": 131072 - } - }, { "id": "nano-gpt/moonshotai/Kimi-K2-Instruct", "name": "Kimi K2 0905", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -78436,10 +86566,38 @@ "output": 262144 } }, + { + "id": "nano-gpt/moonshotai/kimi", + "name": "Kimi Latest", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-03", + "last_updated": "2026-05-03", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.5, + "output": 2.6, + "cache_read": 0.125 + }, + "limit": { + "context": 256000, + "output": 65536 + } + }, { "id": "nano-gpt/moonshotai/kimi-k2-instruct", "name": "Kimi K2 Instruct", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -78547,7 +86705,7 @@ { "id": "nano-gpt/moonshotai/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": false, "tool_call": true, @@ -78603,7 +86761,7 @@ { "id": "nano-gpt/moonshotai/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": false, "tool_call": true, @@ -78656,6 +86814,141 @@ "output": 65536 } }, + { + "id": "nano-gpt/nanogpt/coding-router", + "name": "Coding Router", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-12", + "last_updated": "2026-05-12", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.1, + "output": 2.2, + "cache_read": 0.11 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "nano-gpt/nanogpt/coding-router:high", + "name": "Coding Router High", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-12", + "last_updated": "2026-05-12", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.1, + "output": 2.2, + "cache_read": 0.11 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "nano-gpt/nanogpt/coding-router:low", + "name": "Coding Router Low", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-12", + "last_updated": "2026-05-12", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.14, + "output": 0.28, + "cache_read": 0.028 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "nano-gpt/nanogpt/coding-router:max", + "name": "Coding Router Max", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-12", + "last_updated": "2026-05-12", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 30.0, + "cache_read": 0.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "nano-gpt/nanogpt/coding-router:medium", + "name": "Coding Router Medium", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-12", + "last_updated": "2026-05-12", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.14, + "output": 0.28, + "cache_read": 0.028 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "nano-gpt/nex-agi/deepseek-v3.1-nex-n1", "name": "DeepSeek V3.1 Nex N1", @@ -78717,6 +87010,7 @@ "attachment": false, "reasoning": false, "tool_call": false, + "temperature": true, "release_date": "2025-04-15", "last_updated": "2025-04-15", "modalities": { @@ -78737,33 +87031,6 @@ "output": 8192 } }, - { - "id": "nano-gpt/nvidia/Llama-3.1-Nemotron-Ultra-253B-v1", - "name": "Nvidia Nemotron Ultra 253B", - "family": "nemotron", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-07-03", - "last_updated": "2025-07-03", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.4, - "output": 0.8 - }, - "limit": { - "context": 128000, - "output": 16384 - } - }, { "id": "nano-gpt/nvidia/Llama-3.3-Nemotron-Super-49B-v1", "name": "Nvidia Nemotron Super 49B", @@ -78771,6 +87038,7 @@ "attachment": false, "reasoning": false, "tool_call": false, + "temperature": true, "release_date": "2025-08-08", "last_updated": "2025-08-08", "modalities": { @@ -78798,6 +87066,7 @@ "attachment": false, "reasoning": false, "tool_call": false, + "temperature": true, "release_date": "2025-08-08", "last_updated": "2025-08-08", "modalities": { @@ -78825,6 +87094,7 @@ "attachment": false, "reasoning": false, "tool_call": false, + "temperature": true, "release_date": "2025-12-15", "last_updated": "2025-12-15", "modalities": { @@ -78845,6 +87115,91 @@ "output": 262144 } }, + { + "id": "nano-gpt/nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + "name": "Nvidia Nemotron 3 Nano Omni", + "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" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.105, + "output": 0.42 + }, + "limit": { + "context": 256000, + "output": 65536 + } + }, + { + "id": "nano-gpt/nvidia/nemotron-3-super-120b-a12b", + "name": "Nvidia Nemotron 3 Super 120B", + "family": "nemotron", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-01", + "last_updated": "2026-03-01", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.05, + "output": 0.25 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/nvidia/nemotron-3-super-120b-a12b:thinking", + "name": "Nvidia Nemotron 3 Super 120B Thinking", + "family": "nemotron", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-01", + "last_updated": "2026-03-01", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.05, + "output": 0.25 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, { "id": "nano-gpt/nvidia/nvidia-nemotron-nano-9b-v2", "name": "Nvidia Nemotron Nano 9B v2", @@ -78852,6 +87207,7 @@ "attachment": false, "reasoning": false, "tool_call": false, + "temperature": true, "release_date": "2025-08-18", "last_updated": "2025-08-18", "modalities": { @@ -78873,18 +87229,18 @@ } }, { - "id": "nano-gpt/openai/chatgpt-4o", - "name": "ChatGPT 4o", - "family": "gpt", + "id": "nano-gpt/openai/gpt", + "name": "GPT Latest", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, - "release_date": "2024-05-13", - "last_updated": "2024-05-13", + "release_date": "2026-03-29", + "last_updated": "2026-03-29", "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -78892,12 +87248,13 @@ }, "open_weights": false, "cost": { - "input": 4.998, - "output": 14.993999999999998 + "input": 5.0, + "output": 30.0, + "cache_read": 0.5 }, "limit": { - "context": 128000, - "output": 16384 + "context": 1000000, + "output": 128000 } }, { @@ -79070,13 +87427,13 @@ }, { "id": "nano-gpt/openai/gpt-4o", - "name": "GPT-4o (2024-11-20)", + "name": "GPT-4o", "family": "gpt", "attachment": true, "reasoning": false, "tool_call": false, - "release_date": "2024-11-20", - "last_updated": "2024-11-20", + "release_date": "2024-05-13", + "last_updated": "2024-05-13", "modalities": { "input": [ "text", @@ -79088,8 +87445,8 @@ }, "open_weights": false, "cost": { - "input": 2.5, - "output": 10.0 + "input": 2.499, + "output": 9.996 }, "limit": { "context": 128000, @@ -79208,34 +87565,6 @@ "output": 128000 } }, - { - "id": "nano-gpt/openai/gpt-5-chat", - "name": "GPT 5 Chat", - "family": "gpt", - "attachment": true, - "reasoning": true, - "tool_call": false, - "release_date": "2025-08-07", - "last_updated": "2025-08-07", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.25, - "output": 10.0 - }, - "limit": { - "context": 400000, - "output": 128000 - } - }, { "id": "nano-gpt/openai/gpt-5-codex", "name": "GPT-5 Codex", @@ -79349,44 +87678,18 @@ }, { "id": "nano-gpt/openai/gpt-5.1", - "name": "GPT-5.1 (2025-11-13)", - "family": "gpt", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-11-13", - "last_updated": "2025-11-13", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.25, - "output": 10.0 - }, - "limit": { - "context": 1000000, - "output": 32768 - } - }, - { - "id": "nano-gpt/openai/gpt-5.1-chat", - "name": "GPT 5.1 Chat (Latest)", + "name": "GPT 5.1", "family": "gpt", "attachment": true, "reasoning": true, - "tool_call": false, + "tool_call": true, "release_date": "2025-11-13", "last_updated": "2025-11-13", "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -79399,7 +87702,7 @@ }, "limit": { "context": 400000, - "output": 16384 + "output": 128000 } }, { @@ -79516,34 +87819,6 @@ "output": 128000 } }, - { - "id": "nano-gpt/openai/gpt-5.2-chat", - "name": "GPT 5.2 Chat", - "family": "gpt", - "attachment": true, - "reasoning": true, - "tool_call": false, - "release_date": "2026-01-01", - "last_updated": "2026-01-01", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.75, - "output": 14.0 - }, - "limit": { - "context": 400000, - "output": 16384 - } - }, { "id": "nano-gpt/openai/gpt-5.2-codex", "name": "GPT 5.2 Codex", @@ -79602,6 +87877,209 @@ "output": 128000 } }, + { + "id": "nano-gpt/openai/gpt-5.3-codex", + "name": "GPT 5.3 Codex", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-02-24", + "last_updated": "2026-02-24", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.75, + "output": 14.0, + "cache_read": 0.175 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "nano-gpt/openai/gpt-5.4", + "name": "GPT 5.4", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-03-05", + "last_updated": "2026-03-05", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.5, + "output": 15.0, + "cache_read": 0.25 + }, + "limit": { + "context": 922000, + "output": 128000 + } + }, + { + "id": "nano-gpt/openai/gpt-5.4-mini", + "name": "GPT 5.4 Mini", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-03-17", + "last_updated": "2026-03-17", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.75, + "output": 4.5, + "cache_read": 0.075 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "nano-gpt/openai/gpt-5.4-nano", + "name": "GPT 5.4 Nano", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-03-17", + "last_updated": "2026-03-17", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.2, + "output": 1.25, + "cache_read": 0.02 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "nano-gpt/openai/gpt-5.4-pro", + "name": "GPT 5.4 Pro", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-03-05", + "last_updated": "2026-03-05", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 30.0, + "output": 180.0, + "cache_read": 3.0 + }, + "limit": { + "context": 922000, + "output": 128000 + } + }, + { + "id": "nano-gpt/openai/gpt-5.5", + "name": "GPT 5.5", + "attachment": true, + "reasoning": true, + "tool_call": true, + "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": 1000000, + "output": 128000 + } + }, + { + "id": "nano-gpt/openai/gpt-chat", + "name": "GPT Chat Latest", + "attachment": true, + "reasoning": false, + "tool_call": true, + "release_date": "2026-05-03", + "last_updated": "2026-05-03", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 30.0, + "cache_read": 0.5 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, { "id": "nano-gpt/openai/gpt-oss-120b", "name": "GPT OSS 120B", @@ -80009,6 +88487,32 @@ "output": 100000 } }, + { + "id": "nano-gpt/owl", + "name": "OWL", + "attachment": false, + "reasoning": false, + "tool_call": true, + "release_date": "2026-05-01", + "last_updated": "2026-05-01", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.1, + "output": 0.3 + }, + "limit": { + "context": 1048756, + "output": 262144 + } + }, { "id": "nano-gpt/pamanseau/OpenReasoning-Nemotron-32B", "name": "OpenReasoning Nemotron 32B", @@ -80036,6 +88540,34 @@ "output": 65536 } }, + { + "id": "nano-gpt/perceptron/perceptron-mk1", + "name": "Perceptron Mk1", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-05-12", + "last_updated": "2026-05-12", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.15, + "output": 1.5 + }, + "limit": { + "context": 32768, + "output": 8192 + } + }, { "id": "nano-gpt/phi-4-mini-instruct", "name": "Phi 4 Mini", @@ -80088,6 +88620,58 @@ "output": 16384 } }, + { + "id": "nano-gpt/poolside/laguna-m.1", + "name": "Laguna M.1", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2026-04-29", + "last_updated": "2026-04-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.1, + "output": 0.3 + }, + "limit": { + "context": 128000, + "output": 32768 + } + }, + { + "id": "nano-gpt/poolside/laguna-xs.2", + "name": "Laguna XS.2", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2026-04-29", + "last_updated": "2026-04-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.1, + "output": 0.3 + }, + "limit": { + "context": 128000, + "output": 32768 + } + }, { "id": "nano-gpt/qvq-max", "name": "Qwen: QvQ Max", @@ -80144,31 +88728,6 @@ "output": 65536 } }, - { - "id": "nano-gpt/qwen-image", - "name": "Qwen Image", - "attachment": true, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-08-07", - "last_updated": "2025-08-07", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "image" - ] - }, - "open_weights": false, - "cost": {}, - "limit": { - "context": 0, - "output": 0 - } - }, { "id": "nano-gpt/qwen-long", "name": "Qwen Long 10M", @@ -80275,14 +88834,196 @@ } }, { - "id": "nano-gpt/qwen/Qwen3.6-35B-A3B", - "name": "Qwen3.6 35B A3B", - "family": "qwen3.6", + "id": "nano-gpt/qwen/Qwen2.5-Coder-32B-Instruct", + "name": "Qwen 2.5 Coder 32b", "attachment": false, "reasoning": false, "tool_call": false, + "release_date": "2025-07-03", + "last_updated": "2025-07-03", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.2006, + "output": 0.2006 + }, + "limit": { + "context": 32000, + "output": 8192 + } + }, + { + "id": "nano-gpt/qwen/Qwen3-235B-A22B-Instruct", + "name": "Qwen 3 235b A22B 2507", + "attachment": false, + "reasoning": false, + "tool_call": true, + "release_date": "2025-07-25", + "last_updated": "2025-07-25", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.13, + "output": 0.5 + }, + "limit": { + "context": 256000, + "output": 262144 + } + }, + { + "id": "nano-gpt/qwen/Qwen3-235B-A22B-Instruct-2507-TEE", + "name": "Qwen 3 235b A22B 2507 (TEE)", + "attachment": false, + "reasoning": false, + "tool_call": true, + "release_date": "2025-07-25", + "last_updated": "2025-07-25", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.13, + "output": 0.5 + }, + "limit": { + "context": 256000, + "output": 262144 + } + }, + { + "id": "nano-gpt/qwen/Qwen3-235B-A22B-Thinking", + "name": "Qwen 3 235b A22B 2507 Thinking", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2025-09-11", + "last_updated": "2025-09-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 0.5 + }, + "limit": { + "context": 256000, + "output": 262144 + } + }, + { + "id": "nano-gpt/qwen/Qwen3-8B", + "name": "Qwen 3 8B", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2024-01-01", + "last_updated": "2024-01-01", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.47, + "output": 0.47 + }, + "limit": { + "context": 41000, + "output": 32768 + } + }, + { + "id": "nano-gpt/qwen/Qwen3-Next-80B-A3B-Instruct", + "name": "Qwen3 Next 80B A3B (Instruct)", + "attachment": false, + "reasoning": false, + "tool_call": true, + "release_date": "2025-09-11", + "last_updated": "2025-09-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.15, + "output": 0.65 + }, + "limit": { + "context": 256000, + "output": 262144 + } + }, + { + "id": "nano-gpt/qwen/Qwen3-VL-235B-A22B-Instruct", + "name": "Qwen3 VL 235B A22B Instruct", + "attachment": true, + "reasoning": false, + "tool_call": false, + "release_date": "2024-01-01", + "last_updated": "2024-01-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 1.2 + }, + "limit": { + "context": 128000, + "output": 262144 + } + }, + { + "id": "nano-gpt/qwen/Qwen3.6-35B-A3B", + "name": "Qwen3.6 35B A3B", + "attachment": true, + "reasoning": false, + "tool_call": false, "release_date": "2026-04-17", - "last_updated": "2026-04-21", + "last_updated": "2026-04-17", "modalities": { "input": [ "text", @@ -80293,10 +89034,10 @@ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { - "input": 0.29, - "output": 1.74 + "input": 0.112, + "output": 0.8 }, "limit": { "context": 262144, @@ -80306,12 +89047,11 @@ { "id": "nano-gpt/qwen/Qwen3.6-35B-A3B:thinking", "name": "Qwen3.6 35B A3B Thinking", - "family": "qwen3.6", - "attachment": false, + "attachment": true, "reasoning": true, "tool_call": false, "release_date": "2026-04-19", - "last_updated": "2026-04-21", + "last_updated": "2026-04-19", "modalities": { "input": [ "text", @@ -80322,16 +89062,304 @@ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { - "input": 0.29, - "output": 1.74 + "input": 0.112, + "output": 0.8 }, "limit": { "context": 262144, "output": 16384 } }, + { + "id": "nano-gpt/qwen/qwen-2.5-72b-instruct", + "name": "Qwen2.5 72B", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2025-07-03", + "last_updated": "2025-07-03", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.357, + "output": 0.408 + }, + "limit": { + "context": 131072, + "output": 8192 + } + }, + { + "id": "nano-gpt/qwen/qwen3-14b", + "name": "Qwen 3 14b", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2024-01-01", + "last_updated": "2024-01-01", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.08, + "output": 0.24 + }, + "limit": { + "context": 41000, + "output": 32768 + } + }, + { + "id": "nano-gpt/qwen/qwen3-235b-a22b", + "name": "Qwen 3 235b A22B", + "attachment": true, + "reasoning": false, + "tool_call": true, + "release_date": "2025-04-29", + "last_updated": "2025-04-29", + "modalities": { + "input": [ + "text", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 0.5 + }, + "limit": { + "context": 41000, + "output": 32768 + } + }, + { + "id": "nano-gpt/qwen/qwen3-30b-a3b", + "name": "Qwen3 30B A3B", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2025-02-27", + "last_updated": "2025-02-27", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.1, + "output": 0.3 + }, + "limit": { + "context": 41000, + "output": 32768 + } + }, + { + "id": "nano-gpt/qwen/qwen3-32b", + "name": "Qwen 3 32b", + "attachment": true, + "reasoning": false, + "tool_call": false, + "release_date": "2024-01-01", + "last_updated": "2024-01-01", + "modalities": { + "input": [ + "text", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.1, + "output": 0.3 + }, + "limit": { + "context": 41000, + "output": 32768 + } + }, + { + "id": "nano-gpt/qwen/qwen3-coder", + "name": "Qwen 3 Coder 480B", + "attachment": false, + "reasoning": false, + "tool_call": true, + "release_date": "2026-03-17", + "last_updated": "2026-03-17", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.13, + "output": 0.5 + }, + "limit": { + "context": 262000, + "output": 65536 + } + }, + { + "id": "nano-gpt/qwen/qwen3-coder-flash", + "name": "Qwen3 Coder Flash", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2025-09-17", + "last_updated": "2025-09-17", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 1.5 + }, + "limit": { + "context": 128000, + "output": 65536 + } + }, + { + "id": "nano-gpt/qwen/qwen3-coder-next", + "name": "Qwen3 Coder Next", + "attachment": false, + "reasoning": false, + "tool_call": true, + "release_date": "2025-12-08", + "last_updated": "2025-12-08", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.15, + "output": 1.5 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, + { + "id": "nano-gpt/qwen/qwen3-coder-plus", + "name": "Qwen3 Coder Plus", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2025-09-17", + "last_updated": "2025-09-17", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 5.0 + }, + "limit": { + "context": 128000, + "output": 65536 + } + }, + { + "id": "nano-gpt/qwen/qwen3-max", + "name": "Qwen3 Max", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2025-09-05", + "last_updated": "2025-09-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.08018, + "output": 5.4009 + }, + "limit": { + "context": 256000, + "output": 32768 + } + }, + { + "id": "nano-gpt/qwen/qwen3-next-80b-a3b-thinking", + "name": "Qwen3 Next 80B A3B (Thinking)", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2024-01-01", + "last_updated": "2024-01-01", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.15, + "output": 0.65 + }, + "limit": { + "context": 256000, + "output": 32768 + } + }, { "id": "nano-gpt/qwen/qwen3.5-397b-a17b", "name": "Qwen3.5 397B A17B", @@ -80361,6 +89389,145 @@ "output": 65536 } }, + { + "id": "nano-gpt/qwen/qwen3.5-397b-a17b-thinking", + "name": "Qwen3.5 397B A17B Thinking", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-02-16", + "last_updated": "2026-02-16", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.6, + "output": 3.6 + }, + "limit": { + "context": 258048, + "output": 65536 + } + }, + { + "id": "nano-gpt/qwen/qwen3.5-9b", + "name": "Qwen3.5 9B", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-03-10", + "last_updated": "2026-03-10", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.05, + "output": 0.15 + }, + "limit": { + "context": 256000, + "output": 65536 + } + }, + { + "id": "nano-gpt/qwen/qwen3.5-plus", + "name": "Qwen3.5 Plus", + "attachment": true, + "reasoning": false, + "tool_call": false, + "release_date": "2026-02-16", + "last_updated": "2026-02-16", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.4, + "output": 2.4, + "cache_read": 0.04 + }, + "limit": { + "context": 983616, + "output": 65536 + } + }, + { + "id": "nano-gpt/qwen/qwen3.5-plus-thinking", + "name": "Qwen3.5 Plus Thinking", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-02-16", + "last_updated": "2026-02-16", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.4, + "output": 2.4, + "cache_read": 0.04 + }, + "limit": { + "context": 983616, + "output": 65536 + } + }, + { + "id": "nano-gpt/qwen/qwq-32b-preview", + "name": "Qwen QwQ 32B Preview", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2025-02-27", + "last_updated": "2025-02-27", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.2, + "output": 0.2 + }, + "limit": { + "context": 32768, + "output": 32768 + } + }, { "id": "nano-gpt/qwen25-vl-72b-instruct", "name": "Qwen25 VL 72b", @@ -80520,6 +89687,288 @@ "output": 32768 } }, + { + "id": "nano-gpt/qwen3.5-122b-a10b", + "name": "Qwen3.5 122B A10B", + "attachment": true, + "reasoning": false, + "tool_call": false, + "release_date": "2026-02-24", + "last_updated": "2026-02-24", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.36, + "output": 2.88 + }, + "limit": { + "context": 260096, + "output": 65536 + } + }, + { + "id": "nano-gpt/qwen3.5-122b-a10b:thinking", + "name": "Qwen3.5 122B A10B Thinking", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-02-24", + "last_updated": "2026-02-24", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.36, + "output": 2.88 + }, + "limit": { + "context": 260096, + "output": 65536 + } + }, + { + "id": "nano-gpt/qwen3.5-27b", + "name": "Qwen3.5 27B", + "attachment": true, + "reasoning": false, + "tool_call": false, + "release_date": "2026-02-24", + "last_updated": "2026-02-24", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.27, + "output": 2.16 + }, + "limit": { + "context": 260096, + "output": 65536 + } + }, + { + "id": "nano-gpt/qwen3.5-27b:thinking", + "name": "Qwen3.5 27B Thinking", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-02-24", + "last_updated": "2026-02-24", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.27, + "output": 2.16 + }, + "limit": { + "context": 260096, + "output": 65536 + } + }, + { + "id": "nano-gpt/qwen3.5-35b-a3b", + "name": "Qwen3.5 35B A3B", + "attachment": true, + "reasoning": false, + "tool_call": false, + "release_date": "2026-02-24", + "last_updated": "2026-02-24", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.225, + "output": 1.8 + }, + "limit": { + "context": 260096, + "output": 65536 + } + }, + { + "id": "nano-gpt/qwen3.5-35b-a3b:thinking", + "name": "Qwen3.5 35B A3B Thinking", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-02-24", + "last_updated": "2026-02-24", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.225, + "output": 1.8 + }, + "limit": { + "context": 260096, + "output": 65536 + } + }, + { + "id": "nano-gpt/qwen3.5-flash", + "name": "Qwen3.5 Flash", + "attachment": true, + "reasoning": false, + "tool_call": false, + "release_date": "2026-02-24", + "last_updated": "2026-02-24", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.09, + "output": 0.36 + }, + "limit": { + "context": 991808, + "output": 65536 + } + }, + { + "id": "nano-gpt/qwen3.5-flash:thinking", + "name": "Qwen3.5 Flash Thinking", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-02-24", + "last_updated": "2026-02-24", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.09, + "output": 0.36 + }, + "limit": { + "context": 991808, + "output": 65536 + } + }, + { + "id": "nano-gpt/qwen3.5-omni-flash", + "name": "Qwen3.5 Omni Flash", + "attachment": true, + "reasoning": false, + "tool_call": false, + "release_date": "2026-03-30", + "last_updated": "2026-03-30", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 49152, + "output": 16384 + } + }, + { + "id": "nano-gpt/qwen3.5-omni-plus", + "name": "Qwen3.5 Omni Plus", + "attachment": true, + "reasoning": false, + "tool_call": false, + "release_date": "2026-03-30", + "last_updated": "2026-03-30", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 983616, + "output": 65536 + } + }, { "id": "nano-gpt/qwen3.6-max-preview", "name": "Qwen3.6 Max Preview", @@ -80547,6 +89996,118 @@ "output": 65536 } }, + { + "id": "nano-gpt/qwen3.7-max", + "name": "Qwen3.7 Max", + "attachment": false, + "reasoning": false, + "tool_call": false, + "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 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "nano-gpt/qwen3.7-max:thinking", + "name": "Qwen3.7 Max Thinking", + "attachment": false, + "reasoning": true, + "tool_call": false, + "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 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "nano-gpt/qwen3.7-plus", + "name": "Qwen3.7 Plus", + "attachment": true, + "reasoning": false, + "tool_call": false, + "release_date": "2026-06-01", + "last_updated": "2026-06-01", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.4, + "output": 1.6, + "cache_read": 0.04 + }, + "limit": { + "context": 991808, + "output": 65536 + } + }, + { + "id": "nano-gpt/qwen3.7-plus:thinking", + "name": "Qwen3.7 Plus Thinking", + "attachment": true, + "reasoning": true, + "tool_call": false, + "release_date": "2026-06-01", + "last_updated": "2026-06-01", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.4, + "output": 1.6, + "cache_read": 0.04 + }, + "limit": { + "context": 983616, + "output": 65536 + } + }, { "id": "nano-gpt/qwq-32b", "name": "Qwen: QwQ 32B", @@ -80574,18 +90135,16 @@ } }, { - "id": "nano-gpt/raifle/sorcererlm-8x22b", - "name": "SorcererLM 8x22B", - "family": "mixtral", - "attachment": true, - "reasoning": false, - "tool_call": false, - "release_date": "2025-01-01", - "last_updated": "2025-01-01", + "id": "nano-gpt/sarvam-105b", + "name": "Sarvam 105B", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-12", + "last_updated": "2026-05-12", "modalities": { "input": [ - "text", - "pdf" + "text" ], "output": [ "text" @@ -80593,22 +90152,23 @@ }, "open_weights": false, "cost": { - "input": 4.505, - "output": 4.505 + "input": 0.045, + "output": 0.177, + "cache_read": 0.028 }, "limit": { - "context": 16000, - "output": 8192 + "context": 131072, + "output": 4096 } }, { - "id": "nano-gpt/sarvan-medium", - "name": "Sarvam Medium", + "id": "nano-gpt/sarvam-30b", + "name": "Sarvam 30B", "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-01-01", - "last_updated": "2025-01-01", + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-12", + "last_updated": "2026-05-12", "modalities": { "input": [ "text" @@ -80619,12 +90179,13 @@ }, "open_weights": false, "cost": { - "input": 0.25, - "output": 0.75 + "input": 0.028, + "output": 0.111, + "cache_read": 0.017 }, "limit": { - "context": 128000, - "output": 16384 + "context": 65536, + "output": 4096 } }, { @@ -80999,17 +90560,18 @@ } }, { - "id": "nano-gpt/stepfun-ai/step-3.5-flash:thinking", - "name": "Step 3.5 Flash Thinking", - "family": "step", - "attachment": false, + "id": "nano-gpt/stepfun/step-3.7-flash:thinking", + "name": "Step 3.7 Flash Thinking", + "attachment": true, "reasoning": true, - "tool_call": false, - "release_date": "2026-02-02", - "last_updated": "2026-02-02", + "tool_call": true, + "release_date": "2026-05-29", + "last_updated": "2026-05-29", "modalities": { "input": [ - "text" + "text", + "image", + "video" ], "output": [ "text" @@ -81018,40 +90580,14 @@ "open_weights": false, "cost": { "input": 0.2, - "output": 0.5 + "output": 1.15, + "cache_read": 0.04 }, "limit": { "context": 256000, "output": 256000 } }, - { - "id": "nano-gpt/study_gpt-chatgpt-4o", - "name": "Study Mode", - "attachment": true, - "reasoning": false, - "tool_call": false, - "release_date": "2024-05-13", - "last_updated": "2024-05-13", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 4.998, - "output": 14.994 - }, - "limit": { - "context": 200000, - "output": 16384 - } - }, { "id": "nano-gpt/tencent/Hunyuan-MT-7B", "name": "Hunyuan MT 7B", @@ -81080,14 +90616,13 @@ } }, { - "id": "nano-gpt/tngtech/DeepSeek-TNG-R1T2-Chimera", - "name": "DeepSeek TNG R1T2 Chimera", - "family": "tngtech", + "id": "nano-gpt/tencent/hy3-preview", + "name": "Tencent: Hy3 preview", "attachment": false, "reasoning": false, "tool_call": false, - "release_date": "2025-09-05", - "last_updated": "2025-09-05", + "release_date": "2026-04-23", + "last_updated": "2026-04-23", "modalities": { "input": [ "text" @@ -81098,39 +90633,13 @@ }, "open_weights": false, "cost": { - "input": 0.31, - "output": 0.31 + "input": 0.066, + "output": 0.26, + "cache_read": 0.029 }, "limit": { - "context": 128000, - "output": 8192 - } - }, - { - "id": "nano-gpt/tngtech/tng-r1t-chimera", - "name": "TNG R1T Chimera", - "family": "tngtech", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-11-26", - "last_updated": "2025-11-26", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.3, - "output": 1.2 - }, - "limit": { - "context": 128000, - "output": 65536 + "context": 262144, + "output": 262144 } }, { @@ -81215,33 +90724,6 @@ "output": 131072 } }, - { - "id": "nano-gpt/unsloth/gemma-3-1b-it", - "name": "Gemma 3 1B IT", - "family": "unsloth", - "attachment": false, - "reasoning": false, - "tool_call": false, - "release_date": "2025-03-10", - "last_updated": "2025-03-10", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.1003, - "output": 0.1003 - }, - "limit": { - "context": 128000, - "output": 8192 - } - }, { "id": "nano-gpt/unsloth/gemma-3-27b-it", "name": "Gemma 3 27B IT", @@ -81298,6 +90780,33 @@ "output": 8192 } }, + { + "id": "nano-gpt/upstage/solar-pro-3", + "name": "Solar Pro 3", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2026-03-03", + "last_updated": "2026-03-03", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.15, + "output": 0.6, + "cache_read": 0.015 + }, + "limit": { + "context": 128000, + "output": 128000 + } + }, { "id": "nano-gpt/v0-1.0-md", "name": "v0 1.0 MD", @@ -81429,14 +90938,13 @@ } }, { - "id": "nano-gpt/x-ai/grok-4-07-09", - "name": "Grok 4", - "family": "grok", + "id": "nano-gpt/x-ai/grok", + "name": "Grok Latest", "attachment": true, "reasoning": true, - "tool_call": false, - "release_date": "2025-07-09", - "last_updated": "2025-07-09", + "tool_call": true, + "release_date": "2026-05-03", + "last_updated": "2026-05-03", "modalities": { "input": [ "text", @@ -81448,123 +90956,123 @@ }, "open_weights": false, "cost": { - "input": 3.0, - "output": 15.0 + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "limit": { + "context": 1000000, + "output": 1000000 + } + }, + { + "id": "nano-gpt/x-ai/grok-4.20", + "name": "Grok 4.20", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-03-31", + "last_updated": "2026-03-31", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 6.0 + }, + "limit": { + "context": 2000000, + "output": 131072 + } + }, + { + "id": "nano-gpt/x-ai/grok-4.20-multi-agent", + "name": "Grok 4.20 Multi-Agent", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-03-31", + "last_updated": "2026-03-31", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 6.0 + }, + "limit": { + "context": 2000000, + "output": 131072 + } + }, + { + "id": "nano-gpt/x-ai/grok-4.3", + "name": "Grok 4.3", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-30", + "last_updated": "2026-04-30", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "limit": { + "context": 1000000, + "output": 1000000 + } + }, + { + "id": "nano-gpt/x-ai/grok-build-0.1", + "name": "Grok Build 0.1", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-20", + "last_updated": "2026-05-20", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 2.0, + "cache_read": 0.2 }, "limit": { "context": 256000, - "output": 131072 - } - }, - { - "id": "nano-gpt/x-ai/grok-4-fast", - "name": "Grok 4 Fast", - "family": "grok", - "attachment": true, - "reasoning": true, - "tool_call": true, - "release_date": "2025-09-20", - "last_updated": "2025-09-20", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.2, - "output": 0.5 - }, - "limit": { - "context": 2000000, - "output": 131072 - } - }, - { - "id": "nano-gpt/x-ai/grok-4-fast:thinking", - "name": "Grok 4 Fast Thinking", - "family": "grok", - "attachment": true, - "reasoning": true, - "tool_call": true, - "release_date": "2025-07-09", - "last_updated": "2025-07-09", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.2, - "output": 0.5 - }, - "limit": { - "context": 2000000, - "output": 131072 - } - }, - { - "id": "nano-gpt/x-ai/grok-4.1-fast", - "name": "Grok 4.1 Fast", - "family": "grok", - "attachment": true, - "reasoning": true, - "tool_call": true, - "release_date": "2025-11-20", - "last_updated": "2025-11-20", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.2, - "output": 0.5 - }, - "limit": { - "context": 2000000, - "output": 131072 - } - }, - { - "id": "nano-gpt/x-ai/grok-code-fast-1", - "name": "Grok Code Fast 1", - "family": "grok", - "attachment": false, - "reasoning": true, - "tool_call": false, - "release_date": "2025-08-28", - "last_updated": "2025-08-28", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.2, - "output": 1.5 - }, - "limit": { - "context": 256000, - "output": 131072 + "output": 256000 } }, { @@ -81572,7 +91080,7 @@ "name": "MiMo V2 Flash", "family": "mimo", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": false, "release_date": "2025-12-17", "last_updated": "2025-12-17", @@ -81599,7 +91107,7 @@ "name": "MiMo V2 Flash Original", "family": "mimo", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": false, "release_date": "2025-12-17", "last_updated": "2025-12-17", @@ -81626,7 +91134,7 @@ "name": "MiMo V2 Flash (Thinking)", "family": "mimo", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": false, "release_date": "2025-12-17", "last_updated": "2025-12-17", @@ -81653,7 +91161,7 @@ "name": "MiMo V2 Flash (Thinking) Original", "family": "mimo", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": false, "release_date": "2025-12-17", "last_updated": "2025-12-17", @@ -81675,6 +91183,119 @@ "output": 32768 } }, + { + "id": "nano-gpt/xiaomi/mimo-v2-omni", + "name": "MiMo V2 Omni", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-03-19", + "last_updated": "2026-03-19", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.4, + "output": 2.0, + "cache_read": 0.08 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, + { + "id": "nano-gpt/xiaomi/mimo-v2-pro", + "name": "MiMo V2 Pro", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-03-19", + "last_updated": "2026-03-19", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 3.0, + "cache_read": 0.2 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, + { + "id": "nano-gpt/xiaomi/mimo-v2.5", + "name": "MiMo V2.5", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.14, + "output": 0.28, + "cache_read": 0.0028 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, + { + "id": "nano-gpt/xiaomi/mimo-v2.5-pro", + "name": "MiMo V2.5 Pro", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.435, + "output": 0.87, + "cache_read": 0.0036 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, { "id": "nano-gpt/yi-large", "name": "Yi Large", @@ -81864,27 +91485,377 @@ } }, { - "id": "nano-gpt/z-image-turbo", - "name": "Z Image Turbo", - "attachment": true, + "id": "nano-gpt/z-ai/glm-5-turbo", + "name": "GLM 5 Turbo", + "attachment": false, "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-11-27", - "last_updated": "2025-11-27", + "tool_call": true, + "release_date": "2026-03-15", + "last_updated": "2026-03-15", "modalities": { "input": [ "text" ], "output": [ - "image" + "text" ] }, "open_weights": false, - "cost": {}, + "cost": { + "input": 1.2, + "output": 4.0, + "cache_read": 0.24 + }, "limit": { - "context": 0, - "output": 0 + "context": 202800, + "output": 131072 + } + }, + { + "id": "nano-gpt/z-ai/glm-5v-turbo", + "name": "GLM 5V Turbo", + "attachment": true, + "reasoning": false, + "tool_call": true, + "release_date": "2026-04-01", + "last_updated": "2026-04-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.2, + "output": 4.0, + "cache_read": 0.24 + }, + "limit": { + "context": 202800, + "output": 131100 + } + }, + { + "id": "nano-gpt/z-ai/glm-5v-turbo:thinking", + "name": "GLM 5V Turbo Thinking", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.2, + "output": 4.0, + "cache_read": 0.24 + }, + "limit": { + "context": 202800, + "output": 131100 + } + }, + { + "id": "nano-gpt/zai-org/GLM-4.5-Air", + "name": "GLM 4.5 Air", + "attachment": false, + "reasoning": false, + "tool_call": true, + "release_date": "2025-04-15", + "last_updated": "2025-04-15", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.12, + "output": 0.8 + }, + "limit": { + "context": 128000, + "output": 98304 + } + }, + { + "id": "nano-gpt/zai-org/GLM-4.5-Air:thinking", + "name": "GLM 4.5 Air (Thinking)", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2024-01-01", + "last_updated": "2024-01-01", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.12, + "output": 0.8 + }, + "limit": { + "context": 128000, + "output": 98304 + } + }, + { + "id": "nano-gpt/zai-org/GLM-4.5:thinking", + "name": "GLM 4.5 (Thinking)", + "attachment": false, + "reasoning": true, + "tool_call": false, + "release_date": "2024-01-01", + "last_updated": "2024-01-01", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 1.3 + }, + "limit": { + "context": 128000, + "output": 65536 + } + }, + { + "id": "nano-gpt/zai-org/GLM-4.6-turbo", + "name": "GLM 4.6 Turbo", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2025-10-02", + "last_updated": "2025-10-02", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 3.0 + }, + "limit": { + "context": 200000, + "output": 204800 + } + }, + { + "id": "nano-gpt/zai-org/GLM-4.6-turbo:thinking", + "name": "GLM 4.6 Turbo (Thinking)", + "attachment": false, + "reasoning": true, + "tool_call": false, + "release_date": "2025-10-02", + "last_updated": "2025-10-02", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 3.0 + }, + "limit": { + "context": 200000, + "output": 204800 + } + }, + { + "id": "nano-gpt/zai-org/glm", + "name": "GLM Latest", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-05-03", + "last_updated": "2026-05-03", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.75, + "output": 2.6, + "cache_read": 0.15 + }, + "limit": { + "context": 200000, + "output": 131072 + } + }, + { + "id": "nano-gpt/zai-org/glm-4.5", + "name": "GLM 4.5", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2025-04-15", + "last_updated": "2025-04-15", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 1.3 + }, + "limit": { + "context": 128000, + "output": 65536 + } + }, + { + "id": "nano-gpt/zai-org/glm-4.6-original", + "name": "GLM 4.6 Original", + "attachment": false, + "reasoning": true, + "tool_call": false, + "release_date": "2025-12-11", + "last_updated": "2025-12-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.35, + "output": 1.4 + }, + "limit": { + "context": 256000, + "output": 65535 + } + }, + { + "id": "nano-gpt/zai-org/glm-4.6v", + "name": "GLM 4.6V", + "attachment": true, + "reasoning": false, + "tool_call": false, + "release_date": "2025-12-11", + "last_updated": "2025-12-11", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 0.9 + }, + "limit": { + "context": 128000, + "output": 24000 + } + }, + { + "id": "nano-gpt/zai-org/glm-4.6v-flash-original", + "name": "GLM 4.6V Flash", + "attachment": true, + "reasoning": false, + "tool_call": false, + "release_date": "2025-12-08", + "last_updated": "2025-12-08", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.1, + "output": 0.4 + }, + "limit": { + "context": 128000, + "output": 24000 + } + }, + { + "id": "nano-gpt/zai-org/glm-4.6v-original", + "name": "GLM 4.6V Original", + "attachment": true, + "reasoning": false, + "tool_call": false, + "release_date": "2025-12-08", + "last_updated": "2025-12-08", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.6, + "output": 0.9 + }, + "limit": { + "context": 128000, + "output": 24000 } }, { @@ -81941,6 +91912,164 @@ "output": 128000 } }, + { + "id": "nano-gpt/zai-org/glm-4.7-flash-original", + "name": "GLM 4.7 Flash Original", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-01-19", + "last_updated": "2026-01-19", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.07, + "output": 0.4 + }, + "limit": { + "context": 200000, + "output": 128000 + } + }, + { + "id": "nano-gpt/zai-org/glm-4.7-flash-original:thinking", + "name": "GLM 4.7 Flash Original Thinking", + "attachment": false, + "reasoning": true, + "tool_call": false, + "release_date": "2026-01-19", + "last_updated": "2026-01-19", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.07, + "output": 0.4 + }, + "limit": { + "context": 200000, + "output": 128000 + } + }, + { + "id": "nano-gpt/zai-org/glm-4.7-flash:thinking", + "name": "GLM 4.7 Flash Thinking", + "attachment": false, + "reasoning": true, + "tool_call": false, + "release_date": "2026-01-19", + "last_updated": "2026-01-19", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.07, + "output": 0.4 + }, + "limit": { + "context": 200000, + "output": 128000 + } + }, + { + "id": "nano-gpt/zai-org/glm-4.7-original", + "name": "GLM 4.7 Original", + "attachment": false, + "reasoning": true, + "tool_call": 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, + "cache_read": 0.11 + }, + "limit": { + "context": 200000, + "output": 65535 + } + }, + { + "id": "nano-gpt/zai-org/glm-4.7-original:thinking", + "name": "GLM 4.7 Original Thinking", + "attachment": false, + "reasoning": true, + "tool_call": 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, + "cache_read": 0.11 + }, + "limit": { + "context": 200000, + "output": 65535 + } + }, + { + "id": "nano-gpt/zai-org/glm-4.7:thinking", + "name": "GLM 4.7 Thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2025-12-22", + "last_updated": "2025-12-22", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.2, + "output": 0.8 + }, + "limit": { + "context": 200000, + "output": 65535 + } + }, { "id": "nano-gpt/zai-org/glm-5", "name": "GLM 5", @@ -81968,6 +92097,60 @@ "output": 128000 } }, + { + "id": "nano-gpt/zai-org/glm-5-original", + "name": "GLM 5 Original", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-02-11", + "last_updated": "2026-02-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 3.2, + "cache_read": 0.2 + }, + "limit": { + "context": 200000, + "output": 128000 + } + }, + { + "id": "nano-gpt/zai-org/glm-5-original:thinking", + "name": "GLM 5 Original Thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-02-11", + "last_updated": "2026-02-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 3.2, + "cache_read": 0.2 + }, + "limit": { + "context": 200000, + "output": 128000 + } + }, { "id": "nano-gpt/zai-org/glm-5.1", "name": "GLM 5.1", @@ -82422,8 +92605,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", @@ -82490,8 +92673,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", @@ -83385,7 +93568,7 @@ "name": "Qwen3 235B A22B Instruct 2507", "family": "qwen", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2025-07", @@ -83738,36 +93921,6 @@ "output": 384000 } }, - { - "id": "nebius/google/gemma-2-2b-it", - "name": "Gemma-2-2b-it", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "knowledge": "2024-06", - "release_date": "2024-07-31", - "last_updated": "2026-02-04", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.02, - "output": 0.06, - "cache_read": 0.002, - "cache_write": 0.025 - }, - "limit": { - "context": 8192, - "output": 4096 - } - }, { "id": "nebius/google/gemma-3-27b-it", "name": "Gemma-3-27b-it", @@ -83829,40 +93982,10 @@ "output": 8192 } }, - { - "id": "nebius/meta-llama/Meta-Llama-3.1-8B-Instruct", - "name": "Meta-Llama-3.1-8B-Instruct", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-12", - "release_date": "2024-07-23", - "last_updated": "2026-02-04", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.02, - "output": 0.06, - "cache_read": 0.002, - "cache_write": 0.025 - }, - "limit": { - "context": 128000, - "output": 4096 - } - }, { "id": "nebius/moonshotai/Kimi-K2.5", "name": "Kimi-K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -83894,7 +94017,7 @@ { "id": "nebius/moonshotai/Kimi-K2.5-fast", "name": "Kimi-K2.5-fast", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -83926,6 +94049,7 @@ { "id": "nebius/nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", "name": "Llama-3.1-Nemotron-Ultra-253B-v1", + "family": "nemotron", "attachment": false, "reasoning": false, "tool_call": true, @@ -83956,6 +94080,7 @@ { "id": "nebius/nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B", "name": "Nemotron-3-Nano-30B-A3B", + "family": "nemotron", "attachment": false, "reasoning": false, "tool_call": true, @@ -83986,6 +94111,7 @@ { "id": "nebius/nvidia/Nemotron-3-Nano-Omni", "name": "Nemotron-3-Nano-Omni", + "family": "nemotron", "attachment": false, "reasoning": true, "tool_call": true, @@ -84016,6 +94142,7 @@ { "id": "nebius/nvidia/nemotron-3-super-120b-a12b", "name": "Nemotron-3-Super-120B-A12B", + "family": "nemotron", "attachment": false, "reasoning": true, "tool_call": true, @@ -84132,15 +94259,15 @@ } }, { - "id": "neuralwatt/MiniMaxAI/MiniMax-M2.5", - "name": "MiniMax M2.5", - "family": "minimax", + "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-02-12", - "last_updated": "2026-02-12", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "modalities": { "input": [ "text" @@ -84151,12 +94278,1165 @@ }, "open_weights": true, "cost": { - "input": 0.35, - "output": 1.38 + "input": 1.4, + "output": 4.4 }, "limit": { - "context": 196592, - "output": 196592 + "context": 432000, + "output": 432000 + } + }, + { + "id": "neon/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", + "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": "neon/claude-opus-4.1", + "name": "Claude Opus 4.1 (latest)", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-03-31", + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 15.0, + "output": 75.0, + "cache_read": 1.5, + "cache_write": 18.75 + }, + "limit": { + "context": 200000, + "output": 32000 + } + }, + { + "id": "neon/claude-opus-4.5", + "name": "Claude Opus 4.5 (latest)", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2025-11-24", + "last_updated": "2025-11-24", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 200000, + "output": 64000 + } + }, + { + "id": "neon/claude-opus-4.6", + "name": "Claude Opus 4.6", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05-31", + "release_date": "2026-02-05", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "neon/claude-opus-4.7", + "name": "Claude Opus 4.7", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "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", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-07-31", + "release_date": "2025-09-29", + "last_updated": "2025-09-29", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 15.0, + "cache_read": 0.3, + "cache_write": 3.75 + }, + "limit": { + "context": 200000, + "output": 64000 + } + }, + { + "id": "neon/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5 (latest)", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-07-31", + "release_date": "2025-09-29", + "last_updated": "2025-09-29", + "modalities": { + "input": [ + "text", + "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": "neon/claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-08-31", + "release_date": "2026-02-17", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 15.0, + "cache_read": 0.3, + "cache_write": 3.75 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "neon/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", + "audio", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 2.5, + "cache_read": 0.03 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "neon/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", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "neon/gemini-3-flash", + "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", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.5, + "output": 3.0, + "cache_read": 0.05 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "neon/gemini-3-pro", + "name": "Gemini 3 Pro Preview", + "family": "gemini-pro", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2025-11-18", + "last_updated": "2025-11-18", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 12.0, + "cache_read": 0.2 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "neon/gemini-3.1-flash-lite", + "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 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "neon/gemini-3.1-pro", + "name": "Gemini 3.1 Pro Preview Custom Tools", + "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", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 12.0, + "cache_read": 0.2 + }, + "limit": { + "context": 1048576, + "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", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2024-09-30", + "release_date": "2025-08-07", + "last_updated": "2025-08-07", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text", + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "neon/gpt-5-mini", + "name": "GPT-5 Mini", + "family": "gpt-mini", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2024-05-30", + "release_date": "2025-08-07", + "last_updated": "2025-08-07", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text", + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.25, + "output": 2.0, + "cache_read": 0.025 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "neon/gpt-5-nano", + "name": "GPT-5 Nano", + "family": "gpt-nano", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2024-05-30", + "release_date": "2025-08-07", + "last_updated": "2025-08-07", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text", + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.05, + "output": 0.4, + "cache_read": 0.005 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "neon/gpt-5.1", + "name": "GPT-5.1", + "family": "gpt", + "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-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", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2025-12-11", + "last_updated": "2025-12-11", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text", + "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, + "cost": { + "input": 1.75, + "output": 14.0, + "cache_read": 0.175 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "neon/gpt-5.4", + "name": "GPT-5.4", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-05", + "last_updated": "2026-03-05", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text", + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 2.5, + "output": 15.0, + "cache_read": 0.25 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "neon/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", + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.75, + "output": 4.5, + "cache_read": 0.075 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "neon/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", + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.2, + "output": 1.25, + "cache_read": 0.02 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "neon/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.072, + "output": 0.28 + }, + "limit": { + "context": 131072, + "output": 32768 + } + }, + { + "id": "neon/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.05, + "output": 0.2 + }, + "limit": { + "context": 131072, + "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 } }, { @@ -84180,7 +95460,8 @@ "open_weights": true, "cost": { "input": 0.69, - "output": 4.14 + "output": 4.14, + "cache_read": 0.1725 }, "limit": { "context": 262128, @@ -84209,7 +95490,8 @@ "open_weights": true, "cost": { "input": 0.29, - "output": 1.15 + "output": 1.15, + "cache_read": 0.0725 }, "limit": { "context": 131056, @@ -84217,15 +95499,15 @@ } }, { - "id": "neuralwatt/glm-5-fast", - "name": "GLM 5 Fast", + "id": "neuralwatt/glm-5.2", + "name": "GLM 5.2", "family": "glm", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-04-07", - "last_updated": "2026-04-07", + "release_date": "2026-06-17", + "last_updated": "2026-06-17", "modalities": { "input": [ "text" @@ -84236,24 +95518,25 @@ }, "open_weights": true, "cost": { - "input": 1.1, - "output": 3.6 + "input": 1.45, + "output": 4.5, + "cache_read": 0.3625 }, "limit": { - "context": 202736, - "output": 202736 + "context": 1048560, + "output": 1048560 } }, { - "id": "neuralwatt/glm-5.1-fast", - "name": "GLM 5.1 Fast", + "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-04-07", - "last_updated": "2026-04-07", + "release_date": "2026-06-17", + "last_updated": "2026-06-17", "modalities": { "input": [ "text" @@ -84264,18 +95547,164 @@ }, "open_weights": true, "cost": { - "input": 1.1, - "output": 3.6 + "input": 1.45, + "output": 4.5, + "cache_read": 0.3625 }, "limit": { - "context": 202736, - "output": 202736 + "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", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": false, "tool_call": true, @@ -84294,7 +95723,8 @@ "open_weights": true, "cost": { "input": 0.52, - "output": 2.59 + "output": 2.59, + "cache_read": 0.13 }, "limit": { "context": 262128, @@ -84304,7 +95734,7 @@ { "id": "neuralwatt/kimi-k2.6-fast", "name": "Kimi K2.6 Fast", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": false, "tool_call": true, @@ -84323,7 +95753,8 @@ "open_weights": true, "cost": { "input": 0.69, - "output": 3.22 + "output": 3.22, + "cache_read": 0.1725 }, "limit": { "context": 262128, @@ -84331,15 +95762,15 @@ } }, { - "id": "neuralwatt/mistralai/Devstral-Small-2-24B-Instruct", - "name": "Devstral Small 2 24B Instruct 2512", - "family": "devstral", + "id": "neuralwatt/kimi-k2.6-flex", + "name": "Kimi K2.6 Flex", + "family": "kimi-k2", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-12-09", - "last_updated": "2025-12-09", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", "modalities": { "input": [ "text", @@ -84351,18 +95782,50 @@ }, "open_weights": true, "cost": { - "input": 0.12, - "output": 0.35 + "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", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -84381,7 +95844,8 @@ "open_weights": true, "cost": { "input": 0.52, - "output": 2.59 + "output": 2.59, + "cache_read": 0.13 }, "limit": { "context": 262128, @@ -84391,7 +95855,7 @@ { "id": "neuralwatt/moonshotai/Kimi-K2.6", "name": "Kimi K2.6", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -84410,7 +95874,8 @@ "open_weights": true, "cost": { "input": 0.69, - "output": 3.22 + "output": 3.22, + "cache_read": 0.1725 }, "limit": { "context": 262128, @@ -84418,18 +95883,20 @@ } }, { - "id": "neuralwatt/openai/gpt-oss-20b", - "name": "GPT OSS 20B", - "family": "gpt-oss", - "attachment": false, + "id": "neuralwatt/moonshotai/Kimi-K2.7-Code", + "name": "Kimi K2.7 Code", + "family": "kimi-k2", + "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, - "release_date": "2025-08-05", - "last_updated": "2025-08-05", + "temperature": false, + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -84437,12 +95904,13 @@ }, "open_weights": true, "cost": { - "input": 0.03, - "output": 0.16 + "input": 0.95, + "output": 4.0, + "cache_read": 0.2375 }, "limit": { - "context": 16368, - "output": 16368 + "context": 262144, + "output": 262144 } }, { @@ -84466,7 +95934,8 @@ "open_weights": true, "cost": { "input": 0.69, - "output": 4.14 + "output": 4.14, + "cache_read": 0.1725 }, "limit": { "context": 262128, @@ -84495,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", @@ -84714,7 +96156,7 @@ "tool_call": true, "temperature": true, "release_date": "2025-06-30", - "last_updated": "2025-06-30", + "last_updated": "2026-06-14", "modalities": { "input": [ "text", @@ -84726,8 +96168,8 @@ }, "open_weights": true, "cost": { - "input": 1.4, - "output": 5.6 + "input": 0.14, + "output": 0.56 }, "limit": { "context": 30000, @@ -85263,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, @@ -85424,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" @@ -85435,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, @@ -85987,7 +97430,7 @@ { "id": "novita-ai/moonshotai/kimi-k2", "name": "Kimi K2 0905", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -86037,19 +97480,19 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 32768 } }, { "id": "novita-ai/moonshotai/kimi-k2-thinking", "name": "Kimi K2 Thinking", - "family": "kimi", + "family": "kimi-thinking", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2025-11-07", - "last_updated": "2025-11-07", + "last_updated": "2026-06-29", "modalities": { "input": [ "text" @@ -86061,7 +97504,8 @@ "open_weights": true, "cost": { "input": 0.6, - "output": 2.5 + "output": 2.5, + "cache_read": 0.15 }, "limit": { "context": 262144, @@ -86071,7 +97515,7 @@ { "id": "novita-ai/moonshotai/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -86103,7 +97547,7 @@ { "id": "novita-ai/moonshotai/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -86123,8 +97567,8 @@ }, "open_weights": true, "cost": { - "input": 0.95, - "output": 4.0, + "input": 0.8, + "output": 3.4, "cache_read": 0.16 }, "limit": { @@ -86597,8 +98041,8 @@ }, "open_weights": true, "cost": { - "input": 0.3, - "output": 1.3 + "input": 0.38, + "output": 1.55 }, "limit": { "context": 262144, @@ -87066,7 +98510,7 @@ "cost": { "input": 1.25, "output": 3.75, - "cache_read": 0.125, + "cache_read": 0.25, "cache_write": 1.5625 }, "limit": { @@ -87264,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, @@ -87352,7 +98796,8 @@ "open_weights": true, "cost": { "input": 0.13, - "output": 0.85 + "output": 0.85, + "cache_read": 0.025 }, "limit": { "context": 131072, @@ -87560,7 +99005,7 @@ }, "open_weights": true, "cost": { - "input": 1.4, + "input": 1.38, "output": 4.4, "cache_read": 0.26 }, @@ -87569,6 +99014,35 @@ "output": 131072 } }, + { + "id": "novita-ai/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": 1048576, + "output": 131072 + } + }, { "id": "nvidia/abacusai/dracarys-llama-3_1-70b-instruct", "name": "dracarys-llama-3.1-70b-instruct", @@ -87767,64 +99241,6 @@ "output": 262000 } }, - { - "id": "nvidia/deepseek-ai/deepseek-v3.1-terminus", - "name": "DeepSeek V3.1 Terminus", - "family": "deepseek", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-01", - "release_date": "2025-09-22", - "last_updated": "2025-09-22", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 128000, - "output": 8192 - } - }, - { - "id": "nvidia/deepseek-ai/deepseek-v3.2", - "name": "DeepSeek V3.2", - "family": "deepseek", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-07", - "release_date": "2025-12-01", - "last_updated": "2025-12-01", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 163840, - "output": 65536 - } - }, { "id": "nvidia/deepseek-ai/deepseek-v4-flash", "name": "DeepSeek V4 Flash", @@ -87912,36 +99328,6 @@ "output": 4096 } }, - { - "id": "nvidia/google/gemma-3-27b-it", - "name": "Gemma-3-27B-IT", - "family": "gemma", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-12", - "release_date": "2024-12-01", - "last_updated": "2025-09-05", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 131072, - "output": 8192 - } - }, { "id": "nvidia/google/gemma-3n-e2b-it", "name": "Gemma 3n E2b It", @@ -88373,8 +99759,8 @@ "id": "nvidia/microsoft/phi-4-mini-instruct", "name": "Phi-4-Mini", "family": "phi", - "attachment": true, - "reasoning": true, + "attachment": false, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2024-12", @@ -88382,15 +99768,13 @@ "last_updated": "2025-09-05", "modalities": { "input": [ - "text", - "image", - "audio" + "text" ], "output": [ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.0, "output": 0.0 @@ -88426,35 +99810,6 @@ "output": 16384 } }, - { - "id": "nvidia/minimaxai/minimax-m2.5", - "name": "MiniMax-M2.5", - "family": "minimax", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-08", - "release_date": "2026-02-12", - "last_updated": "2026-02-12", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 204800, - "output": 131072 - } - }, { "id": "nvidia/minimaxai/minimax-m2.7", "name": "MiniMax-M2.7", @@ -88484,19 +99839,20 @@ } }, { - "id": "nvidia/mistralai/devstral-2-123b-instruct", - "name": "Devstral-2-123B-Instruct-2512", - "family": "devstral", + "id": "nvidia/minimaxai/minimax-m3", + "name": "MiniMax-M3", + "family": "minimax", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-12", - "release_date": "2025-12-08", - "last_updated": "2025-12-09", + "release_date": "2026-06-01", + "last_updated": "2026-06-01", "modalities": { "input": [ - "text" + "text", + "image", + "video" ], "output": [ "text" @@ -88508,8 +99864,8 @@ "output": 0.0 }, "limit": { - "context": 262144, - "output": 262144 + "context": 1000000, + "output": 16384 } }, { @@ -88626,6 +99982,7 @@ { "id": "nvidia/mistralai/mistral-nemotron", "name": "mistral-nemotron", + "family": "nemotron", "attachment": false, "reasoning": false, "tool_call": true, @@ -88653,15 +100010,16 @@ { "id": "nvidia/mistralai/mistral-small-4-119b", "name": "mistral-small-4-119b-2603", - "attachment": false, - "reasoning": false, + "attachment": true, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2026-03-16", "last_updated": "2026-03-16", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -88734,7 +100092,7 @@ { "id": "nvidia/moonshotai/kimi-k2-instruct", "name": "Kimi K2 0905", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -88760,41 +100118,10 @@ "output": 262144 } }, - { - "id": "nvidia/moonshotai/kimi-k2-thinking", - "name": "Kimi K2 Thinking", - "family": "kimi-thinking", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-07", - "release_date": "2025-11", - "last_updated": "2025-12", - "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": 262144, - "output": 262144 - } - }, { "id": "nvidia/moonshotai/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -88993,6 +100320,7 @@ { "id": "nvidia/nvidia/llama-3_1-nemotron-safety-guard-8b-v3", "name": "llama-3.1-nemotron-safety-guard-8b-v3", + "family": "nemotron", "attachment": false, "reasoning": false, "tool_call": false, @@ -89044,67 +100372,10 @@ "output": 2048 } }, - { - "id": "nvidia/nvidia/llama-3_3-nemotron-super-49b-v1", - "name": "Llama 3.3 Nemotron Super 49B v1", - "family": "nemotron", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2023-12", - "release_date": "2025-04-07", - "last_updated": "2025-04-07", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 131072, - "output": 131072 - } - }, - { - "id": "nvidia/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, - "knowledge": "2023-12", - "release_date": "2025-07-25", - "last_updated": "2025-07-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 131072, - "output": 131072 - } - }, { "id": "nvidia/nvidia/llama-nemotron-embed-vl-1b-v2", "name": "llama-nemotron-embed-vl-1b-v2", + "family": "nemotron", "attachment": true, "reasoning": false, "tool_call": false, @@ -89133,6 +100404,7 @@ { "id": "nvidia/nvidia/llama-nemotron-rerank-vl-1b-v2", "name": "llama-nemotron-rerank-vl-1b-v2", + "family": "nemotron", "attachment": true, "reasoning": false, "tool_call": false, @@ -89189,6 +100461,7 @@ { "id": "nvidia/nvidia/nemotron-3-content-safety", "name": "nemotron-3-content-safety", + "family": "nemotron", "attachment": false, "reasoning": false, "tool_call": false, @@ -89243,7 +100516,7 @@ } }, { - "id": "nvidia/nvidia/nemotron-3-nano-omni-30b-a3b", + "id": "nvidia/nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", "name": "Nemotron 3 Nano Omni", "family": "nemotron", "attachment": true, @@ -89302,9 +100575,39 @@ "output": 262144 } }, + { + "id": "nvidia/nvidia/nemotron-3-ultra-550b-a55b", + "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.5, + "output": 2.5, + "cache_read": 0.15 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "nvidia/nvidia/nemotron-content-safety-reasoning-4b", "name": "nemotron-content-safety-reasoning-4b", + "family": "nemotron", "attachment": false, "reasoning": true, "tool_call": false, @@ -89332,6 +100635,7 @@ { "id": "nvidia/nvidia/nemotron-mini-4b-instruct", "name": "nemotron-mini-4b-instruct", + "family": "nemotron", "attachment": false, "reasoning": false, "tool_call": true, @@ -89359,6 +100663,7 @@ { "id": "nvidia/nvidia/nemotron-voicechat", "name": "nemotron-voicechat", + "family": "nemotron", "attachment": true, "reasoning": false, "tool_call": true, @@ -89687,9 +100992,9 @@ "id": "nvidia/openai/gpt-oss-120b", "name": "GPT-OSS-120B", "family": "gpt-oss", - "attachment": true, + "attachment": false, "reasoning": true, - "tool_call": false, + "tool_call": true, "temperature": true, "knowledge": "2025-08", "release_date": "2025-08-04", @@ -89702,7 +101007,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.0, "output": 0.0 @@ -89912,35 +101217,6 @@ "output": 16384 } }, - { - "id": "nvidia/qwen/qwen3-next-80b-a3b-thinking", - "name": "Qwen3-Next-80B-A3B-Thinking", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-12", - "release_date": "2024-12-01", - "last_updated": "2025-09-05", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 262144, - "output": 16384 - } - }, { "id": "nvidia/qwen/qwen3.5-122b-a10b", "name": "Qwen3.5 122B-A10B", @@ -90112,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" @@ -90135,36 +101411,7 @@ "output": 0.0 }, "limit": { - "context": 131072, - "output": 131072 - } - }, - { - "id": "nvidia/z-ai/glm4.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.0, - "output": 0.0 - }, - "limit": { - "context": 204800, + "context": 1000000, "output": 131072 } }, @@ -90560,6 +101807,31 @@ "output": 131072 } }, + { + "id": "ollama-cloud/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": {}, + "limit": { + "context": 976000, + "output": 131072 + } + }, { "id": "ollama-cloud/gpt-oss:120b", "name": "gpt-oss:120b", @@ -90636,7 +101908,7 @@ { "id": "ollama-cloud/kimi-k2.5", "name": "kimi-k2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -90661,7 +101933,7 @@ { "id": "ollama-cloud/kimi-k2.6", "name": "kimi-k2.6", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -90683,10 +101955,37 @@ "output": 262144 } }, + { + "id": "ollama-cloud/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": {}, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "ollama-cloud/kimi-k2:1t", "name": "kimi-k2:1t", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -90713,7 +102012,7 @@ "name": "minimax-m2", "family": "minimax", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "release_date": "2025-10-23", "last_updated": "2026-01-19", @@ -90809,7 +102108,7 @@ "id": "ollama-cloud/minimax-m3", "name": "minimax-m3", "family": "minimax-m3", - "attachment": false, + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, @@ -90940,6 +102239,7 @@ "attachment": false, "reasoning": true, "tool_call": true, + "temperature": true, "release_date": "2025-12-15", "last_updated": "2026-01-19", "modalities": { @@ -90964,6 +102264,7 @@ "attachment": false, "reasoning": true, "tool_call": true, + "temperature": true, "release_date": "2026-03-11", "last_updated": "2026-03-12", "modalities": { @@ -90981,6 +102282,31 @@ "output": 65536 } }, + { + "id": "ollama-cloud/nemotron-3-ultra", + "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": {}, + "limit": { + "context": 262144, + "output": 128000 + } + }, { "id": "ollama-cloud/qwen3-coder-next", "name": "qwen3-coder-next", @@ -91365,7 +102691,7 @@ }, { "id": "openai/gpt-4o", - "name": "GPT-4o (2024-05-13)", + "name": "GPT-4o", "family": "gpt", "attachment": true, "reasoning": false, @@ -91373,11 +102699,12 @@ "temperature": true, "knowledge": "2023-09", "release_date": "2024-05-13", - "last_updated": "2024-05-13", + "last_updated": "2024-08-06", "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -91385,12 +102712,13 @@ }, "open_weights": false, "cost": { - "input": 5.0, - "output": 15.0 + "input": 2.5, + "output": 10.0, + "cache_read": 1.25 }, "limit": { "context": 128000, - "output": 4096 + "output": 16384 } }, { @@ -92171,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", @@ -92251,6 +103711,36 @@ "output": 0 } }, + { + "id": "openai/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": 5.0, + "output": 30.0, + "cache_read": 1.25 + }, + "limit": { + "context": 0, + "output": 0 + } + }, { "id": "openai/o1", "name": "o1", @@ -92283,66 +103773,6 @@ "output": 100000 } }, - { - "id": "openai/o1-mini", - "name": "o1-mini", - "family": "o-mini", - "attachment": false, - "reasoning": true, - "tool_call": false, - "temperature": false, - "knowledge": "2023-09", - "release_date": "2024-09-12", - "last_updated": "2024-09-12", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.1, - "output": 4.4, - "cache_read": 0.55 - }, - "limit": { - "context": 128000, - "output": 65536 - } - }, - { - "id": "openai/o1-preview", - "name": "o1-preview", - "family": "o", - "attachment": false, - "reasoning": true, - "tool_call": false, - "temperature": true, - "knowledge": "2023-09", - "release_date": "2024-09-12", - "last_updated": "2024-09-12", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 15.0, - "output": 60.0, - "cache_read": 7.5 - }, - "limit": { - "context": 128000, - "output": 32768 - } - }, { "id": "openai/o1-pro", "name": "o1-pro", @@ -92765,10 +104195,39 @@ "output": 32768 } }, + { + "id": "opencode-go/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-go/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi-k2.5", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -92800,7 +104259,7 @@ { "id": "opencode-go/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -92829,6 +104288,38 @@ "output": 65536 } }, + { + "id": "opencode-go/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-go/mimo-v2-omni", "name": "MiMo V2 Omni", @@ -92957,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, @@ -92987,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, @@ -93017,7 +104508,7 @@ }, { "id": "opencode-go/minimax-m3", - "name": "MiniMax M3", + "name": "MiniMax-M3", "family": "minimax-m3", "attachment": false, "reasoning": true, @@ -93038,12 +104529,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": 131072 } }, @@ -93109,7 +104600,7 @@ "cache_write": 0.625 }, "limit": { - "context": 262144, + "context": 1000000, "output": 65536 } }, @@ -93143,6 +104634,38 @@ "output": 65536 } }, + { + "id": "opencode-go/qwen3.7-plus", + "name": "Qwen3.7 Plus", + "family": "qwen3.7-plus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-02", + "last_updated": "2026-06-02", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.4, + "output": 1.6, + "cache_read": 0.04, + "cache_write": 0.5 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "opencode/big-pickle", "name": "Big Pickle", @@ -93207,6 +104730,39 @@ "output": 8192 } }, + { + "id": "opencode/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": "opencode/claude-haiku-4.5", "name": "Claude Haiku 4.5", @@ -93380,6 +104936,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -93503,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", @@ -93526,7 +105116,7 @@ "cost": { "input": 0.14, "output": 0.28, - "cache_read": 0.03 + "cache_read": 0.028 }, "limit": { "context": 1000000, @@ -93563,6 +105153,36 @@ "output": 128000 } }, + { + "id": "opencode/deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "family": "deepseek-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.74, + "output": 3.84, + "cache_read": 0.145 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, { "id": "opencode/gemini-3-flash", "name": "Gemini 3 Flash", @@ -93879,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", @@ -94413,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", @@ -94473,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", @@ -94506,7 +106214,7 @@ { "id": "opencode/kimi-k2", "name": "Kimi K2", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -94566,7 +106274,7 @@ { "id": "opencode/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -94630,7 +106338,7 @@ { "id": "opencode/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -94659,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", @@ -94816,7 +106556,7 @@ }, { "id": "opencode/minimax-m2.1", - "name": "MiniMax M2.1", + "name": "MiniMax-M2.1", "family": "minimax", "attachment": false, "reasoning": true, @@ -94846,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, @@ -94876,7 +106616,7 @@ }, { "id": "opencode/minimax-m2.5", - "name": "MiniMax M2.5", + "name": "MiniMax-M2.5", "family": "minimax", "attachment": false, "reasoning": true, @@ -94906,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, @@ -94936,7 +106676,7 @@ }, { "id": "opencode/minimax-m2.7", - "name": "MiniMax M2.7", + "name": "MiniMax-M2.7", "family": "minimax", "attachment": false, "reasoning": true, @@ -94964,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, @@ -95026,6 +106797,65 @@ "output": 128000 } }, + { + "id": "opencode/nemotron-3-ultra-free", + "name": "Nemotron 3 Ultra Free", + "family": "nemotron-free", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2026-02", + "release_date": "2026-06-04", + "last_updated": "2026-06-04", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "opencode/north-mini-code-free", + "name": "North Mini Code Free", + "family": "north-free", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-09-23", + "release_date": "2026-06-09", + "last_updated": "2026-06-09", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 256000, + "output": 64000 + } + }, { "id": "opencode/qwen3-coder", "name": "Qwen3 Coder", @@ -95240,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", @@ -95322,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)", @@ -95590,20 +107422,21 @@ } }, { - "id": "openrouter/anthropic/claude-3.5-haiku", - "name": "Claude 3.5 Haiku", - "family": "claude", + "id": "openrouter/anthropic/claude-fable-5", + "name": "Claude Fable 5", + "family": "claude-fable", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, - "temperature": true, - "knowledge": "2024-07-31", - "release_date": "2024-11-04", - "last_updated": "2024-11-04", + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-09", + "last_updated": "2026-06-09", "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -95611,14 +107444,14 @@ }, "open_weights": false, "cost": { - "input": 0.8, - "output": 4.0, - "cache_read": 0.08, - "cache_write": 1.0 + "input": 10.0, + "output": 50.0, + "cache_read": 1.0, + "cache_write": 12.5 }, "limit": { - "context": 200000, - "output": 8192 + "context": 1000000, + "output": 128000 } }, { @@ -95700,8 +107533,8 @@ "last_updated": "2025-08-05", "modalities": { "input": [ - "image", "text", + "image", "pdf" ], "output": [ @@ -95728,14 +107561,14 @@ "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": { "input": [ - "pdf", + "text", "image", - "text" + "pdf" ], "output": [ "text" @@ -95786,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", @@ -95890,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": { @@ -96046,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", @@ -96074,63 +107909,6 @@ "output": 32768 } }, - { - "id": "openrouter/arcee-ai/maestro", - "name": "Maestro Reasoning", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "knowledge": "2025-03-31", - "release_date": "2025-05-05", - "last_updated": "2025-05-05", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.9, - "output": 3.3 - }, - "limit": { - "context": 131072, - "output": 32000 - } - }, - { - "id": "openrouter/arcee-ai/spotlight", - "name": "Spotlight", - "attachment": true, - "reasoning": false, - "tool_call": false, - "temperature": true, - "knowledge": "2025-03-31", - "release_date": "2025-05-05", - "last_updated": "2025-05-05", - "modalities": { - "input": [ - "image", - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.18, - "output": 0.18 - }, - "limit": { - "context": 131072, - "output": 65537 - } - }, { "id": "openrouter/arcee-ai/trinity-large-thinking", "name": "Trinity Large Thinking", @@ -96151,13 +107929,13 @@ }, "open_weights": true, "cost": { - "input": 0.22, - "output": 0.85, + "input": 0.25, + "output": 0.8, "cache_read": 0.06 }, "limit": { "context": 262144, - "output": 262144 + "output": 80000 } }, { @@ -96216,65 +107994,6 @@ "output": 64000 } }, - { - "id": "openrouter/baidu/ernie-4.5-300b-a47b", - "name": "ERNIE 4.5 300B A47B ", - "family": "ernie", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "knowledge": "2025-03-31", - "release_date": "2025-06-30", - "last_updated": "2025-06-30", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.28, - "output": 1.1 - }, - "limit": { - "context": 123000, - "output": 12000 - } - }, - { - "id": "openrouter/baidu/ernie-4.5-vl-28b-a3b", - "name": "ERNIE 4.5 VL 28B A3B", - "family": "ernie", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-03-31", - "release_date": "2025-08-12", - "last_updated": "2025-08-12", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.14, - "output": 0.56 - }, - "limit": { - "context": 30000, - "output": 8000 - } - }, { "id": "openrouter/baidu/ernie-4.5-vl-424b-a47b", "name": "ERNIE 4.5 VL 424B A47B ", @@ -96580,8 +108299,8 @@ "tool_call": false, "temperature": true, "knowledge": "2024-06-01", - "release_date": "2024-02-27", - "last_updated": "2024-02-27", + "release_date": "2024-12-02", + "last_updated": "2024-12-02", "modalities": { "input": [ "text" @@ -96600,6 +108319,34 @@ "output": 4000 } }, + { + "id": "openrouter/cohere/north-mini-code:free", + "name": "North Mini Code (free)", + "family": "north", + "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.0, + "output": 0.0 + }, + "limit": { + "context": 256000, + "output": 64000 + } + }, { "id": "openrouter/deepcogito/cogito-v2.1-671b", "name": "Cogito v2.1 671B", @@ -96678,8 +108425,8 @@ }, "open_weights": true, "cost": { - "input": 0.2, - "output": 0.77, + "input": 0.24, + "output": 0.9, "cache_read": 0.135 }, "limit": { @@ -96719,15 +108466,15 @@ }, { "id": "openrouter/deepseek/deepseek-r1", - "name": "R1", + "name": "DeepSeek-R1", "family": "deepseek-thinking", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2024-07-31", + "knowledge": "2024-07", "release_date": "2025-01-20", - "last_updated": "2025-01-20", + "last_updated": "2025-05-29", "modalities": { "input": [ "text" @@ -96767,41 +108514,12 @@ }, "open_weights": true, "cost": { - "input": 0.7, + "input": 0.8, "output": 0.8 }, "limit": { - "context": 131072, - "output": 16384 - } - }, - { - "id": "openrouter/deepseek/deepseek-r1-distill-qwen-32b", - "name": "R1 Distill Qwen 32B", - "family": "deepseek", - "attachment": false, - "reasoning": true, - "tool_call": false, - "temperature": true, - "knowledge": "2024-07-31", - "release_date": "2025-01-29", - "last_updated": "2025-01-29", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.29, - "output": 0.29 - }, - "limit": { - "context": 32768, - "output": 32768 + "context": 8192, + "output": 8192 } }, { @@ -96856,7 +108574,8 @@ "open_weights": true, "cost": { "input": 0.2288, - "output": 0.3432 + "output": 0.3432, + "cache_read": 0.02288 }, "limit": { "context": 128000, @@ -96913,13 +108632,13 @@ }, "open_weights": true, "cost": { - "input": 0.0983, - "output": 0.1966, - "cache_read": 0.0197 + "input": 0.09, + "output": 0.18, + "cache_read": 0.018 }, "limit": { "context": 1048576, - "output": 131072 + "output": 65536 } }, { @@ -96952,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", @@ -96989,15 +108680,15 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ - "pdf", - "image", "text", + "image", "audio", - "video" + "video", + "pdf" ], "output": [ "text" @@ -97028,12 +108719,12 @@ "last_updated": "2025-08-26", "modalities": { "input": [ - "image", - "text" + "text", + "image" ], "output": [ - "image", - "text" + "text", + "image" ] }, "open_weights": false, @@ -97063,44 +108754,9 @@ "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-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" + "video", + "pdf" ], "output": [ "text" @@ -97127,15 +108783,15 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", "image", - "pdf", "audio", - "video" + "video", + "pdf" ], "output": [ "text" @@ -97237,9 +108893,9 @@ "input": [ "text", "image", - "pdf", + "video", "audio", - "video" + "pdf" ], "output": [ "text" @@ -97254,19 +108910,19 @@ }, "limit": { "context": 1048576, - "output": 65536 + "output": 65535 } }, { - "id": "openrouter/google/gemini-3-pro-image-preview", - "name": "Nano Banana Pro (Gemini 3 Pro Image Preview)", + "id": "openrouter/google/gemini-3-pro-image", + "name": "Nano Banana Pro (Gemini 3 Pro Image)", "family": "gemini", "attachment": true, "reasoning": true, - "tool_call": false, + "tool_call": true, "temperature": true, - "release_date": "2025-11-20", - "last_updated": "2025-11-20", + "release_date": "2026-06-18", + "last_updated": "2026-06-18", "modalities": { "input": [ "image", @@ -97290,16 +108946,48 @@ } }, { - "id": "openrouter/google/gemini-3.1-flash-image-preview", - "name": "Nano Banana 2", - "family": "gemini-flash", + "id": "openrouter/google/gemini-3-pro-image-preview", + "name": "Nano Banana Pro", + "family": "gemini-pro", "attachment": true, "reasoning": true, "tool_call": false, "temperature": true, "knowledge": "2025-01", - "release_date": "2026-02-26", - "last_updated": "2026-02-26", + "release_date": "2025-11-20", + "last_updated": "2025-11-20", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text", + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 12.0, + "cache_read": 0.2, + "cache_write": 0.375 + }, + "limit": { + "context": 65536, + "output": 32768 + } + }, + { + "id": "openrouter/google/gemini-3.1-flash-image", + "name": "Nano Banana 2 (Gemini 3.1 Flash Image)", + "family": "gemini", + "attachment": true, + "reasoning": true, + "tool_call": false, + "temperature": true, + "release_date": "2026-06-18", + "last_updated": "2026-06-18", "modalities": { "input": [ "image", @@ -97316,8 +109004,39 @@ "output": 3.0 }, "limit": { - "context": 65536, - "output": 65536 + "context": 131072, + "output": 32768 + } + }, + { + "id": "openrouter/google/gemini-3.1-flash-image-preview", + "name": "Nano Banana 2", + "family": "gemini-flash", + "attachment": true, + "reasoning": true, + "tool_call": false, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-02-26", + "last_updated": "2026-02-26", + "modalities": { + "input": [ + "image", + "text" + ], + "output": [ + "text", + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.5, + "output": 3.0 + }, + "limit": { + "context": 131072, + "output": 32768 } }, { @@ -97336,8 +109055,8 @@ "text", "image", "video", - "pdf", - "audio" + "audio", + "pdf" ], "output": [ "text" @@ -97355,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", @@ -97371,8 +109121,8 @@ "text", "image", "video", - "pdf", - "audio" + "audio", + "pdf" ], "output": [ "text" @@ -97403,11 +109153,11 @@ "last_updated": "2026-02-19", "modalities": { "input": [ - "audio", - "pdf", - "image", "text", - "video" + "image", + "video", + "audio", + "pdf" ], "output": [ "text" @@ -97439,9 +109189,9 @@ "modalities": { "input": [ "text", - "audio", "image", "video", + "audio", "pdf" ], "output": [ @@ -97476,8 +109226,8 @@ "text", "image", "video", - "pdf", - "audio" + "audio", + "pdf" ], "output": [ "text" @@ -97546,8 +109296,8 @@ }, "open_weights": true, "cost": { - "input": 0.04, - "output": 0.13 + "input": 0.05, + "output": 0.15 }, "limit": { "context": 131072, @@ -97606,8 +109356,8 @@ }, "open_weights": true, "cost": { - "input": 0.04, - "output": 0.08 + "input": 0.05, + "output": 0.1 }, "limit": { "context": 131072, @@ -97699,7 +109449,7 @@ "output": 0.0 }, "limit": { - "context": 262144, + "context": 131072, "output": 32768 } }, @@ -97726,11 +109476,12 @@ "open_weights": true, "cost": { "input": 0.12, - "output": 0.37 + "output": 0.35, + "cache_read": 0.09 }, "limit": { "context": 262144, - "output": 16384 + "output": 262144 } }, { @@ -97760,7 +109511,7 @@ }, "limit": { "context": 262144, - "output": 32768 + "output": 8192 } }, { @@ -98015,9 +109766,9 @@ }, "open_weights": false, "cost": { - "input": 0.3, - "output": 2.5, - "cache_read": 0.06 + "input": 0.075, + "output": 0.625, + "cache_read": 0.015 }, "limit": { "context": 262144, @@ -98109,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)", @@ -98172,7 +109895,7 @@ "family": "liquid", "attachment": false, "reasoning": true, - "tool_call": false, + "tool_call": true, "temperature": true, "knowledge": "2025-06", "release_date": "2026-01-20", @@ -98224,35 +109947,6 @@ "output": 2000 } }, - { - "id": "openrouter/meta-llama/llama-3-70b-instruct", - "name": "Llama 3 70B Instruct", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "knowledge": "2023-12-31", - "release_date": "2024-04-18", - "last_updated": "2024-04-18", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.51, - "output": 0.74 - }, - "limit": { - "context": 8192, - "output": 8000 - } - }, { "id": "openrouter/meta-llama/llama-3-8b-instruct", "name": "Llama 3 8B Instruct", @@ -98274,8 +109968,8 @@ }, "open_weights": true, "cost": { - "input": 0.04, - "output": 0.04 + "input": 0.14, + "output": 0.14 }, "limit": { "context": 8192, @@ -98333,10 +110027,10 @@ "open_weights": true, "cost": { "input": 0.02, - "output": 0.05 + "output": 0.03 }, "limit": { - "context": 16384, + "context": 131072, "output": 16384 } }, @@ -98362,8 +110056,8 @@ }, "open_weights": true, "cost": { - "input": 0.245, - "output": 0.245 + "input": 0.345, + "output": 0.345 }, "limit": { "context": 131072, @@ -98420,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 } }, { @@ -98567,7 +110261,7 @@ }, "open_weights": true, "cost": { - "input": 0.08, + "input": 0.1, "output": 0.3 }, "limit": { @@ -98575,35 +110269,6 @@ "output": 16384 } }, - { - "id": "openrouter/meta-llama/llama-guard-3-8b", - "name": "Llama Guard 3 8B", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "knowledge": "2023-12-31", - "release_date": "2025-02-12", - "last_updated": "2025-02-12", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.484, - "output": 0.03 - }, - "limit": { - "context": 131072, - "output": 131072 - } - }, { "id": "openrouter/meta-llama/llama-guard-4-12b", "name": "Llama Guard 4 12B", @@ -98655,7 +110320,7 @@ }, "open_weights": true, "cost": { - "input": 0.065, + "input": 0.07, "output": 0.14 }, "limit": { @@ -98663,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", @@ -98800,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 } }, { @@ -98857,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 } }, { @@ -98887,7 +110522,8 @@ "open_weights": true, "cost": { "input": 0.15, - "output": 1.15 + "output": 0.9, + "cache_read": 0.05 }, "limit": { "context": 196608, @@ -98914,24 +110550,24 @@ }, "open_weights": true, "cost": { - "input": 0.26, - "output": 1.2 + "input": 0.24, + "output": 0.96 }, "limit": { "context": 196608, - "output": 131072 + "output": 196608 } }, { "id": "openrouter/minimax/minimax-m3", - "name": "MiniMax M3", - "family": "minimax-m3", + "name": "MiniMax-M3", + "family": "minimax", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-05-31", - "last_updated": "2026-05-31", + "release_date": "2026-06-01", + "last_updated": "2026-06-01", "modalities": { "input": [ "text", @@ -99107,34 +110743,33 @@ }, { "id": "openrouter/mistralai/mistral-large", - "name": "Mistral Large 3", + "name": "Mistral Large", "family": "mistral-large", "attachment": true, "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2024-11", - "release_date": "2024-11-01", - "last_updated": "2025-12-02", + "knowledge": "2024-11-30", + "release_date": "2024-02-26", + "last_updated": "2024-02-26", "modalities": { "input": [ "text", - "image", "pdf" ], "output": [ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { - "input": 0.5, - "output": 1.5, - "cache_read": 0.05 + "input": 2.0, + "output": 6.0, + "cache_read": 0.2 }, "limit": { - "context": 262144, - "output": 262144 + "context": 128000, + "output": 128000 } }, { @@ -99476,7 +111111,7 @@ { "id": "openrouter/moonshotai/kimi-k2", "name": "Kimi K2 0711", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, @@ -99499,7 +111134,7 @@ }, "limit": { "context": 131072, - "output": 32768 + "output": 100352 } }, { @@ -99524,17 +111159,18 @@ "open_weights": true, "cost": { "input": 0.6, - "output": 2.5 + "output": 2.5, + "cache_read": 0.15 }, "limit": { "context": 262144, - "output": 262144 + "output": 100352 } }, { "id": "openrouter/moonshotai/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi-k2.5", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -99553,19 +111189,19 @@ }, "open_weights": true, "cost": { - "input": 0.4, - "output": 1.9, - "cache_read": 0.09 + "input": 0.375, + "output": 2.025, + "cache_read": 0.203 }, "limit": { - "context": 262144, - "output": 262144 + "context": 256000, + "output": 256000 } }, { "id": "openrouter/moonshotai/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -99584,9 +111220,9 @@ }, "open_weights": true, "cost": { - "input": 0.684, - "output": 3.42, - "cache_read": 0.144 + "input": 0.65, + "output": 3.41, + "cache_read": 0.14 }, "limit": { "context": 262144, @@ -99594,16 +111230,16 @@ } }, { - "id": "openrouter/moonshotai/kimi-k2.6:free", - "name": "Kimi K2.6 (free)", - "family": "kimi-k2.6", + "id": "openrouter/moonshotai/kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "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", @@ -99615,8 +111251,9 @@ }, "open_weights": true, "cost": { - "input": 0.0, - "output": 0.0 + "input": 0.72, + "output": 3.5, + "cache_read": 0.15 }, "limit": { "context": 262144, @@ -99680,18 +111317,19 @@ } }, { - "id": "openrouter/nex-agi/deepseek-v3.1-nex-n1", - "name": "DeepSeek V3.1 Nex N1", - "family": "deepseek", - "attachment": false, - "reasoning": false, + "id": "openrouter/nex-agi/nex-n2-mini", + "name": "Nex-N2-Mini", + "family": "agi", + "attachment": true, + "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-12-08", - "last_updated": "2025-12-08", + "release_date": "2026-06-24", + "last_updated": "2026-06-24", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -99699,28 +111337,29 @@ }, "open_weights": true, "cost": { - "input": 0.135, - "output": 0.5 + "input": 0.025, + "output": 0.1, + "cache_read": 0.0025 }, "limit": { - "context": 131072, - "output": 163840 + "context": 262144, + "output": 262144 } }, { - "id": "openrouter/nousresearch/hermes-2-pro-llama-3-8b", - "name": "Hermes 2 Pro - Llama-3 8B", - "family": "nousresearch", - "attachment": false, - "reasoning": false, - "tool_call": false, + "id": "openrouter/nex-agi/nex-n2-pro", + "name": "Nex-N2-Pro", + "family": "agi", + "attachment": true, + "reasoning": true, + "tool_call": true, "temperature": true, - "knowledge": "2023-12-31", - "release_date": "2024-05-27", - "last_updated": "2024-05-27", + "release_date": "2026-06-08", + "last_updated": "2026-06-08", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -99728,12 +111367,13 @@ }, "open_weights": true, "cost": { - "input": 0.14, - "output": 0.14 + "input": 0.25, + "output": 1.0, + "cache_read": 0.025 }, "limit": { - "context": 8192, - "output": 8192 + "context": 262144, + "output": 262144 } }, { @@ -99815,8 +111455,8 @@ }, "open_weights": true, "cost": { - "input": 0.3, - "output": 0.3 + "input": 0.7, + "output": 0.7 }, "limit": { "context": 131072, @@ -99883,15 +111523,14 @@ }, { "id": "openrouter/nvidia/llama-3.3-nemotron-super-49b-v1.5", - "name": "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, - "knowledge": "2024-03-31", - "release_date": "2025-10-10", - "last_updated": "2025-10-10", + "release_date": "2025-07-25", + "last_updated": "2025-07-25", "modalities": { "input": [ "text" @@ -99902,7 +111541,7 @@ }, "open_weights": true, "cost": { - "input": 0.1, + "input": 0.4, "output": 0.4 }, "limit": { @@ -99918,8 +111557,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-12-14", - "last_updated": "2025-12-14", + "release_date": "2025-12-15", + "last_updated": "2025-12-15", "modalities": { "input": [ "text" @@ -99946,9 +111585,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-11", - "release_date": "2025-12-14", - "last_updated": "2025-12-14", + "release_date": "2025-12-15", + "last_updated": "2025-12-15", "modalities": { "input": [ "text" @@ -99980,15 +111618,15 @@ "modalities": { "input": [ "text", - "audio", "image", - "video" + "video", + "audio" ], "output": [ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.0, "output": 0.0 @@ -100000,13 +111638,12 @@ }, { "id": "openrouter/nvidia/nemotron-3-super-120b-a12b", - "name": "Nemotron 3 Super", + "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-03-11", "modalities": { @@ -100019,12 +111656,12 @@ }, "open_weights": true, "cost": { - "input": 0.09, + "input": 0.08, "output": 0.45 }, "limit": { "context": 262144, - "output": 262144 + "output": 16384 } }, { @@ -100035,7 +111672,6 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2024-04", "release_date": "2026-03-11", "last_updated": "2026-03-11", "modalities": { @@ -100056,6 +111692,92 @@ "output": 262144 } }, + { + "id": "openrouter/nvidia/nemotron-3-ultra-550b-a55b", + "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.5, + "output": 2.2, + "cache_read": 0.1 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free", + "name": "Nemotron 3 Ultra (free)", + "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.0, + "output": 0.0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "openrouter/nvidia/nemotron-3.5-content-safety:free", + "name": "Nemotron 3.5 Content Safety (free)", + "family": "nemotron", + "attachment": true, + "reasoning": true, + "tool_call": false, + "temperature": true, + "release_date": "2026-06-04", + "last_updated": "2026-06-04", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 128000, + "output": 8192 + } + }, { "id": "openrouter/nvidia/nemotron-nano-12b-v2-vl:free", "name": "Nemotron Nano 12B 2 VL (free)", @@ -100064,13 +111786,12 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-11", "release_date": "2025-10-28", "last_updated": "2025-10-28", "modalities": { "input": [ - "image", "text", + "image", "video" ], "output": [ @@ -100087,35 +111808,6 @@ "output": 128000 } }, - { - "id": "openrouter/nvidia/nemotron-nano-9b-v2", - "name": "Nemotron Nano 9B V2", - "family": "nemotron", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-03-31", - "release_date": "2025-09-05", - "last_updated": "2025-09-05", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.04, - "output": 0.16 - }, - "limit": { - "context": 131072, - "output": 16384 - } - }, { "id": "openrouter/nvidia/nemotron-nano-9b-v2:free", "name": "Nemotron Nano 9B V2 (free)", @@ -100124,9 +111816,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", - "release_date": "2025-09-05", - "last_updated": "2025-09-05", + "release_date": "2025-08-18", + "last_updated": "2025-08-18", "modalities": { "input": [ "text" @@ -100261,35 +111952,6 @@ "output": 4096 } }, - { - "id": "openrouter/openai/gpt-4-1106-preview", - "name": "GPT-4 Turbo (older v1106)", - "family": "gpt", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2023-04-30", - "release_date": "2023-11-06", - "last_updated": "2023-11-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 10.0, - "output": 30.0 - }, - "limit": { - "context": 128000, - "output": 4096 - } - }, { "id": "openrouter/openai/gpt-4-turbo", "name": "GPT-4 Turbo", @@ -100362,8 +112024,8 @@ "last_updated": "2025-04-14", "modalities": { "input": [ - "image", "text", + "image", "pdf" ], "output": [ @@ -100394,8 +112056,8 @@ "last_updated": "2025-04-14", "modalities": { "input": [ - "image", "text", + "image", "pdf" ], "output": [ @@ -100447,7 +112109,7 @@ }, { "id": "openrouter/openai/gpt-4o", - "name": "GPT-4o (2024-05-13)", + "name": "GPT-4o", "family": "gpt", "attachment": true, "reasoning": false, @@ -100455,7 +112117,7 @@ "temperature": true, "knowledge": "2023-09", "release_date": "2024-05-13", - "last_updated": "2024-05-13", + "last_updated": "2024-08-06", "modalities": { "input": [ "text", @@ -100468,12 +112130,12 @@ }, "open_weights": false, "cost": { - "input": 5.0, - "output": 15.0 + "input": 2.5, + "output": 10.0 }, "limit": { "context": 128000, - "output": 4096 + "output": 16384 } }, { @@ -100960,8 +112622,8 @@ "last_updated": "2025-11-13", "modalities": { "input": [ - "image", - "text" + "text", + "image" ], "output": [ "text" @@ -101339,9 +113001,9 @@ "last_updated": "2026-04-23", "modalities": { "input": [ - "pdf", + "text", "image", - "text" + "pdf" ], "output": [ "text" @@ -101371,9 +113033,9 @@ "last_updated": "2026-04-23", "modalities": { "input": [ - "pdf", + "text", "image", - "text" + "pdf" ], "output": [ "text" @@ -101389,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", @@ -101482,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": { @@ -101501,12 +113360,12 @@ }, "open_weights": true, "cost": { - "input": 0.039, + "input": 0.036, "output": 0.18 }, "limit": { "context": 131072, - "output": 32768 + "output": 131072 } }, { @@ -101517,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": { @@ -101540,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": { @@ -101575,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": { @@ -101593,7 +113449,7 @@ }, "limit": { "context": 131072, - "output": 8192 + "output": 32768 } }, { @@ -101618,7 +113474,7 @@ "cost": { "input": 0.075, "output": 0.3, - "cache_read": 0.037 + "cache_read": 0.0375 }, "limit": { "context": 131072, @@ -101701,8 +113557,8 @@ "last_updated": "2025-04-16", "modalities": { "input": [ - "image", "text", + "image", "pdf" ], "output": [ @@ -102024,15 +113880,14 @@ } }, { - "id": "openrouter/openrouter/owl-alpha", - "name": "Owl Alpha", - "family": "alpha", + "id": "openrouter/openrouter/fusion", + "name": "Fusion", "attachment": false, "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2026-04-28", - "last_updated": "2026-04-28", + "tool_call": false, + "temperature": false, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "modalities": { "input": [ "text" @@ -102042,13 +113897,10 @@ ] }, "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0 - }, + "cost": {}, "limit": { - "context": 1048756, - "output": 262144 + "context": 1000000, + "output": 128000 } }, { @@ -102248,6 +114100,34 @@ "output": 128000 } }, + { + "id": "openrouter/poolside/laguna-m.1", + "name": "Laguna M.1", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-28", + "last_updated": "2026-04-28", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.2, + "output": 0.4, + "cache_read": 0.1 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, { "id": "openrouter/poolside/laguna-m.1:free", "name": "Laguna M.1 (free)", @@ -102265,7 +114145,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.0, "output": 0.0 @@ -102276,14 +114156,42 @@ } }, { - "id": "openrouter/poolside/laguna-xs.2:free", - "name": "Laguna XS.2 (free)", + "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" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.06, + "output": 0.12, + "cache_read": 0.03 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, + { + "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-07-02", + "last_updated": "2026-07-02", "modalities": { "input": [ "text" @@ -102302,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", @@ -102366,7 +114245,7 @@ "family": "qwen", "attachment": false, "reasoning": false, - "tool_call": false, + "tool_call": true, "temperature": true, "knowledge": "2024-06-30", "release_date": "2024-10-16", @@ -102420,15 +114299,15 @@ }, { "id": "openrouter/qwen/qwen-plus", - "name": "Qwen-Plus", + "name": "Qwen Plus", "family": "qwen", "attachment": false, "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", - "release_date": "2025-02-01", - "last_updated": "2025-02-01", + "knowledge": "2024-04", + "release_date": "2024-01-25", + "last_updated": "2025-09-11", "modalities": { "input": [ "text" @@ -102501,12 +114380,13 @@ }, "open_weights": true, "cost": { - "input": 0.25, - "output": 0.75 + "input": 0.8, + "output": 1.0, + "cache_read": 0.4 }, "limit": { - "context": 32000, - "output": 8192 + "context": 128000, + "output": 128000 } }, { @@ -102540,15 +114420,15 @@ }, { "id": "openrouter/qwen/qwen3-235b-a22b", - "name": "Qwen3 235B A22B Instruct 2507", + "name": "Qwen3 235B-A22B", "family": "qwen", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-06-30", - "release_date": "2025-07-21", - "last_updated": "2025-07-21", + "knowledge": "2025-04", + "release_date": "2025-04", + "last_updated": "2025-04", "modalities": { "input": [ "text" @@ -102559,12 +114439,12 @@ }, "open_weights": true, "cost": { - "input": 0.071, - "output": 0.1 + "input": 0.455, + "output": 1.82 }, "limit": { - "context": 262144, - "output": 16384 + "context": 131072, + "output": 8192 } }, { @@ -102588,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 } }, @@ -102618,12 +114497,12 @@ }, "open_weights": true, "cost": { - "input": 0.09, - "output": 0.45 + "input": 0.12, + "output": 0.5 }, "limit": { "context": 40960, - "output": 20000 + "output": 16384 } }, { @@ -102647,12 +114526,12 @@ }, "open_weights": true, "cost": { - "input": 0.09, - "output": 0.3 + "input": 0.04815, + "output": 0.19305 }, "limit": { - "context": 262144, - "output": 262144 + "context": 128000, + "output": 32000 } }, { @@ -102676,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 } }, { @@ -102693,9 +114571,9 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", - "release_date": "2025-04-28", - "last_updated": "2025-04-28", + "knowledge": "2025-04", + "release_date": "2025-04", + "last_updated": "2025-04", "modalities": { "input": [ "text" @@ -102735,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 } }, @@ -102775,15 +114652,15 @@ }, { "id": "openrouter/qwen/qwen3-coder-30b-a3b-instruct", - "name": "Qwen3 Coder 30B A3B Instruct", + "name": "Qwen3-Coder 30B-A3B Instruct", "family": "qwen", "attachment": false, "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2025-06-30", - "release_date": "2025-07-31", - "last_updated": "2025-07-31", + "knowledge": "2025-04", + "release_date": "2025-04", + "last_updated": "2025-04", "modalities": { "input": [ "text" @@ -102810,9 +114687,9 @@ "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2025-06-30", - "release_date": "2025-09-17", - "last_updated": "2025-09-17", + "knowledge": "2025-04", + "release_date": "2025-07-28", + "last_updated": "2025-07-28", "modalities": { "input": [ "text" @@ -102870,9 +114747,9 @@ "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2025-06-30", - "release_date": "2025-09-23", - "last_updated": "2025-09-23", + "knowledge": "2025-04", + "release_date": "2025-07-23", + "last_updated": "2025-07-23", "modalities": { "input": [ "text" @@ -102930,7 +114807,7 @@ "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2025-06-30", + "knowledge": "2025-04", "release_date": "2025-09-23", "last_updated": "2025-09-23", "modalities": { @@ -102983,15 +114860,15 @@ }, { "id": "openrouter/qwen/qwen3-next-80b-a3b-instruct", - "name": "Qwen3 Next 80B A3B Instruct", + "name": "Qwen3-Next 80B-A3B Instruct", "family": "qwen", "attachment": false, "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2025-09-30", - "release_date": "2025-09-11", - "last_updated": "2025-09-11", + "knowledge": "2025-04", + "release_date": "2025-09", + "last_updated": "2025-09", "modalities": { "input": [ "text" @@ -103018,9 +114895,9 @@ "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2025-09-30", - "release_date": "2025-09-11", - "last_updated": "2025-09-11", + "knowledge": "2025-04", + "release_date": "2025-09", + "last_updated": "2025-09", "modalities": { "input": [ "text" @@ -103041,15 +114918,15 @@ }, { "id": "openrouter/qwen/qwen3-next-80b-a3b-thinking", - "name": "Qwen3 Next 80B A3B Thinking", + "name": "Qwen3-Next 80B-A3B (Thinking)", "family": "qwen", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-09-30", - "release_date": "2025-09-11", - "last_updated": "2025-09-11", + "knowledge": "2025-04", + "release_date": "2025-09", + "last_updated": "2025-09", "modalities": { "input": [ "text" @@ -103239,8 +115116,8 @@ }, "open_weights": true, "cost": { - "input": 0.08, - "output": 0.5 + "input": 0.117, + "output": 0.455 }, "limit": { "context": 131072, @@ -103278,14 +115155,14 @@ }, { "id": "openrouter/qwen/qwen3.5-122b-a10b", - "name": "Qwen3.5-122B-A10B", - "family": "qwen3.5", + "name": "Qwen3.5 122B-A10B", + "family": "qwen", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-25", - "last_updated": "2026-02-25", + "release_date": "2026-02-23", + "last_updated": "2026-02-23", "modalities": { "input": [ "text", @@ -103308,14 +115185,14 @@ }, { "id": "openrouter/qwen/qwen3.5-27b", - "name": "Qwen3.5-27B", - "family": "qwen3.5", + "name": "Qwen3.5 27B", + "family": "qwen", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-25", - "last_updated": "2026-02-25", + "release_date": "2026-02-23", + "last_updated": "2026-02-23", "modalities": { "input": [ "text", @@ -103338,14 +115215,14 @@ }, { "id": "openrouter/qwen/qwen3.5-35b-a3b", - "name": "Qwen3.5-35B-A3B", - "family": "qwen3.5", + "name": "Qwen3.5 35B-A3B", + "family": "qwen", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-25", - "last_updated": "2026-02-25", + "release_date": "2026-02-23", + "last_updated": "2026-02-23", "modalities": { "input": [ "text", @@ -103364,20 +115241,19 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 81920 } }, { "id": "openrouter/qwen/qwen3.5-397b-a17b", - "name": "Qwen3.5 397B A17B", + "name": "Qwen3.5 397B-A17B", "family": "qwen", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-04", - "release_date": "2026-02-16", - "last_updated": "2026-02-16", + "release_date": "2026-02-15", + "last_updated": "2026-02-15", "modalities": { "input": [ "text", @@ -103390,24 +115266,25 @@ }, "open_weights": true, "cost": { - "input": 0.39, - "output": 2.34 + "input": 0.385, + "output": 2.45, + "cache_read": 0.111 }, "limit": { - "context": 262144, - "output": 65536 + "context": 131072, + "output": 64000 } }, { "id": "openrouter/qwen/qwen3.5-9b", - "name": "Qwen3.5-9B", - "family": "qwen3.5", + "name": "Qwen3.5 9B", + "family": "qwen", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-03-10", - "last_updated": "2026-03-10", + "release_date": "2026-02-23", + "last_updated": "2026-02-23", "modalities": { "input": [ "text", @@ -103420,12 +115297,12 @@ }, "open_weights": true, "cost": { - "input": 0.04, + "input": 0.1, "output": 0.15 }, "limit": { "context": 262144, - "output": 81920 + "output": 262144 } }, { @@ -103523,13 +115400,13 @@ { "id": "openrouter/qwen/qwen3.6-27b", "name": "Qwen3.6 27B", - "family": "qwen3.6", + "family": "qwen", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-04-27", - "last_updated": "2026-04-27", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", "modalities": { "input": [ "text", @@ -103542,8 +115419,9 @@ }, "open_weights": true, "cost": { - "input": 0.29, - "output": 3.2 + "input": 0.285, + "output": 2.4, + "cache_read": 0.15 }, "limit": { "context": 262140, @@ -103552,14 +115430,14 @@ }, { "id": "openrouter/qwen/qwen3.6-35b-a3b", - "name": "Qwen3.6 35B A3B", - "family": "qwen3.6", + "name": "Qwen3.6 35B-A3B", + "family": "qwen", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-04-27", - "last_updated": "2026-04-27", + "release_date": "2026-04-17", + "last_updated": "2026-04-17", "modalities": { "input": [ "text", @@ -103576,8 +115454,8 @@ "output": 1.0 }, "limit": { - "context": 262140, - "output": 262140 + "context": 262144, + "output": 262144 } }, { @@ -103614,13 +115492,14 @@ { "id": "openrouter/qwen/qwen3.6-max-preview", "name": "Qwen3.6 Max Preview", - "family": "qwen3.6", + "family": "qwen", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-04-27", - "last_updated": "2026-04-27", + "knowledge": "2025-04", + "release_date": "2026-04-20", + "last_updated": "2026-04-20", "modalities": { "input": [ "text" @@ -103702,6 +115581,38 @@ "output": 65536 } }, + { + "id": "openrouter/qwen/qwen3.7-plus", + "name": "Qwen3.7 Plus", + "family": "qwen", + "attachment": true, + "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.32, + "output": 1.28, + "cache_read": 0.064, + "cache_write": 0.4 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "openrouter/rekaai/reka-edge", "name": "Reka Edge", @@ -103816,32 +115727,33 @@ } }, { - "id": "openrouter/sao10k/l3-euryale-70b", - "name": "Llama 3 Euryale 70B v2.1", - "family": "llama", - "attachment": false, - "reasoning": false, + "id": "openrouter/sakana/fugu-ultra", + "name": "Fugu Ultra", + "family": "fugu", + "attachment": true, + "reasoning": true, "tool_call": true, - "temperature": true, - "knowledge": "2023-12-31", - "release_date": "2024-06-18", - "last_updated": "2024-06-18", + "temperature": false, + "release_date": "2026-06-24", + "last_updated": "2026-06-24", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { - "input": 1.48, - "output": 1.48 + "input": 5.0, + "output": 30.0, + "cache_read": 0.5 }, "limit": { - "context": 8192, - "output": 8192 + "context": 1000000, + "output": 128000 } }, { @@ -103963,14 +115875,13 @@ { "id": "openrouter/stepfun/step-3.5-flash", "name": "Step 3.5 Flash", - "family": "step", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, "knowledge": "2025-01", "release_date": "2026-01-29", - "last_updated": "2026-01-29", + "last_updated": "2026-02-13", "modalities": { "input": [ "text" @@ -103981,25 +115892,24 @@ }, "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 } }, { "id": "openrouter/stepfun/step-3.7-flash", "name": "Step 3.7 Flash", - "family": "step", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-05-28", - "last_updated": "2026-05-28", + "knowledge": "2026-01-01", + "release_date": "2026-05-29", + "last_updated": "2026-05-29", "modalities": { "input": [ "text", @@ -104010,7 +115920,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.2, "output": 1.15, @@ -104021,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", @@ -104078,15 +115961,15 @@ } }, { - "id": "openrouter/tencent/hy3-preview", - "name": "Hy3 preview", - "family": "Hy", + "id": "openrouter/tencent/hy3", + "name": "Hy3", + "family": "hy3", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-04-22", - "last_updated": "2026-04-22", + "release_date": "2026-07-06", + "last_updated": "2026-07-06", "modalities": { "input": [ "text" @@ -104097,9 +115980,66 @@ }, "open_weights": true, "cost": { - "input": 0.066, - "output": 0.26, - "cache_read": 0.029 + "input": 0.14, + "output": 0.58, + "cache_read": 0.035 + }, + "limit": { + "context": 262144, + "output": 131072 + } + }, + { + "id": "openrouter/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.063, + "output": 0.21, + "cache_read": 0.021 + }, + "limit": { + "context": 262144, + "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, @@ -104140,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", @@ -104155,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 } }, { @@ -104360,8 +116300,8 @@ }, "open_weights": false, "cost": { - "input": 2.0, - "output": 6.0, + "input": 1.25, + "output": 2.5, "cache_read": 0.2 }, "limit": { @@ -104382,7 +116322,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -104399,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", @@ -104412,7 +116384,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -104429,36 +116402,6 @@ "output": 256000 } }, - { - "id": "openrouter/xiaomi/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": "openrouter/xiaomi/mimo-v2.5", "name": "MiMo-V2.5", @@ -104473,8 +116416,8 @@ "modalities": { "input": [ "text", - "audio", "image", + "audio", "video" ], "output": [ @@ -104483,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 } }, @@ -104522,35 +116465,6 @@ "output": 131072 } }, - { - "id": "openrouter/z-ai/glm-4-32b", - "name": "GLM 4 32B ", - "family": "glm", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-06-30", - "release_date": "2025-07-24", - "last_updated": "2025-07-24", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.1, - "output": 0.1 - }, - "limit": { - "context": 128000, - "output": 128000 - } - }, { "id": "openrouter/z-ai/glm-4.5", "name": "GLM-4.5", @@ -104602,42 +116516,13 @@ }, "open_weights": true, "cost": { - "input": 0.125, + "input": 0.13, "output": 0.85, - "cache_read": 0.06 - }, - "limit": { - "context": 131070, - "output": 131070 - } - }, - { - "id": "openrouter/z-ai/glm-4.5-air:free", - "name": "GLM 4.5 Air (free)", - "family": "glm-air", - "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": true, - "cost": { - "input": 0.0, - "output": 0.0 + "cache_read": 0.025 }, "limit": { "context": 131072, - "output": 96000 + "output": 98304 } }, { @@ -104714,8 +116599,8 @@ "last_updated": "2025-12-08", "modalities": { "input": [ - "image", "text", + "image", "video" ], "output": [ @@ -104726,11 +116611,11 @@ "cost": { "input": 0.3, "output": 0.9, - "cache_read": 0.05 + "cache_read": 0.055 }, "limit": { "context": 131072, - "output": 24000 + "output": 32768 } }, { @@ -104801,8 +116686,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-11", - "last_updated": "2026-02-11", + "release_date": "2026-02-12", + "last_updated": "2026-02-12", "modalities": { "input": [ "text" @@ -104814,12 +116699,12 @@ "open_weights": true, "cost": { "input": 0.6, - "output": 2.08, + "output": 1.92, "cache_read": 0.12 }, "limit": { "context": 202752, - "output": 16384 + "output": 128000 } }, { @@ -104847,7 +116732,7 @@ "cache_read": 0.24 }, "limit": { - "context": 202752, + "context": 262144, "output": 131072 } }, @@ -104859,8 +116744,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-03-27", - "last_updated": "2026-03-27", + "release_date": "2026-04-07", + "last_updated": "2026-04-07", "modalities": { "input": [ "text" @@ -104869,15 +116754,44 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { - "input": 0.98, - "output": 3.08, - "cache_read": 0.182 + "input": 0.966, + "output": 3.036, + "cache_read": 0.1794 }, "limit": { - "context": 202752, - "output": 131072 + "context": 200000, + "output": 128000 + } + }, + { + "id": "openrouter/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": 0.54, + "output": 1.76, + "cache_read": 0.1 + }, + "limit": { + "context": 101376, + "output": 101376 } }, { @@ -104911,6 +116825,38 @@ "output": 131072 } }, + { + "id": "openrouter/~anthropic/claude-fable", + "name": "Claude Fable Latest", + "family": "claude-fable", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "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", "name": "Anthropic Claude Haiku Latest", @@ -104950,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": { @@ -104982,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": { @@ -104997,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, @@ -105097,9 +117043,9 @@ }, "open_weights": false, "cost": { - "input": 0.684, - "output": 3.42, - "cache_read": 0.144 + "input": 0.65, + "output": 3.41, + "cache_read": 0.14 }, "limit": { "context": 262144, @@ -105114,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": { @@ -105131,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, @@ -105170,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)", @@ -105277,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": { @@ -105596,8 +117574,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", @@ -105664,8 +117642,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", @@ -106019,7 +117997,7 @@ { "id": "orcarouter/kimi/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi-k2.5", + "family": "kimi-k2", "attachment": false, "reasoning": true, "tool_call": true, @@ -106051,7 +118029,7 @@ { "id": "orcarouter/kimi/kimi-k2.6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -106386,7 +118364,7 @@ }, { "id": "orcarouter/openai/gpt-4o", - "name": "GPT-4o (2024-05-13)", + "name": "GPT-4o", "family": "gpt", "attachment": true, "reasoning": false, @@ -106394,11 +118372,12 @@ "temperature": true, "knowledge": "2023-09", "release_date": "2024-05-13", - "last_updated": "2024-05-13", + "last_updated": "2024-08-06", "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -106406,12 +118385,13 @@ }, "open_weights": false, "cost": { - "input": 5.0, - "output": 15.0 + "input": 2.5, + "output": 10.0, + "cache_read": 1.25 }, "limit": { "context": 128000, - "output": 4096 + "output": 16384 } }, { @@ -107569,8 +119549,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-11", - "last_updated": "2026-02-11", + "release_date": "2026-02-12", + "last_updated": "2026-02-12", "modalities": { "input": [ "text" @@ -107599,8 +119579,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-03-27", - "last_updated": "2026-03-27", + "release_date": "2026-04-07", + "last_updated": "2026-04-07", "modalities": { "input": [ "text" @@ -107609,7 +119589,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 1.4, "output": 4.4, @@ -107673,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", @@ -108567,7 +120520,7 @@ } }, { - "id": "perplexity-agent/xai/grok-4.1-fast-non", + "id": "perplexity-agent/xai/grok-4.1-fast-non-reasoning", "name": "Grok 4.1 Fast (Non-Reasoning)", "family": "grok", "attachment": true, @@ -108976,6 +120929,7 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -110486,7 +122440,7 @@ { "id": "poe/novita/kimi-k2-thinking", "name": "kimi-k2-thinking", - "family": "kimi", + "family": "kimi-thinking", "attachment": true, "reasoning": true, "tool_call": true, @@ -110736,14 +122690,14 @@ }, { "id": "poe/openai/gpt-4-classic", - "name": "GPT-4-Classic-0314", + "name": "GPT-4-Classic", "family": "gpt", "attachment": true, "reasoning": false, "tool_call": true, "temperature": false, - "release_date": "2024-08-26", - "last_updated": "2024-08-26", + "release_date": "2024-03-25", + "last_updated": "2024-03-25", "modalities": { "input": [ "text", @@ -112391,37 +124345,7 @@ } }, { - "id": "poe/xai/grok-4-fast", - "name": "Grok-4-Fast-Reasoning", - "family": "grok", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": false, - "release_date": "2025-09-16", - "last_updated": "2025-09-16", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.2, - "output": 0.5, - "cache_read": 0.05 - }, - "limit": { - "context": 2000000, - "output": 128000 - } - }, - { - "id": "poe/xai/grok-4-fast-non", + "id": "poe/xai/grok-4-fast-non-reasoning", "name": "Grok-4-Fast-Non-Reasoning", "family": "grok", "attachment": true, @@ -112451,13 +124375,43 @@ } }, { - "id": "poe/xai/grok-4.1-fast", - "name": "Grok-4.1-Fast-Reasoning", + "id": "poe/xai/grok-4-fast-reasoning", + "name": "Grok-4-Fast-Reasoning", "family": "grok", "attachment": true, "reasoning": true, "tool_call": true, "temperature": false, + "release_date": "2025-09-16", + "last_updated": "2025-09-16", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.2, + "output": 0.5, + "cache_read": 0.05 + }, + "limit": { + "context": 2000000, + "output": 128000 + } + }, + { + "id": "poe/xai/grok-4.1-fast-non-reasoning", + "name": "Grok-4.1-Fast-Non-Reasoning", + "family": "grok", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": false, "release_date": "2025-11-19", "last_updated": "2025-11-19", "modalities": { @@ -112477,11 +124431,11 @@ } }, { - "id": "poe/xai/grok-4.1-fast-non", - "name": "Grok-4.1-Fast-Non-Reasoning", + "id": "poe/xai/grok-4.1-fast-reasoning", + "name": "Grok-4.1-Fast-Reasoning", "family": "grok", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": false, "release_date": "2025-11-19", @@ -112568,7 +124522,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-04-28", - "last_updated": "2026-04-28", + "last_updated": "2026-06-13", "modalities": { "input": [ "text" @@ -112585,8 +124539,8 @@ "cache_write": 0.0 }, "limit": { - "context": 131040, - "output": 8192 + "context": 262144, + "output": 32768 } }, { @@ -112597,7 +124551,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-04-28", - "last_updated": "2026-04-28", + "last_updated": "2026-06-13", "modalities": { "input": [ "text" @@ -112614,8 +124568,8 @@ "cache_write": 0.0 }, "limit": { - "context": 131040, - "output": 8192 + "context": 262144, + "output": 32768 } }, { @@ -114996,9 +126950,36 @@ } }, { - "id": "qiniu-ai/x-ai/grok-4-fast-non", + "id": "qiniu-ai/x-ai/grok-4-fast-non-reasoning", "name": "X-Ai/Grok-4-Fast-Non-Reasoning", "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2025-12-18", + "last_updated": "2025-12-18", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 2000000, + "output": 2000000 + } + }, + { + "id": "qiniu-ai/x-ai/grok-4-fast-reasoning", + "name": "X-Ai/Grok-4-Fast-Reasoning", + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, @@ -115047,9 +127028,36 @@ } }, { - "id": "qiniu-ai/x-ai/grok-4.1-fast-non", + "id": "qiniu-ai/x-ai/grok-4.1-fast-non-reasoning", "name": "X-Ai/Grok 4.1 Fast Non Reasoning", "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2025-12-19", + "last_updated": "2025-12-19", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 2000000, + "output": 2000000 + } + }, + { + "id": "qiniu-ai/x-ai/grok-4.1-fast-reasoning", + "name": "X-Ai/Grok 4.1 Fast Reasoning", + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, @@ -115069,7 +127077,7 @@ "open_weights": false, "cost": {}, "limit": { - "context": 2000000, + "context": 20000000, "output": 2000000 } }, @@ -115931,8 +127939,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", @@ -116808,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, @@ -116859,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, @@ -116919,26 +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", + "id": "routing-run/glm-5.2", + "name": "GLM-5.2", + "family": "glm", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-05", - "release_date": "2026-04-24", - "last_updated": "2026-04-24", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "modalities": { "input": [ "text" @@ -116949,25 +128955,53 @@ }, "open_weights": true, "cost": { - "input": 0.4928, - "output": 0.7392, - "cache_read": 0.003625 + "input": 0.8, + "output": 2.4 }, "limit": { - "context": 1000000, - "output": 131072 + "context": 200000, + "output": 32000 } }, { - "id": "routing-run/route/gemma-4-31b-it", - "name": "Gemma 4 31B IT", - "family": "gemma", - "attachment": true, - "reasoning": false, + "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-02", - "last_updated": "2026-04-02", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.8, + "output": 2.4 + }, + "limit": { + "context": 200000, + "output": 32000 + } + }, + { + "id": "routing-run/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", @@ -116980,85 +129014,56 @@ }, "open_weights": true, "cost": { - "input": 0.1, - "output": 0.3 + "input": 0.275, + "output": 1.1 }, "limit": { - "context": 131072, - "output": 65536 + "context": 200000, + "output": 32000 } }, { - "id": "routing-run/route/glm-5.1", - "name": "GLM 5.1", - "family": "glm", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-03-27", - "last_updated": "2026-03-27", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.0, - "output": 3.0, - "cache_read": 0.26, - "cache_write": 0.0 - }, - "limit": { - "context": 202752, - "output": 65536 - } - }, - { - "id": "routing-run/route/glm-5.1-6bit", - "name": "GLM 5.1 6bit", - "family": "glm", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-03-27", - "last_updated": "2026-03-27", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.0, - "output": 3.0, - "cache_read": 0.26, - "cache_write": 0.0 - }, - "limit": { - "context": 202752, - "output": 65536 - } - }, - { - "id": "routing-run/route/kimi-k2.5", - "name": "Kimi K2.5", - "family": "kimi-k2.5", + "id": "routing-run/kimi-k2.6-nitro", + "name": "Kimi K2.6 Nitro", + "family": "kimi-k2", "attachment": true, - "reasoning": false, + "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.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-01", - "last_updated": "2026-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", "modalities": { "input": [ "text", @@ -117071,26 +129076,25 @@ }, "open_weights": true, "cost": { - "input": 0.462, - "output": 2.42, - "cache_read": 0.1 + "input": 0.275, + "output": 1.1 }, "limit": { - "context": 131072, - "output": 32768 + "context": 200000, + "output": 32000 } }, { - "id": "routing-run/route/kimi-k2.6", - "name": "Kimi K2.6", - "family": "kimi-k2.6", + "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": 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", @@ -117103,420 +129107,24 @@ }, "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", - "family": "kimi-k2.6", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-01", - "release_date": "2026-04-21", - "last_updated": "2026-04-21", - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.462, - "output": 2.42, - "cache_read": 0.16 - }, - "limit": { - "context": 262144, - "output": 262144 - } - }, - { - "id": "routing-run/route/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", - "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", - "name": "MiMo V2.5 Pro", - "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/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", + "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-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" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.4, - "output": 2.0 - }, - "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 2603", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-01", - "release_date": "2026-04-02", - "last_updated": "2026-04-02", + "release_date": "2026-06-04", + "last_updated": "2026-06-04", "modalities": { "input": [ "text" @@ -117528,24 +129136,23 @@ "open_weights": true, "cost": { "input": 0.1, - "output": 0.3, - "cache_read": 0.02 + "output": 0.1 }, "limit": { - "context": 262144, - "output": 65536 + "context": 131072, + "output": 32000 } }, { - "id": "routing-run/route/stepfun-3.5-flash", - "name": "StepFun 3.5 Flash", + "id": "routing-run/qwen3.5-9b", + "name": "Qwen3.5 9B", + "family": "qwen", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-01", - "release_date": "2026-01-29", - "last_updated": "2026-02-13", + "release_date": "2026-02-23", + "last_updated": "2026-02-23", "modalities": { "input": [ "text" @@ -117556,13 +129163,68 @@ }, "open_weights": true, "cost": { - "input": 0.096, - "output": 0.288, - "cache_read": 0.019 + "input": 0.16, + "output": 0.48 }, "limit": { "context": 262144, - "output": 65536 + "output": 32000 + } + }, + { + "id": "sakana/fugu", + "name": "Fugu", + "family": "fugu", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "release_date": "2026-06-15", + "last_updated": "2026-06-15", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1000000, + "output": 1000000 + } + }, + { + "id": "sakana/fugu-ultra", + "name": "Fugu Ultra", + "family": "fugu", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "release_date": "2026-06-15", + "last_updated": "2026-06-15", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 30.0, + "cache_read": 0.5 + }, + "limit": { + "context": 1000000, + "output": 1000000 } }, { @@ -117994,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", @@ -118096,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", @@ -118262,8 +130025,8 @@ "tool_call": true, "temperature": false, "knowledge": "2025-08-31", - "release_date": "2026-04-27", - "last_updated": "2026-04-27", + "release_date": "2026-03-05", + "last_updated": "2026-03-05", "modalities": { "input": [ "text", @@ -118285,6 +130048,38 @@ "output": 128000 } }, + { + "id": "sap-ai-core/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": "sap-ai-core/sonar", "name": "sonar", @@ -118540,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", @@ -118919,62 +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", - "name": "PaddlePaddle/PaddleOCR-VL", - "attachment": true, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-10-16", - "last_updated": "2025-10-16", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 16384, - "output": 16384 - } - }, { "id": "siliconflow-cn/PaddlePaddle/PaddleOCR-VL-1.5", "name": "PaddlePaddle/PaddleOCR-VL-1.5", @@ -119003,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", @@ -119171,62 +130910,6 @@ "output": 164000 } }, - { - "id": "siliconflow-cn/Pro/moonshotai/Kimi-K2-Instruct", - "name": "Pro/moonshotai/Kimi-K2-Instruct-0905", - "family": "kimi", - "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", @@ -119286,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", @@ -119371,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", @@ -119483,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", @@ -119539,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", @@ -119653,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", @@ -119737,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", @@ -119798,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", @@ -119877,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", @@ -120224,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", @@ -120444,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", @@ -120470,146 +131698,6 @@ "output": 65536 } }, - { - "id": "siliconflow-cn/THUDM/GLM-4-32B", - "name": "THUDM/GLM-4-32B-0414", - "family": "glm", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-04-18", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.27, - "output": 0.27 - }, - "limit": { - "context": 33000, - "output": 33000 - } - }, - { - "id": "siliconflow-cn/THUDM/GLM-4-9B", - "name": "THUDM/GLM-4-9B-0414", - "family": "glm", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-04-18", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.086, - "output": 0.086 - }, - "limit": { - "context": 33000, - "output": 33000 - } - }, - { - "id": "siliconflow-cn/THUDM/GLM-Z1-32B", - "name": "THUDM/GLM-Z1-32B-0414", - "family": "glm-z", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-04-18", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.14, - "output": 0.57 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, - { - "id": "siliconflow-cn/THUDM/GLM-Z1-9B", - "name": "THUDM/GLM-Z1-9B-0414", - "family": "glm-z", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-04-18", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.086, - "output": 0.086 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, - { - "id": "siliconflow-cn/ascend-tribe/pangu-pro-moe", - "name": "ascend-tribe/pangu-pro-moe", - "family": "pangu", - "attachment": false, - "reasoning": true, - "tool_call": false, - "temperature": true, - "release_date": "2025-07-02", - "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/baidu/ERNIE-4.5-300B-A47B", "name": "baidu/ERNIE-4.5-300B-A47B", @@ -120694,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", @@ -120834,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", @@ -120863,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", @@ -120920,118 +131953,6 @@ "output": 131000 } }, - { - "id": "siliconflow-cn/inclusionAI/Ling-mini-2.0", - "name": "inclusionAI/Ling-mini-2.0", - "family": "ling", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-09-10", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.07, - "output": 0.28 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, - { - "id": "siliconflow-cn/inclusionAI/Ring-flash-2.0", - "name": "inclusionAI/Ring-flash-2.0", - "family": "ring", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-09-29", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.14, - "output": 0.57 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, - { - "id": "siliconflow-cn/moonshotai/Kimi-K2-Instruct", - "name": "moonshotai/Kimi-K2-Instruct-0905", - "family": "kimi", - "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", @@ -121065,7 +131986,7 @@ "name": "tencent/Hunyuan-A13B-Instruct", "family": "hunyuan", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2025-06-30", @@ -121088,34 +132009,6 @@ "output": 131000 } }, - { - "id": "siliconflow-cn/tencent/Hunyuan-MT-7B", - "name": "tencent/Hunyuan-MT-7B", - "family": "hunyuan", - "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.0, - "output": 0.0 - }, - "limit": { - "context": 33000, - "output": 33000 - } - }, { "id": "siliconflow-cn/zai-org/GLM-4.5-Air", "name": "zai-org/GLM-4.5-Air", @@ -121145,89 +132038,32 @@ } }, { - "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", + "id": "siliconflow-cn/zai-org/GLM-5.2", + "name": "GLM-5.2", "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", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { - "input": 0.3, - "output": 0.9 + "input": 1.4, + "output": 4.4, + "cache_write": 0.0 }, "limit": { - "context": 131000, - "output": 131000 + "context": 1049000, + "output": 262000 } }, { @@ -121258,34 +132094,6 @@ "output": 262000 } }, - { - "id": "siliconflow/MiniMaxAI/MiniMax-M2.1", - "name": "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/MiniMaxAI/MiniMax-M2.5", "name": "MiniMaxAI/MiniMax-M2.5", @@ -121295,7 +132103,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-02-15", - "last_updated": "2026-02-15", + "last_updated": "2026-06-15", "modalities": { "input": [ "text" @@ -121314,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", @@ -121426,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", @@ -121482,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", @@ -121625,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", @@ -121737,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", @@ -121798,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", @@ -121877,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", @@ -122225,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" @@ -122245,24 +132597,52 @@ }, "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/THUDM/GLM-4-32B", - "name": "THUDM/GLM-4-32B-0414", - "family": "glm", + "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": "2025-04-18", - "last_updated": "2025-11-25", + "release_date": "2026-02-23", + "last_updated": "2026-02-23", "modalities": { "input": [ "text" @@ -122273,24 +132653,24 @@ }, "open_weights": false, "cost": { - "input": 0.27, - "output": 0.27 + "input": 0.24, + "output": 1.8 }, "limit": { - "context": 33000, - "output": 33000 + "context": 262144, + "output": 262144 } }, { - "id": "siliconflow/THUDM/GLM-4-9B", - "name": "THUDM/GLM-4-9B-0414", - "family": "glm", + "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": "2025-04-18", - "last_updated": "2025-11-25", + "release_date": "2026-02-15", + "last_updated": "2026-02-15", "modalities": { "input": [ "text" @@ -122301,24 +132681,24 @@ }, "open_weights": false, "cost": { - "input": 0.086, - "output": 0.086 + "input": 0.39, + "output": 2.34 }, "limit": { - "context": 33000, - "output": 33000 + "context": 262144, + "output": 262144 } }, { - "id": "siliconflow/THUDM/GLM-Z1-32B", - "name": "THUDM/GLM-Z1-32B-0414", - "family": "glm-z", + "id": "siliconflow/Qwen/Qwen3.5-9B", + "name": "Qwen/Qwen3.5-9B", + "family": "qwen", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-04-18", - "last_updated": "2025-11-25", + "release_date": "2026-03-03", + "last_updated": "2026-04-24", "modalities": { "input": [ "text" @@ -122329,24 +132709,24 @@ }, "open_weights": false, "cost": { - "input": 0.14, - "output": 0.57 + "input": 0.1, + "output": 0.15 }, "limit": { - "context": 131000, - "output": 131000 + "context": 262144, + "output": 262144 } }, { - "id": "siliconflow/THUDM/GLM-Z1-9B", - "name": "THUDM/GLM-Z1-9B-0414", - "family": "glm-z", + "id": "siliconflow/Qwen/Qwen3.6-27B", + "name": "Qwen3.6 27B", + "family": "qwen", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-04-18", - "last_updated": "2025-11-25", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", "modalities": { "input": [ "text" @@ -122357,12 +132737,40 @@ }, "open_weights": false, "cost": { - "input": 0.086, - "output": 0.086 + "input": 0.3, + "output": 3.2 }, "limit": { - "context": 131000, - "output": 131000 + "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 } }, { @@ -122421,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", @@ -122618,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, @@ -122648,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, @@ -122678,19 +133030,18 @@ } }, { - "id": "siliconflow/deepseek-ai/deepseek-vl2", - "name": "deepseek-ai/deepseek-vl2", - "family": "deepseek", - "attachment": true, + "id": "siliconflow/google/gemma-4-26B-A4B-it", + "name": "Gemma 4 26B A4B IT", + "family": "gemma", + "attachment": false, "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2024-12-13", - "last_updated": "2025-11-25", + "release_date": "2026-04-02", + "last_updated": "2026-04-02", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" @@ -122698,12 +133049,40 @@ }, "open_weights": false, "cost": { - "input": 0.15, - "output": 0.15 + "input": 0.12, + "output": 0.4 }, "limit": { - "context": 4000, - "output": 4000 + "context": 262144, + "output": 262144 + } + }, + { + "id": "siliconflow/google/gemma-4-31B-it", + "name": "Gemma 4 31B IT", + "family": "gemma", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.13, + "output": 0.4 + }, + "limit": { + "context": 262144, + "output": 262144 } }, { @@ -122734,146 +133113,6 @@ "output": 131000 } }, - { - "id": "siliconflow/inclusionAI/Ling-mini-2.0", - "name": "inclusionAI/Ling-mini-2.0", - "family": "ling", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-09-10", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.07, - "output": 0.28 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, - { - "id": "siliconflow/inclusionAI/Ring-flash-2.0", - "name": "inclusionAI/Ring-flash-2.0", - "family": "ring", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-09-29", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.14, - "output": 0.57 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, - { - "id": "siliconflow/meta-llama/Meta-Llama-3.1-8B-Instruct", - "name": "meta-llama/Meta-Llama-3.1-8B-Instruct", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-04-23", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.06, - "output": 0.06 - }, - "limit": { - "context": 33000, - "output": 4000 - } - }, - { - "id": "siliconflow/moonshotai/Kimi-K2-Instruct", - "name": "moonshotai/Kimi-K2-Instruct", - "family": "kimi", - "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", @@ -122912,7 +133151,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-04-21", - "last_updated": "2026-04-21", + "last_updated": "2026-06-15", "modalities": { "input": [ "text", @@ -122924,43 +133163,15 @@ }, "open_weights": true, "cost": { - "input": 0.95, + "input": 0.77, "output": 4.0, - "cache_read": 0.16 + "cache_read": 0.2 }, "limit": { "context": 262000, "output": 262000 } }, - { - "id": "siliconflow/nex-agi/DeepSeek-V3.1-Nex-N1", - "name": "nex-agi/DeepSeek-V3.1-Nex-N1", - "family": "deepseek", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-01-01", - "last_updated": "2025-11-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.5, - "output": 2.0 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, { "id": "siliconflow/openai/gpt-oss-120b", "name": "openai/gpt-oss-120b", @@ -123050,7 +133261,7 @@ "name": "tencent/Hunyuan-A13B-Instruct", "family": "hunyuan", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2025-06-30", @@ -123074,15 +133285,15 @@ } }, { - "id": "siliconflow/tencent/Hunyuan-MT-7B", - "name": "tencent/Hunyuan-MT-7B", - "family": "hunyuan", + "id": "siliconflow/tencent/Hy3-preview", + "name": "Hy3 preview", + "family": "Hy", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-09-18", - "last_updated": "2025-11-25", + "release_date": "2026-04-20", + "last_updated": "2026-04-20", "modalities": { "input": [ "text" @@ -123093,40 +133304,13 @@ }, "open_weights": false, "cost": { - "input": 0.0, - "output": 0.0 + "input": 0.066, + "output": 0.26, + "cache_read": 0.029 }, "limit": { - "context": 33000, - "output": 33000 - } - }, - { - "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 + "context": 262144, + "output": 262144 } }, { @@ -123157,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", @@ -123280,7 +133350,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-02-12", - "last_updated": "2026-02-12", + "last_updated": "2026-06-15", "modalities": { "input": [ "text" @@ -123291,8 +133361,8 @@ }, "open_weights": true, "cost": { - "input": 1.0, - "output": 3.2 + "input": 0.95, + "output": 2.55 }, "limit": { "context": 205000, @@ -123328,6 +133398,35 @@ "output": 205000 } }, + { + "id": "siliconflow/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_write": 0.0 + }, + "limit": { + "context": 1049000, + "output": 262000 + } + }, { "id": "siliconflow/zai-org/GLM-5V-Turbo", "name": "zai-org/GLM-5V-Turbo", @@ -123358,6 +133457,34 @@ "output": 131072 } }, + { + "id": "snowflake-cortex/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": {}, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "snowflake-cortex/claude-haiku-4.5", "name": "Claude Haiku 4.5 (latest)", @@ -123414,6 +133541,34 @@ "output": 128000 } }, + { + "id": "snowflake-cortex/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": {}, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "snowflake-cortex/claude-sonnet-4.5", "name": "Claude Sonnet 4.5 (latest)", @@ -123470,6 +133625,89 @@ "output": 16384 } }, + { + "id": "snowflake-cortex/deepseek-r1", + "name": "DeepSeek-R1", + "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": {}, + "limit": { + "context": 128000, + "output": 32768 + } + }, + { + "id": "snowflake-cortex/gemini-3.1-pro", + "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", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "snowflake-cortex/mistral-large2", + "name": "Mistral Large (latest)", + "family": "mistral-large", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2024-11", + "release_date": "2024-11-01", + "last_updated": "2025-12-02", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": {}, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "snowflake-cortex/openai-gpt-4.1", "name": "GPT-4.1", @@ -123661,6 +133899,60 @@ "output": 128000 } }, + { + "id": "snowflake-cortex/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": {}, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "snowflake-cortex/snowflake-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": {}, + "limit": { + "context": 128000, + "output": 4096 + } + }, { "id": "stackit/Qwen/Qwen3-VL-235B-A22B-Instruct-FP8", "name": "Qwen3-VL 235B", @@ -123682,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 } }, { @@ -123719,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", @@ -123739,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 } }, { @@ -123768,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 } }, { @@ -123804,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, @@ -123880,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", @@ -123897,7 +134247,7 @@ "temperature": true, "knowledge": "2025-01", "release_date": "2026-01-29", - "last_updated": "2026-02-13", + "last_updated": "2026-06-15", "modalities": { "input": [ "text" @@ -123908,15 +134258,120 @@ }, "open_weights": true, "cost": { - "input": 0.096, - "output": 0.288, - "cache_read": 0.019 + "input": 0.1, + "output": 0.3, + "cache_read": 0.02 }, "limit": { "context": 256000, "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)", @@ -123977,14 +134432,14 @@ }, { "id": "stepfun/step-3.5-flash", - "name": "Step 3.5 Flash 2603", + "name": "Step 3.5 Flash", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2026-04-02", - "last_updated": "2026-04-02", + "release_date": "2026-01-29", + "last_updated": "2026-06-15", "modalities": { "input": [ "text" @@ -124004,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", @@ -124257,216 +134874,15 @@ } }, { - "id": "synthetic/hf:MiniMaxAI/MiniMax-M2", - "name": "MiniMax-M2", + "id": "synthetic/hf:MiniMaxAI/MiniMax-M3", + "name": "MiniMax-M3", "family": "minimax", - "attachment": false, + "attachment": true, "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:Qwen/Qwen2.5-Coder-32B-Instruct", - "name": "Qwen2.5-Coder-32B-Instruct", - "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, - "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-06-12", + "last_updated": "2026-06-12", "modalities": { "input": [ "text", @@ -124479,402 +134895,24 @@ "open_weights": true, "cost": { "input": 0.6, - "output": 3.0, + "output": 1.2, "cache_read": 0.6 }, "limit": { - "context": 262144, + "context": 524288, "output": 65536 } }, { - "id": "synthetic/hf:deepseek-ai/DeepSeek-R1", - "name": "DeepSeek R1 (0528)", - "family": "deepseek-thinking", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-08-01", - "last_updated": "2025-08-01", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 3.0, - "output": 8.0 - }, - "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", + "id": "synthetic/hf:Qwen/Qwen3.6-27B", + "name": "Qwen3.6 27B", + "family": "qwen", "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", - "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", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-01", - "release_date": "2026-01", - "last_updated": "2026-01", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", "modalities": { "input": [ "text", @@ -124886,8 +134924,9 @@ }, "open_weights": true, "cost": { - "input": 0.55, - "output": 2.19 + "input": 0.45, + "output": 3.6, + "cache_read": 0.45 }, "limit": { "context": 262144, @@ -124895,16 +134934,16 @@ } }, { - "id": "synthetic/hf:moonshotai/Kimi-K2.6", - "name": "Kimi K2.6", - "family": "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", @@ -124925,47 +134964,16 @@ "output": 65536 } }, - { - "id": "synthetic/hf:nvidia/Kimi-K2.5-NVFP4", - "name": "Kimi K2.5 (NVFP4)", - "family": "kimi", - "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" @@ -125006,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" @@ -125091,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, @@ -125101,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" @@ -125120,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 } }, @@ -125311,7 +135234,7 @@ { "id": "tencent-coding-plan/kimi-k2.5", "name": "Kimi-K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -125401,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", @@ -125439,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", @@ -125464,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" @@ -125476,8 +135459,8 @@ "open_weights": false, "cost": {}, "limit": { - "context": 128000, - "output": 64000 + "context": 196608, + "output": 30000 } }, { @@ -125488,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" @@ -125512,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", @@ -125537,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" @@ -125549,8 +135532,8 @@ "open_weights": false, "cost": {}, "limit": { - "context": 128000, - "output": 64000 + "context": 196608, + "output": 30000 } }, { @@ -125561,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" @@ -125585,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", @@ -125610,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" @@ -125622,7 +135605,7 @@ "open_weights": false, "cost": {}, "limit": { - "context": 128000, + "context": 196608, "output": 30000 } }, @@ -125634,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" @@ -125650,6 +135633,236 @@ "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", + "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": "togetherai/MiniMaxAI/MiniMax-M2.5", "name": "MiniMax-M2.5", @@ -125708,12 +135921,70 @@ "output": 131072 } }, + { + "id": "togetherai/MiniMaxAI/MiniMax-M3", + "name": "MiniMax-M3", + "family": "minimax", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 + }, + "limit": { + "context": 524288, + "output": 250000 + } + }, + { + "id": "togetherai/Qwen/Qwen2.5-7B-Instruct-Turbo", + "name": "Qwen 2.5 7B Instruct Turbo", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2024-09-19", + "last_updated": "2024-09-19", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 0.3 + }, + "limit": { + "context": 32768, + "output": 32768 + } + }, { "id": "togetherai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput", "name": "Qwen3 235B A22B Instruct 2507 FP8", "family": "qwen", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2025-07", @@ -125771,7 +136042,7 @@ "name": "Qwen3 Coder Next FP8", "family": "qwen", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2026-02-03", @@ -125804,7 +136075,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-02-16", - "last_updated": "2026-02-16", + "last_updated": "2026-06-15", "modalities": { "input": [ "text", @@ -125824,6 +136095,35 @@ "output": 130000 } }, + { + "id": "togetherai/Qwen/Qwen3.5-9B", + "name": "Qwen3.5 9B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-03", + "last_updated": "2026-03-03", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.17, + "output": 0.25 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, { "id": "togetherai/Qwen/Qwen3.6-Plus", "name": "Qwen3.6 Plus", @@ -125857,11 +136157,11 @@ "name": "Qwen3.7 Max", "family": "qwen", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "release_date": "2026-05-21", - "last_updated": "2026-05-21", + "last_updated": "2026-07-02", "modalities": { "input": [ "text" @@ -125872,24 +136172,52 @@ }, "open_weights": false, "cost": { - "input": 2.5, - "output": 7.5 + "input": 1.25, + "output": 3.75 }, "limit": { "context": 1000000, "output": 500000 } }, + { + "id": "togetherai/deepcogito/cogito-v2-1-671b", + "name": "Cogito v2.1 671B", + "family": "cogito", + "attachment": false, + "reasoning": true, + "tool_call": false, + "temperature": true, + "release_date": "2025-11-13", + "last_updated": "2025-11-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 1.25 + }, + "limit": { + "context": 163840, + "output": 163840 + } + }, { "id": "togetherai/deepseek-ai/DeepSeek-R1", - "name": "DeepSeek R1", + "name": "DeepSeek-R1", "family": "deepseek-thinking", "attachment": false, "reasoning": true, "tool_call": false, "temperature": true, "knowledge": "2024-07", - "release_date": "2024-12-26", + "release_date": "2025-01-20", "last_updated": "2025-03-24", "modalities": { "input": [ @@ -125911,14 +136239,14 @@ }, { "id": "togetherai/deepseek-ai/DeepSeek-V3", - "name": "DeepSeek V3", + "name": "DeepSeek-V3", "family": "deepseek", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2024-07", - "release_date": "2025-01-20", + "release_date": "2024-12-26", "last_updated": "2025-05-29", "modalities": { "input": [ @@ -125987,8 +136315,8 @@ }, "open_weights": true, "cost": { - "input": 2.1, - "output": 4.4, + "input": 1.74, + "output": 3.48, "cache_read": 0.2 }, "limit": { @@ -126025,6 +136353,34 @@ "output": 32768 } }, + { + "id": "togetherai/google/gemma-3n-E4B-it", + "name": "Gemma 3N E4B Instruct", + "family": "gemma", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-05-20", + "last_updated": "2025-05-20", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.06, + "output": 0.12 + }, + "limit": { + "context": 32768, + "output": 32768 + } + }, { "id": "togetherai/google/gemma-4-31B-it", "name": "Gemma 4 31B Instruct", @@ -126047,8 +136403,8 @@ }, "open_weights": true, "cost": { - "input": 0.2, - "output": 0.5 + "input": 0.39, + "output": 0.97 }, "limit": { "context": 262144, @@ -126065,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" @@ -126076,18 +136432,46 @@ }, "open_weights": true, "cost": { - "input": 0.88, - "output": 0.88 + "input": 1.04, + "output": 1.04 }, "limit": { "context": 131072, "output": 131072 } }, + { + "id": "togetherai/meta-llama/Meta-Llama-3-8B-Instruct-Lite", + "name": "Meta Llama 3 8B Instruct Lite", + "family": "llama", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2024-04-18", + "last_updated": "2024-04-18", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.14, + "output": 0.14 + }, + "limit": { + "context": 8192, + "output": 8192 + } + }, { "id": "togetherai/moonshotai/Kimi-K2.5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": true, "tool_call": true, @@ -126117,7 +136501,7 @@ { "id": "togetherai/moonshotai/Kimi-K2.6", "name": "Kimi K2.6", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -126146,6 +136530,64 @@ "output": 131000 } }, + { + "id": "togetherai/moonshotai/Kimi-K2.7-Code", + "name": "Kimi K2.7 Code", + "family": "kimi-k2", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-14", + "last_updated": "2026-06-14", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.19 + }, + "limit": { + "context": 262144, + "output": 131072 + } + }, + { + "id": "togetherai/nvidia/nemotron-3-ultra-550b-a55b", + "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.6, + "output": 3.6, + "cache_read": 0.2 + }, + "limit": { + "context": 512300, + "output": 512300 + } + }, { "id": "togetherai/openai/gpt-oss-120b", "name": "GPT OSS 120B", @@ -126175,6 +136617,90 @@ "output": 131072 } }, + { + "id": "togetherai/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.05, + "output": 0.2 + }, + "limit": { + "context": 131072, + "output": 131072 + } + }, + { + "id": "togetherai/pearl-ai/gemma-4-31b-it", + "name": "Pearl AI Gemma 4 31B Instruct", + "family": "gemma", + "attachment": false, + "reasoning": true, + "tool_call": false, + "temperature": true, + "release_date": "2026-04-07", + "last_updated": "2026-04-07", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.28, + "output": 0.86 + }, + "limit": { + "context": 32000, + "output": 32000 + } + }, + { + "id": "togetherai/zai-org/GLM-5", + "name": "GLM-5", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-11", + "last_updated": "2026-02-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.0, + "output": 3.2 + }, + "limit": { + "context": 202752, + "output": 131072 + } + }, { "id": "togetherai/zai-org/GLM-5.1", "name": "GLM-5.1", @@ -126185,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" @@ -126204,17 +136730,228 @@ "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", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": 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", @@ -126275,17 +137012,18 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-03-27", - "last_updated": "2026-03-27", + "release_date": "2026-04-07", + "last_updated": "2026-04-07", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.0, "output": 0.0, @@ -126298,20 +137036,52 @@ } }, { - "id": "umans-ai-coding-plan/umans-kimi-k2.6", - "name": "Kimi K2.6", - "family": "kimi-k2.6", + "id": "umans-ai-coding-plan/umans-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", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 405504, + "output": 131072 + } + }, + { + "id": "umans-ai-coding-plan/umans-kimi-k2.7", + "name": "Kimi K2.7 Code", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": 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", - "image" + "image", + "video" ], "output": [ "text" @@ -126360,6 +137130,159 @@ "output": 262144 } }, + { + "id": "umans-ai/umans-coder", + "name": "Umans Coder", + "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": 262144, + "output": 32768 + } + }, + { + "id": "umans-ai/umans-flash", + "name": "Umans Flash", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "release_date": "2026-04-17", + "last_updated": "2026-04-17", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.15, + "output": 1.0, + "cache_read": 0.05 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, + { + "id": "umans-ai/umans-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", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.29 + }, + "limit": { + "context": 204800, + "output": 131072 + } + }, + { + "id": "umans-ai/umans-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", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.26 + }, + "limit": { + "context": 405504, + "output": 131072 + } + }, + { + "id": "umans-ai/umans-kimi-k2.7", + "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": 32768 + } + }, { "id": "upstage/solar-mini", "name": "solar-mini", @@ -126537,13 +137460,11 @@ { "id": "venice/aion-labs-aion-2.0", "name": "Aion 2.0", - "family": "o", "attachment": false, "reasoning": true, "tool_call": false, - "temperature": true, "release_date": "2026-03-24", - "last_updated": "2026-04-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -126564,15 +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, - "temperature": true, - "release_date": "2026-04-02", - "last_updated": "2026-04-04", + "release_date": "2026-07-08", + "last_updated": "2026-07-08", "modalities": { "input": [ "text" @@ -126581,15 +137500,74 @@ "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 + } + }, + { + "id": "venice/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-10", + "last_updated": "2026-06-11", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 12.0, + "output": 60.0, + "cache_read": 1.2, + "cache_write": 15.0 + }, + "limit": { + "context": 1000000, + "output": 128000 } }, { @@ -126600,8 +137578,9 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-05", "release_date": "2025-12-06", - "last_updated": "2026-04-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -126633,7 +137612,7 @@ "temperature": true, "knowledge": "2025-05-31", "release_date": "2026-02-05", - "last_updated": "2026-03-16", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -126655,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-04-08", - "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", @@ -126695,8 +137642,9 @@ "reasoning": true, "tool_call": true, "temperature": false, + "knowledge": "2026-01-31", "release_date": "2026-04-16", - "last_updated": "2026-04-16", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -126725,9 +137673,10 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, + "knowledge": "2026-01-31", "release_date": "2026-05-14", - "last_updated": "2026-05-14", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -126756,9 +137705,10 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", - "last_updated": "2026-05-28", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -126787,9 +137737,10 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, + "knowledge": "2026-01", "release_date": "2026-05-28", - "last_updated": "2026-05-28", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -126819,8 +137770,9 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-07-31", "release_date": "2025-01-15", - "last_updated": "2026-04-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -126852,7 +137804,7 @@ "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-17", - "last_updated": "2026-03-16", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -126874,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", @@ -126881,10 +137865,8 @@ "attachment": false, "reasoning": true, "tool_call": true, - "temperature": true, - "knowledge": "2025-10", "release_date": "2025-12-04", - "last_updated": "2026-03-24", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -126907,13 +137889,14 @@ { "id": "venice/deepseek-v4-flash", "name": "DeepSeek V4 Flash", - "family": "deepseek-flash", + "family": "deepseek", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-05", "release_date": "2026-04-24", - "last_updated": "2026-04-29", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -126924,8 +137907,8 @@ }, "open_weights": true, "cost": { - "input": 0.17, - "output": 0.35, + "input": 0.138, + "output": 0.275, "cache_read": 0.028 }, "limit": { @@ -126941,8 +137924,9 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-05", "release_date": "2026-04-24", - "last_updated": "2026-04-29", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -126953,8 +137937,8 @@ }, "open_weights": true, "cost": { - "input": 1.73, - "output": 3.796, + "input": 1.65, + "output": 3.301, "cache_read": 0.33 }, "limit": { @@ -126965,20 +137949,20 @@ { "id": "venice/gemini-3-flash-preview", "name": "Gemini 3 Flash Preview", - "family": "gemini-flash", + "family": "gemini", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, "knowledge": "2025-01", "release_date": "2025-12-19", - "last_updated": "2026-03-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", "image", - "audio", "video", + "audio", "pdf" ], "output": [ @@ -126999,13 +137983,14 @@ { "id": "venice/gemini-3.1-pro-preview", "name": "Gemini 3.1 Pro Preview", - "family": "gemini-pro", + "family": "gemini", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-01", "release_date": "2026-02-19", - "last_updated": "2026-03-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127032,13 +138017,14 @@ { "id": "venice/gemini-3.5-flash", "name": "Gemini 3.5 Flash", - "family": "gemini-flash", + "family": "gemini", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-01", "release_date": "2026-05-22", - "last_updated": "2026-05-25", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127069,9 +138055,8 @@ "attachment": true, "reasoning": false, "tool_call": true, - "temperature": true, "release_date": "2026-04-13", - "last_updated": "2026-04-19", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127098,10 +138083,8 @@ "attachment": true, "reasoning": false, "tool_call": true, - "temperature": true, - "knowledge": "2025-07", "release_date": "2025-11-04", - "last_updated": "2026-03-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127130,7 +138113,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-04-02", - "last_updated": "2026-04-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127143,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, @@ -127160,7 +138144,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-04-03", - "last_updated": "2026-05-25", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127173,8 +138157,9 @@ }, "open_weights": true, "cost": { - "input": 0.155, - "output": 0.44 + "input": 0.12, + "output": 0.36, + "cache_read": 0.09 }, "limit": { "context": 256000, @@ -127188,9 +138173,8 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, "release_date": "2026-03-12", - "last_updated": "2026-05-07", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127218,9 +138202,8 @@ "attachment": true, "reasoning": true, "tool_call": false, - "temperature": true, "release_date": "2026-03-12", - "last_updated": "2026-05-07", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127250,7 +138233,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-04-18", - "last_updated": "2026-05-04", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127271,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", @@ -127280,7 +138293,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-05-21", - "last_updated": "2026-05-22", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127308,10 +138321,8 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": true, - "knowledge": "2024-04", "release_date": "2025-09-25", - "last_updated": "2026-03-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -127333,14 +138344,14 @@ { "id": "venice/kimi-k2-5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, "knowledge": "2024-04", "release_date": "2026-01-27", - "last_updated": "2026-04-30", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127364,13 +138375,14 @@ { "id": "venice/kimi-k2-6", "name": "Kimi K2.6", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-01", "release_date": "2026-04-20", - "last_updated": "2026-04-30", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127382,9 +138394,40 @@ }, "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, + "output": 65536 + } + }, + { + "id": "venice/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-13", + "last_updated": "2026-06-16", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.75, + "output": 3.5, + "cache_read": 0.16 }, "limit": { "context": 256000, @@ -127398,10 +138441,8 @@ "attachment": false, "reasoning": false, "tool_call": true, - "temperature": true, - "knowledge": "2023-12", "release_date": "2024-10-03", - "last_updated": "2026-03-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -127427,10 +138468,8 @@ "attachment": false, "reasoning": false, "tool_call": true, - "temperature": true, - "knowledge": "2023-12", "release_date": "2025-04-06", - "last_updated": "2026-03-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -127456,9 +138495,8 @@ "attachment": false, "reasoning": true, "tool_call": true, - "temperature": true, "release_date": "2026-02-20", - "last_updated": "2026-04-09", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -127487,7 +138525,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-02-12", - "last_updated": "2026-04-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -127496,11 +138534,11 @@ "text" ] }, - "open_weights": false, + "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, @@ -127516,7 +138554,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-03-18", - "last_updated": "2026-04-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -127525,11 +138563,11 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.375, "output": 1.5, - "cache_read": 0.075 + "cache_read": 0.06875 }, "limit": { "context": 198000, @@ -127537,33 +138575,33 @@ } }, { - "id": "venice/minimax-m3", - "name": "MiniMax M3", + "id": "venice/minimax-m3-preview", + "name": "MiniMax M3 Preview", "family": "minimax-m3", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, - "release_date": "2026-06-01", - "last_updated": "2026-06-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-13", "modalities": { "input": [ "text", - "image" + "image", + "video" ], "output": [ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.3, "output": 1.2, "cache_read": 0.06 }, "limit": { - "context": 500000, - "output": 32768 + "context": 524288, + "output": 65536 } }, { @@ -127574,8 +138612,9 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-06", "release_date": "2026-03-16", - "last_updated": "2026-04-09", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127602,9 +138641,8 @@ "attachment": false, "reasoning": false, "tool_call": true, - "temperature": true, "release_date": "2026-01-15", - "last_updated": "2026-03-16", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -127632,7 +138670,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-01-27", - "last_updated": "2026-03-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -127651,6 +138689,35 @@ "output": 16384 } }, + { + "id": "venice/nvidia-nemotron-3-ultra-550b-a55b", + "name": "NVIDIA Nemotron 3 Ultra", + "family": "nemotron", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-04", + "last_updated": "2026-06-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.625, + "output": 3.125, + "cache_read": 0.1875 + }, + "limit": { + "context": 256000, + "output": 32768 + } + }, { "id": "venice/nvidia-nemotron-cascade-2-30b-a3b", "name": "Nemotron Cascade 2 30B A3B", @@ -127660,7 +138727,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-03-24", - "last_updated": "2026-04-09", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -127682,13 +138749,12 @@ { "id": "venice/olafangensan-glm-4.7-flash-heretic", "name": "GLM 4.7 Flash Heretic", - "family": "glm-flash", + "family": "glm", "attachment": false, "reasoning": true, "tool_call": true, - "temperature": true, "release_date": "2026-02-04", - "last_updated": "2026-03-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -127699,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, @@ -127715,8 +138782,9 @@ "reasoning": false, "tool_call": true, "temperature": true, + "knowledge": "2023-09", "release_date": "2026-02-28", - "last_updated": "2026-03-06", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127739,13 +138807,14 @@ { "id": "venice/openai-gpt-4o-mini", "name": "GPT-4o Mini", - "family": "gpt-mini", + "family": "gpt", "attachment": true, "reasoning": false, "tool_call": true, "temperature": true, + "knowledge": "2023-09", "release_date": "2026-02-28", - "last_updated": "2026-03-06", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127773,10 +138842,10 @@ "attachment": false, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, "knowledge": "2025-08-31", "release_date": "2025-12-13", - "last_updated": "2026-03-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -127799,14 +138868,14 @@ { "id": "venice/openai-gpt-52-codex", "name": "GPT-5.2 Codex", - "family": "gpt-codex", + "family": "gpt", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, "knowledge": "2025-08", "release_date": "2025-01-15", - "last_updated": "2026-03-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127830,13 +138899,14 @@ { "id": "venice/openai-gpt-53-codex", "name": "GPT-5.3 Codex", - "family": "gpt-codex", + "family": "gpt", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, + "knowledge": "2025-08-31", "release_date": "2026-02-24", - "last_updated": "2026-03-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127864,9 +138934,10 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, + "knowledge": "2025-08-31", "release_date": "2026-03-05", - "last_updated": "2026-03-09", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127890,13 +138961,14 @@ { "id": "venice/openai-gpt-54-mini", "name": "GPT-5.4 Mini", - "family": "gpt-mini", + "family": "gpt", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, + "knowledge": "2025-08-31", "release_date": "2026-03-27", - "last_updated": "2026-03-31", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127920,13 +138992,14 @@ { "id": "venice/openai-gpt-54-pro", "name": "GPT-5.4 Pro", - "family": "gpt-pro", + "family": "gpt", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, + "knowledge": "2025-08-31", "release_date": "2026-03-05", - "last_updated": "2026-03-09", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127953,9 +139026,10 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, + "knowledge": "2025-12-01", "release_date": "2026-04-23", - "last_updated": "2026-04-25", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -127979,13 +139053,14 @@ { "id": "venice/openai-gpt-55-pro", "name": "GPT-5.5 Pro", - "family": "gpt-pro", + "family": "gpt", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, + "knowledge": "2025-12-01", "release_date": "2026-04-24", - "last_updated": "2026-04-25", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -128013,9 +139088,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-07", "release_date": "2025-11-06", - "last_updated": "2026-05-06", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -128042,8 +139116,9 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-04", "release_date": "2026-04-06", - "last_updated": "2026-04-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -128070,15 +139145,17 @@ "id": "venice/qwen-3.7-max", "name": "Qwen 3.7 Max", "family": "qwen", - "attachment": false, + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2026-05-22", - "last_updated": "2026-05-25", + "last_updated": "2026-06-11", "modalities": { "input": [ - "text" + "text", + "image", + "video" ], "output": [ "text" @@ -128096,6 +139173,39 @@ "output": 65536 } }, + { + "id": "venice/qwen-3.7-plus", + "name": "Qwen 3.7 Plus", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2026-06-02", + "last_updated": "2026-06-11", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.5, + "output": 2.0, + "cache_read": 0.05, + "cache_write": 0.625 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "venice/qwen3-235b-a22b-instruct", "name": "Qwen 3 235B A22B Instruct 2507", @@ -128103,10 +139213,8 @@ "attachment": false, "reasoning": false, "tool_call": true, - "temperature": true, - "knowledge": "2025-07", "release_date": "2025-04-29", - "last_updated": "2026-03-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -128132,10 +139240,8 @@ "attachment": false, "reasoning": true, "tool_call": true, - "temperature": true, - "knowledge": "2025-07", "release_date": "2025-04-29", - "last_updated": "2026-03-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -128163,7 +139269,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-02-25", - "last_updated": "2026-05-25", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -128194,7 +139300,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-02-16", - "last_updated": "2026-04-16", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -128224,7 +139330,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-03-05", - "last_updated": "2026-04-19", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -128253,7 +139359,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-04-24", - "last_updated": "2026-04-29", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -128281,9 +139387,8 @@ "attachment": false, "reasoning": false, "tool_call": true, - "temperature": true, "release_date": "2026-01-27", - "last_updated": "2026-02-26", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -128310,10 +139415,8 @@ "attachment": false, "reasoning": false, "tool_call": true, - "temperature": true, - "knowledge": "2025-07", "release_date": "2025-04-29", - "last_updated": "2026-03-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -128335,13 +139438,11 @@ { "id": "venice/qwen3-vl-235b-a22b", "name": "Qwen3 VL 235B", - "family": "qwen", "attachment": true, "reasoning": false, "tool_call": true, - "temperature": true, "release_date": "2026-01-16", - "last_updated": "2026-03-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -128353,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 } }, @@ -128368,9 +139470,8 @@ "attachment": true, "reasoning": false, "tool_call": true, - "temperature": true, "release_date": "2026-04-01", - "last_updated": "2026-04-19", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -128397,9 +139498,8 @@ "attachment": true, "reasoning": false, "tool_call": true, - "temperature": true, "release_date": "2026-02-20", - "last_updated": "2026-03-16", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -128419,6 +139519,39 @@ "output": 4096 } }, + { + "id": "venice/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-06-11", + "last_updated": "2026-06-11", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.14, + "output": 0.28, + "cache_read": 0.05 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "venice/z-ai-glm-5-turbo", "name": "GLM 5 Turbo", @@ -128428,7 +139561,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-03-15", - "last_updated": "2026-04-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -128451,13 +139584,13 @@ { "id": "venice/z-ai-glm-5v-turbo", "name": "GLM 5V Turbo", - "family": "glmv", + "family": "glm", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, "release_date": "2026-04-01", - "last_updated": "2026-04-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text", @@ -128486,8 +139619,9 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-04", "release_date": "2024-04-01", - "last_updated": "2026-04-04", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -128498,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, @@ -128517,7 +139651,7 @@ "temperature": true, "knowledge": "2025-04", "release_date": "2025-12-24", - "last_updated": "2026-03-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -128540,13 +139674,14 @@ { "id": "venice/zai-org-glm-4.7-flash", "name": "GLM 4.7 Flash", - "family": "glm-flash", + "family": "glm", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-04", "release_date": "2026-01-29", - "last_updated": "2026-03-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -128574,7 +139709,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-02-11", - "last_updated": "2026-03-12", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -128603,7 +139738,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-04-07", - "last_updated": "2026-04-08", + "last_updated": "2026-06-11", "modalities": { "input": [ "text" @@ -128614,15 +139749,44 @@ }, "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, "output": 24000 } }, + { + "id": "venice/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": 1000000, + "output": 131072 + } + }, { "id": "vercel/alibaba/qwen-3-14b", "name": "Qwen3-14B", @@ -128632,7 +139796,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2025-04", + "release_date": "2025-04-28", "last_updated": "2025-04", "modalities": { "input": [ @@ -128644,7 +139808,7 @@ }, "open_weights": false, "cost": { - "input": 0.06, + "input": 0.12, "output": 0.24 }, "limit": { @@ -128657,11 +139821,11 @@ "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-04", + "release_date": "2025-04-28", "last_updated": "2025-04", "modalities": { "input": [ @@ -128673,11 +139837,11 @@ }, "open_weights": false, "cost": { - "input": 0.13, - "output": 0.6 + "input": 0.22, + "output": 0.88 }, "limit": { - "context": 40960, + "context": 262144, "output": 16384 } }, @@ -128690,7 +139854,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2025-04", + "release_date": "2025-04-28", "last_updated": "2025-04", "modalities": { "input": [ @@ -128702,8 +139866,8 @@ }, "open_weights": false, "cost": { - "input": 0.08, - "output": 0.29 + "input": 0.12, + "output": 0.5 }, "limit": { "context": 40960, @@ -128719,7 +139883,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2025-04", + "release_date": "2025-04-28", "last_updated": "2025-04", "modalities": { "input": [ @@ -128731,12 +139895,12 @@ }, "open_weights": false, "cost": { - "input": 0.1, - "output": 0.3 + "input": 0.16, + "output": 0.64 }, "limit": { - "context": 40960, - "output": 16384 + "context": 128000, + "output": 8192 } }, { @@ -128751,9 +139915,7 @@ "last_updated": "2026-04-24", "modalities": { "input": [ - "text", - "image", - "pdf" + "text" ], "output": [ "text" @@ -128780,7 +139942,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2025-04", + "release_date": "2025-09-23", "last_updated": "2025-04", "modalities": { "input": [ @@ -128794,12 +139956,12 @@ }, "open_weights": false, "cost": { - "input": 0.3, - "output": 2.9 + "input": 0.4, + "output": 4.0 }, "limit": { - "context": 262114, - "output": 262114 + "context": 131072, + "output": 32768 } }, { @@ -128807,11 +139969,11 @@ "name": "Qwen3 Coder 480B A35B Instruct", "family": "qwen", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2025-04", + "release_date": "2025-07-22", "last_updated": "2025-04", "modalities": { "input": [ @@ -128823,12 +139985,13 @@ }, "open_weights": false, "cost": { - "input": 0.38, - "output": 1.53 + "input": 1.5, + "output": 7.5, + "cache_read": 0.3 }, "limit": { "context": 262144, - "output": 66536 + "output": 65536 } }, { @@ -128840,7 +140003,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2025-04", + "release_date": "2025-07-31", "last_updated": "2025-04", "modalities": { "input": [ @@ -128852,12 +140015,12 @@ }, "open_weights": false, "cost": { - "input": 0.07, - "output": 0.27 + "input": 0.15, + "output": 0.6 }, "limit": { - "context": 160000, - "output": 32768 + "context": 262144, + "output": 8192 } }, { @@ -128910,11 +140073,12 @@ "open_weights": true, "cost": { "input": 1.0, - "output": 5.0 + "output": 5.0, + "cache_read": 0.2 }, "limit": { "context": 1000000, - "output": 1000000 + "output": 65536 } }, { @@ -128924,7 +140088,7 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, + "temperature": true, "release_date": "2025-11-14", "last_updated": "2025-11-14", "modalities": { @@ -128936,10 +140100,7 @@ ] }, "open_weights": false, - "cost": { - "input": 0.01, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 32768, "output": 32768 @@ -128952,7 +140113,7 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, + "temperature": true, "release_date": "2025-06-05", "last_updated": "2025-06-05", "modalities": { @@ -128964,10 +140125,7 @@ ] }, "open_weights": false, - "cost": { - "input": 0.02, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 32768, "output": 32768 @@ -128980,7 +140138,7 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, + "temperature": true, "release_date": "2025-06-05", "last_updated": "2025-06-05", "modalities": { @@ -128992,10 +140150,7 @@ ] }, "open_weights": false, - "cost": { - "input": 0.05, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 32768, "output": 32768 @@ -129023,7 +140178,8 @@ "open_weights": false, "cost": { "input": 1.2, - "output": 6.0 + "output": 6.0, + "cache_read": 0.24 }, "limit": { "context": 262144, @@ -129039,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": [ @@ -129069,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": [ @@ -129099,8 +140255,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-04", - "release_date": "2025-09-12", - "last_updated": "2025-09-12", + "release_date": "2025-09-11", + "last_updated": "2025-09", "modalities": { "input": [ "text" @@ -129111,11 +140267,11 @@ }, "open_weights": true, "cost": { - "input": 0.09, - "output": 1.1 + "input": 0.15, + "output": 1.2 }, "limit": { - "context": 262144, + "context": 131072, "output": 32768 } }, @@ -129128,8 +140284,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-09", - "release_date": "2025-09-12", - "last_updated": "2025-09-12", + "release_date": "2025-09-11", + "last_updated": "2025-09", "modalities": { "input": [ "text" @@ -129141,11 +140297,11 @@ "open_weights": true, "cost": { "input": 0.15, - "output": 1.5 + "output": 1.2 }, "limit": { "context": 131072, - "output": 65536 + "output": 32768 } }, { @@ -129156,7 +140312,7 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2025-09-24", + "release_date": "2025-09-23", "last_updated": "2026-05-01", "modalities": { "input": [ @@ -129169,8 +140325,8 @@ }, "open_weights": false, "cost": { - "input": 0.39999999999999997, - "output": 1.5999999999999999 + "input": 0.4, + "output": 1.6 }, "limit": { "context": 131072, @@ -129186,7 +140342,7 @@ "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": [ @@ -129199,8 +140355,8 @@ }, "open_weights": true, "cost": { - "input": 0.7, - "output": 2.8 + "input": 0.4, + "output": 1.6 }, "limit": { "context": 131072, @@ -129216,12 +140372,13 @@ "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": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -129229,12 +140386,12 @@ }, "open_weights": true, "cost": { - "input": 0.7, - "output": 8.4 + "input": 0.4, + "output": 4.0 }, "limit": { "context": 131072, - "output": 129024 + "output": 32768 } }, { @@ -129277,8 +140434,9 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-04", "release_date": "2026-02-16", - "last_updated": "2026-02-19", + "last_updated": "2026-02-16", "modalities": { "input": [ "text", @@ -129310,7 +140468,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-04-22", - "last_updated": "2026-05-01", + "last_updated": "2026-04-22", "modalities": { "input": [ "text", @@ -129324,7 +140482,7 @@ "open_weights": false, "cost": { "input": 0.6, - "output": 3.5999999999999996 + "output": 3.6 }, "limit": { "context": 256000, @@ -129339,8 +140497,9 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-04", "release_date": "2026-04-02", - "last_updated": "2026-04-03", + "last_updated": "2026-04-02", "modalities": { "input": [ "text", @@ -129355,7 +140514,7 @@ "cost": { "input": 0.5, "output": 3.0, - "cache_read": 0.09999999999999999, + "cache_read": 0.1, "cache_write": 0.625 }, "limit": { @@ -129373,6 +140532,37 @@ "temperature": true, "release_date": "2026-05-21", "last_updated": "2026-05-21", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 3.75, + "cache_read": 0.25, + "cache_write": 1.5625 + }, + "limit": { + "context": 991000, + "output": 64000 + } + }, + { + "id": "vercel/alibaba/qwen3.7-plus", + "name": "Qwen 3.7 Plus", + "family": "qwen3.7-plus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2026-06-02", + "last_updated": "2026-06-02", "modalities": { "input": [ "text", @@ -129385,16 +140575,216 @@ }, "open_weights": false, "cost": { - "input": 2.5, - "output": 7.5, - "cache_read": 0.5, - "cache_write": 3.125 + "input": 0.4, + "output": 1.6, + "cache_read": 0.08, + "cache_write": 0.5 }, "limit": { - "context": 991000, + "context": 1000000, "output": 64000 } }, + { + "id": "vercel/alibaba/wan-v2.5-t2v-preview", + "name": "Wan v2.5 Text-to-Video Preview", + "family": "o", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-09-24", + "last_updated": "2025-09-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/alibaba/wan-v2.6-i2v", + "name": "Wan v2.6 Image-to-Video", + "family": "o", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-12-16", + "last_updated": "2025-12-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/alibaba/wan-v2.6-i2v-flash", + "name": "Wan v2.6 Image-to-Video Flash", + "family": "o", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-12-16", + "last_updated": "2025-12-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/alibaba/wan-v2.6-r2v", + "name": "Wan v2.6 Reference-to-Video", + "family": "o", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-12-16", + "last_updated": "2025-12-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/alibaba/wan-v2.6-r2v-flash", + "name": "Wan v2.6 Reference-to-Video Flash", + "family": "o", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-12-16", + "last_updated": "2025-12-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/alibaba/wan-v2.6-t2v", + "name": "Wan v2.6 Text-to-Video", + "family": "o", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-12-16", + "last_updated": "2025-12-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "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", @@ -129404,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" @@ -129418,7 +140809,8 @@ "open_weights": false, "cost": { "input": 0.3, - "output": 2.5 + "output": 2.5, + "cache_read": 0.075 }, "limit": { "context": 1000000, @@ -129526,8 +140918,8 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, - "release_date": "2024-04", + "temperature": true, + "release_date": "2024-04-30", "last_updated": "2024-04", "modalities": { "input": [ @@ -129538,10 +140930,7 @@ ] }, "open_weights": false, - "cost": { - "input": 0.02, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 8192, "output": 1536 @@ -129561,8 +140950,7 @@ "modalities": { "input": [ "text", - "image", - "pdf" + "image" ], "output": [ "text" @@ -129580,49 +140968,16 @@ "output": 4096 } }, - { - "id": "vercel/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": "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": [ @@ -129647,49 +141002,16 @@ } }, { - "id": "vercel/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": "vercel/anthropic/claude-3.7-sonnet", - "name": "Claude Sonnet 3.7", - "family": "claude-sonnet", + "id": "vercel/anthropic/claude-fable-5", + "name": "Claude Fable 5", + "family": "claude-fable", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2024-10-31", - "release_date": "2025-02-19", - "last_updated": "2025-02-19", + "knowledge": "2026-01-31", + "release_date": "2026-07-01", + "last_updated": "2026-06-09", "modalities": { "input": [ "text", @@ -129702,14 +141024,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": 64000 + "context": 1000000, + "output": 128000 } }, { @@ -129819,7 +141141,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": { @@ -129837,7 +141159,7 @@ "input": 5.0, "output": 25.0, "cache_read": 0.5, - "cache_write": 18.75 + "cache_write": 6.25 }, "limit": { "context": 200000, @@ -129853,8 +141175,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-05-31", - "release_date": "2026-02", - "last_updated": "2026-02", + "release_date": "2026-02-05", + "last_updated": "2026-03-13", "modalities": { "input": [ "text", @@ -129884,7 +141206,8 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, + "knowledge": "2026-01-31", "release_date": "2026-04-16", "last_updated": "2026-04-16", "modalities": { @@ -129917,6 +141240,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2026-01", "release_date": "2026-05-28", "last_updated": "2026-05-28", "modalities": { @@ -129970,7 +141294,7 @@ "cache_write": 3.75 }, "limit": { - "context": 200000, + "context": 1000000, "output": 64000 } }, @@ -130003,7 +141327,7 @@ "cache_write": 3.75 }, "limit": { - "context": 200000, + "context": 1000000, "output": 64000 } }, @@ -130017,7 +141341,7 @@ "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-17", - "last_updated": "2026-02-17", + "last_updated": "2026-03-13", "modalities": { "input": [ "text", @@ -130040,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", @@ -130049,7 +141406,7 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-01", + "release_date": "2026-01-27", "last_updated": "2025-01", "modalities": { "input": [ @@ -130106,7 +141463,7 @@ "tool_call": false, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-12", + "release_date": "2025-12-01", "last_updated": "2025-12", "modalities": { "input": [ @@ -130118,7 +141475,7 @@ }, "open_weights": false, "cost": { - "input": 0.05, + "input": 0.045, "output": 0.15 }, "limit": { @@ -130126,6 +141483,131 @@ "output": 131072 } }, + { + "id": "vercel/bfl/flux-2-flex", + "name": "FLUX.2 [flex]", + "family": "flux", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-11-25", + "last_updated": "2026-06-08", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/bfl/flux-2-klein-4b", + "name": "FLUX.2 [klein] 4B", + "family": "flux", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-01-15", + "last_updated": "2026-06-08", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/bfl/flux-2-klein-9b", + "name": "FLUX.2 [klein] 9B", + "family": "flux", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-01-15", + "last_updated": "2026-06-08", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/bfl/flux-2-max", + "name": "FLUX.2 [max]", + "family": "flux", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-12-16", + "last_updated": "2026-06-08", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 67300, + "output": 67300 + } + }, + { + "id": "vercel/bfl/flux-2-pro", + "name": "FLUX.2 [pro]", + "family": "flux", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-11-25", + "last_updated": "2026-06-08", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 67300, + "output": 67300 + } + }, { "id": "vercel/bfl/flux-kontext-max", "name": "FLUX.1 Kontext Max", @@ -130133,8 +141615,8 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, - "release_date": "2025-06", + "temperature": true, + "release_date": "2025-05-29", "last_updated": "2025-06", "modalities": { "input": [ @@ -130158,8 +141640,8 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, - "release_date": "2025-06", + "temperature": true, + "release_date": "2025-05-29", "last_updated": "2025-06", "modalities": { "input": [ @@ -130183,8 +141665,8 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, - "release_date": "2024-10", + "temperature": true, + "release_date": "2024-10-01", "last_updated": "2024-10", "modalities": { "input": [ @@ -130208,8 +141690,8 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, - "release_date": "2024-10", + "temperature": true, + "release_date": "2024-10-02", "last_updated": "2024-10", "modalities": { "input": [ @@ -130233,8 +141715,8 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, - "release_date": "2024-11", + "temperature": true, + "release_date": "2024-11-01", "last_updated": "2024-11", "modalities": { "input": [ @@ -130260,11 +141742,12 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-09", + "release_date": "2025-09-01", "last_updated": "2025-09", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -130290,7 +141773,7 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-10", + "release_date": "2025-09-01", "last_updated": "2025-10", "modalities": { "input": [ @@ -130312,6 +141795,208 @@ "output": 64000 } }, + { + "id": "vercel/bytedance/seedance-2.0", + "name": "Seedance 2.0", + "family": "seed", + "attachment": true, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-04-14", + "last_updated": "2026-04-14", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/bytedance/seedance-2.0-fast", + "name": "Seedance 2.0 Fast", + "family": "seed", + "attachment": true, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-04-14", + "last_updated": "2026-04-14", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/bytedance/seedance-v1.0-pro", + "name": "Seedance v1.0 Pro", + "family": "seed", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-06-11", + "last_updated": "2025-06-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/bytedance/seedance-v1.0-pro-fast", + "name": "Seedance v1.0 Pro Fast", + "family": "seed", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-10-24", + "last_updated": "2025-10-31", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/bytedance/seedance-v1.5-pro", + "name": "Seedance v1.5 Pro", + "family": "seed", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-12-16", + "last_updated": "2025-12-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/bytedance/seedream-4.0", + "name": "Seedream 4.0", + "family": "seed", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-09-09", + "last_updated": "2025-08-28", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/bytedance/seedream-4.5", + "name": "Seedream 4.5", + "family": "seed", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-12-03", + "last_updated": "2025-11-28", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/bytedance/seedream-5.0-lite", + "name": "Seedream 5.0 Lite", + "family": "seed", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-02-13", + "last_updated": "2026-01-28", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, { "id": "vercel/cohere/command-a", "name": "Command A", @@ -130348,7 +142033,7 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, + "temperature": true, "release_date": "2025-04-15", "last_updated": "2025-04-15", "modalities": { @@ -130360,15 +142045,87 @@ ] }, "open_weights": false, - "cost": { - "input": 0.12, - "output": 0.0 - }, + "cost": {}, "limit": { - "context": 8192, + "context": 128000, "output": 1536 } }, + { + "id": "vercel/cohere/rerank-v3.5", + "name": "Cohere Rerank 3.5", + "family": "o", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2024-12-02", + "last_updated": "2024-12-02", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 4096, + "output": 4096 + } + }, + { + "id": "vercel/cohere/rerank-v4-fast", + "name": "Cohere Rerank 4 Fast", + "family": "o", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-12-11", + "last_updated": "2025-12-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 32000, + "output": 32000 + } + }, + { + "id": "vercel/cohere/rerank-v4-pro", + "name": "Cohere Rerank 4 Pro", + "family": "o", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-12-11", + "last_updated": "2025-12-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 32000, + "output": 32000 + } + }, { "id": "vercel/deepseek/deepseek-r1", "name": "DeepSeek-R1", @@ -130419,12 +142176,13 @@ }, "open_weights": false, "cost": { - "input": 0.77, - "output": 0.77 + "input": 0.27, + "output": 1.12, + "cache_read": 0.135 }, "limit": { "context": 163840, - "output": 16384 + "output": 163840 } }, { @@ -130448,11 +142206,11 @@ }, "open_weights": false, "cost": { - "input": 0.3, - "output": 1.0 + "input": 0.6, + "output": 1.7 }, "limit": { - "context": 163840, + "context": 128000, "output": 128000 } }, @@ -130478,7 +142236,8 @@ "open_weights": true, "cost": { "input": 0.27, - "output": 1.0 + "output": 1.0, + "cache_read": 0.135 }, "limit": { "context": 131072, @@ -130506,44 +142265,15 @@ }, "open_weights": false, "cost": { - "input": 0.27, - "output": 0.4, - "cache_read": 0.22 + "input": 0.28, + "output": 0.42, + "cache_read": 0.028 }, "limit": { - "context": 163842, + "context": 128000, "output": 8000 } }, - { - "id": "vercel/deepseek/deepseek-v3.2-exp", - "name": "DeepSeek V3.2 Exp", - "family": "deepseek", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-09", - "release_date": "2025-09-29", - "last_updated": "2025-09-29", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.27, - "output": 0.4 - }, - "limit": { - "context": 163840, - "output": 163840 - } - }, { "id": "vercel/deepseek/deepseek-v3.2-thinking", "name": "DeepSeek V3.2 Thinking", @@ -130565,13 +142295,12 @@ }, "open_weights": false, "cost": { - "input": 0.28, - "output": 0.42, - "cache_read": 0.03 + "input": 0.62, + "output": 1.85 }, "limit": { "context": 128000, - "output": 64000 + "output": 8000 } }, { @@ -130582,6 +142311,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-05", "release_date": "2026-04-23", "last_updated": "2026-04-24", "modalities": { @@ -130596,7 +142326,7 @@ "cost": { "input": 0.14, "output": 0.28, - "cache_read": 0.028 + "cache_read": 0.0028 }, "limit": { "context": 1000000, @@ -130611,6 +142341,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-05", "release_date": "2026-04-23", "last_updated": "2026-04-24", "modalities": { @@ -130623,82 +142354,15 @@ }, "open_weights": true, "cost": { - "input": 1.74, - "output": 3.48, - "cache_read": 0.145 + "input": 0.435, + "output": 0.87, + "cache_read": 0.0036 }, "limit": { "context": 1000000, "output": 384000 } }, - { - "id": "vercel/google/gemini-2.0-flash", - "name": "Gemini 2.0 Flash", - "family": "gemini-flash", - "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.1, - "output": 0.4, - "cache_read": 0.025 - }, - "limit": { - "context": 1048576, - "output": 8192 - } - }, - { - "id": "vercel/google/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 - } - }, { "id": "vercel/google/gemini-2.5-flash", "name": "Gemini 2.5 Flash", @@ -130708,8 +142372,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", @@ -130742,8 +142406,8 @@ "tool_call": false, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-03-20", + "release_date": "2025-08-26", + "last_updated": "2025-08-26", "modalities": { "input": [ "text" @@ -130756,41 +142420,12 @@ "open_weights": false, "cost": { "input": 0.3, - "output": 2.5 + "output": 2.5, + "cache_read": 0.03 }, "limit": { "context": 32768, - "output": 32768 - } - }, - { - "id": "vercel/google/gemini-2.5-flash-image-preview", - "name": "Nano Banana Preview (Gemini 2.5 Flash Image Preview)", - "family": "gemini-flash", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-03-20", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text", - "image" - ] - }, - "open_weights": false, - "cost": { - "input": 0.3, - "output": 2.5 - }, - "limit": { - "context": 32768, - "output": 32768 + "output": 65536 } }, { @@ -130808,8 +142443,6 @@ "input": [ "text", "image", - "audio", - "video", "pdf" ], "output": [ @@ -130827,75 +142460,6 @@ "output": 65536 } }, - { - "id": "vercel/google/gemini-2.5-flash-lite-preview-09", - "name": "Gemini 2.5 Flash Lite Preview 09-25", - "family": "gemini-flash-lite", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-01", - "release_date": "2025-09-25", - "last_updated": "2025-09-25", - "modalities": { - "input": [ - "text", - "image", - "audio", - "video", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.1, - "output": 0.4, - "cache_read": 0.01 - }, - "limit": { - "context": 1048576, - "output": 65536 - } - }, - { - "id": "vercel/google/gemini-2.5-flash-preview-09", - "name": "Gemini 2.5 Flash Preview 09-25", - "family": "gemini-flash", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-01", - "release_date": "2025-09-25", - "last_updated": "2025-09-25", - "modalities": { - "input": [ - "text", - "image", - "audio", - "video", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.3, - "output": 2.5, - "cache_read": 0.03, - "cache_write": 0.383 - }, - "limit": { - "context": 1048576, - "output": 65536 - } - }, { "id": "vercel/google/gemini-2.5-pro", "name": "Gemini 2.5 Pro", @@ -130905,8 +142469,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-03-20", - "last_updated": "2025-06-05", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", @@ -130959,7 +142523,7 @@ }, "limit": { "context": 1000000, - "output": 64000 + "output": 65000 } }, { @@ -130971,7 +142535,7 @@ "tool_call": false, "temperature": true, "knowledge": "2025-03", - "release_date": "2025-09", + "release_date": "2025-09-01", "last_updated": "2025-09", "modalities": { "input": [ @@ -130985,7 +142549,8 @@ "open_weights": false, "cost": { "input": 2.0, - "output": 120.0 + "output": 12.0, + "cache_read": 0.2 }, "limit": { "context": 65536, @@ -131007,8 +142572,6 @@ "input": [ "text", "image", - "video", - "audio", "pdf" ], "output": [ @@ -131027,15 +142590,15 @@ } }, { - "id": "vercel/google/gemini-3.1-flash-image-preview", - "name": "Gemini 3.1 Flash Image Preview (Nano Banana 2)", + "id": "vercel/google/gemini-3.1-flash-image", + "name": "Gemini 3.1 Flash Image (Nano Banana 2)", "family": "gemini", "attachment": true, "reasoning": true, "tool_call": false, "temperature": true, - "release_date": "2026-02-26", - "last_updated": "2026-03-06", + "release_date": "2026-05-28", + "last_updated": "2026-05-28", "modalities": { "input": [ "text", @@ -131049,7 +142612,40 @@ "open_weights": false, "cost": { "input": 0.5, - "output": 3.0 + "output": 3.0, + "cache_read": 0.05 + }, + "limit": { + "context": 131072, + "output": 32768 + } + }, + { + "id": "vercel/google/gemini-3.1-flash-image-preview", + "name": "Gemini 3.1 Flash Image Preview (Nano Banana 2)", + "family": "gemini", + "attachment": true, + "reasoning": true, + "tool_call": false, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-02-26", + "last_updated": "2026-02-26", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text", + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.5, + "output": 3.0, + "cache_read": 0.05 }, "limit": { "context": 131072, @@ -131064,8 +142660,9 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-01", "release_date": "2026-05-07", - "last_updated": "2026-05-08", + "last_updated": "2026-05-07", "modalities": { "input": [ "text", @@ -131087,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", @@ -131095,8 +142723,9 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-01", "release_date": "2026-03-03", - "last_updated": "2026-03-06", + "last_updated": "2026-03-03", "modalities": { "input": [ "text", @@ -131111,8 +142740,7 @@ "cost": { "input": 0.25, "output": 1.5, - "cache_read": 0.025, - "cache_write": 1.0 + "cache_read": 0.03 }, "limit": { "context": 1000000, @@ -131127,8 +142755,9 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-19", - "last_updated": "2026-02-24", + "knowledge": "2025-01", + "release_date": "2025-11-18", + "last_updated": "2026-02-19", "modalities": { "input": [ "text", @@ -131158,8 +142787,9 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-01", "release_date": "2026-05-19", - "last_updated": "2026-05-21", + "last_updated": "2026-05-19", "modalities": { "input": [ "text", @@ -131188,7 +142818,8 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, + "temperature": true, + "knowledge": "2025-05", "release_date": "2025-05-20", "last_updated": "2025-05-20", "modalities": { @@ -131200,10 +142831,7 @@ ] }, "open_weights": false, - "cost": { - "input": 0.15, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 8192, "output": 1536 @@ -131235,15 +142863,15 @@ } }, { - "id": "vercel/google/gemma-4-26b-a4b-it", - "name": "Gemma 4 26B A4B IT", - "family": "gemma", + "id": "vercel/google/gemini-omni-flash-preview", + "name": "Gemini Omni Flash Preview", + "family": "gemini", "attachment": true, "reasoning": true, - "tool_call": true, + "tool_call": false, "temperature": true, - "release_date": "2026-04-02", - "last_updated": "2026-04-03", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", "modalities": { "input": [ "text", @@ -131256,8 +142884,39 @@ }, "open_weights": false, "cost": { - "input": 0.13, - "output": 0.39999999999999997 + "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", + "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", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.15, + "output": 0.6, + "cache_read": 0.015 }, "limit": { "context": 262144, @@ -131269,11 +142928,11 @@ "name": "Gemma 4 31B IT", "family": "gemma", "attachment": true, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "release_date": "2026-04-02", - "last_updated": "2026-04-03", + "last_updated": "2026-04-02", "modalities": { "input": [ "text", @@ -131287,7 +142946,7 @@ "open_weights": false, "cost": { "input": 0.14, - "output": 0.39999999999999997 + "output": 0.4 }, "limit": { "context": 262144, @@ -131301,8 +142960,8 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, - "release_date": "2025-06", + "temperature": true, + "release_date": "2025-06-01", "last_updated": "2025-06", "modalities": { "input": [ @@ -131326,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": [ @@ -131376,8 +143035,8 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, - "release_date": "2024-08", + "temperature": true, + "release_date": "2024-08-01", "last_updated": "2024-08", "modalities": { "input": [ @@ -131388,10 +143047,7 @@ ] }, "open_weights": false, - "cost": { - "input": 0.03, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 8192, "output": 1536 @@ -131404,8 +143060,8 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, - "release_date": "2024-03", + "temperature": true, + "release_date": "2024-03-01", "last_updated": "2024-03", "modalities": { "input": [ @@ -131416,15 +143072,112 @@ ] }, "open_weights": false, - "cost": { - "input": 0.03, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 8192, "output": 1536 } }, + { + "id": "vercel/google/veo-3.0-fast-generate-001", + "name": "Veo 3.0 Fast Generate", + "family": "veo", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-07-31", + "last_updated": "2026-06-08", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/google/veo-3.0-generate-001", + "name": "Veo 3.0", + "family": "veo", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-05-20", + "last_updated": "2026-06-08", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/google/veo-3.1-fast-generate-001", + "name": "Veo 3.1 Fast Generate", + "family": "veo", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-10-15", + "last_updated": "2026-06-08", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/google/veo-3.1-generate-001", + "name": "Veo 3.1", + "family": "veo", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-10-15", + "last_updated": "2026-06-08", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, { "id": "vercel/inception/mercury-2", "name": "Mercury 2", @@ -131463,7 +143216,7 @@ "tool_call": true, "temperature": true, "release_date": "2025-02-26", - "last_updated": "2026-05-01", + "last_updated": "2025-02-26", "modalities": { "input": [ "text" @@ -131482,34 +143235,6 @@ "output": 16384 } }, - { - "id": "vercel/inception/mercury-edit-2", - "name": "Mercury Edit 2", - "attachment": false, - "reasoning": true, - "tool_call": false, - "temperature": true, - "release_date": "2026-03-30", - "last_updated": "2026-03-30", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.25, - "output": 0.75, - "cache_read": 0.025 - }, - "limit": { - "context": 128000, - "output": 8192 - } - }, { "id": "vercel/interfaze/interfaze-beta", "name": "Interfaze Beta", @@ -131521,7 +143246,9 @@ "last_updated": "2026-04-29", "modalities": { "input": [ - "text" + "text", + "image", + "pdf" ], "output": [ "text" @@ -131537,6 +143264,206 @@ "output": 32000 } }, + { + "id": "vercel/klingai/kling-v2.5-turbo-i2v", + "name": "Kling v2.5 Turbo Image-to-Video", + "family": "ling", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-09-23", + "last_updated": "2025-09-23", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/klingai/kling-v2.5-turbo-t2v", + "name": "Kling v2.5 Turbo Text-to-Video", + "family": "ling", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-09-23", + "last_updated": "2025-09-23", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/klingai/kling-v2.6-i2v", + "name": "Kling v2.6 Image-to-Video", + "family": "ling", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-12-03", + "last_updated": "2025-12-21", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/klingai/kling-v2.6-motion-control", + "name": "Kling v2.6 Motion Control", + "family": "ling", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-12-18", + "last_updated": "2025-12-21", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/klingai/kling-v2.6-t2v", + "name": "Kling v2.6 Text-to-Video", + "family": "ling", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-12-03", + "last_updated": "2025-12-21", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/klingai/kling-v3.0-i2v", + "name": "Kling v3.0 Image-to-Video", + "family": "ling", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-02-05", + "last_updated": "2026-02-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/klingai/kling-v3.0-motion-control", + "name": "Kling v3.0 Motion Control", + "family": "ling", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-03-04", + "last_updated": "2026-03-04", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/klingai/kling-v3.0-t2v", + "name": "Kling v3.0 Text-to-Video", + "family": "ling", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-02-05", + "last_updated": "2026-02-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, { "id": "vercel/kwaipilot/kat-coder-pro-v1", "name": "KAT-Coder-Pro V1", @@ -131546,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": [ @@ -131557,7 +143484,11 @@ ] }, "open_weights": false, - "cost": {}, + "cost": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 + }, "limit": { "context": 256000, "output": 32000 @@ -131601,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": [ @@ -131615,20 +143546,19 @@ "cost": {}, "limit": { "context": 128000, - "output": 8192 + "output": 100000 } }, { "id": "vercel/meituan/longcat-flash-thinking", - "name": "LongCat Flash Thinking", + "name": "LongCat Flash Thinking 2601", "family": "longcat", "attachment": false, "reasoning": true, - "tool_call": true, + "tool_call": false, "temperature": true, - "knowledge": "2024-10", - "release_date": "2025-09-23", - "last_updated": "2025-09-23", + "release_date": "2026-01-15", + "last_updated": "2026-03-13", "modalities": { "input": [ "text" @@ -131638,13 +143568,10 @@ ] }, "open_weights": false, - "cost": { - "input": 0.15, - "output": 1.5 - }, + "cost": {}, "limit": { - "context": 128000, - "output": 8192 + "context": 32768, + "output": 32768 } }, { @@ -131668,12 +143595,12 @@ }, "open_weights": false, "cost": { - "input": 0.4, - "output": 0.4 + "input": 0.72, + "output": 0.72 }, "limit": { - "context": 131072, - "output": 16384 + "context": 128000, + "output": 8192 } }, { @@ -131697,12 +143624,12 @@ }, "open_weights": false, "cost": { - "input": 0.03, - "output": 0.05 + "input": 0.22, + "output": 0.22 }, "limit": { - "context": 131072, - "output": 16384 + "context": 128000, + "output": 8192 } }, { @@ -131744,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": [ @@ -131773,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": [ @@ -131933,14 +143860,14 @@ }, "open_weights": true, "cost": { - "input": 0.27, - "output": 1.15, + "input": 0.3, + "output": 1.2, "cache_read": 0.03, - "cache_write": 0.38 + "cache_write": 0.375 }, "limit": { - "context": 262114, - "output": 262114 + "context": 205000, + "output": 205000 } }, { @@ -131952,8 +143879,8 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-10-27", - "last_updated": "2025-10-27", + "release_date": "2025-12-23", + "last_updated": "2025-12-23", "modalities": { "input": [ "text" @@ -131967,7 +143894,7 @@ "input": 0.3, "output": 1.2, "cache_read": 0.03, - "cache_write": 0.38 + "cache_write": 0.375 }, "limit": { "context": 204800, @@ -131983,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": [ @@ -131998,7 +143925,7 @@ "input": 0.3, "output": 2.4, "cache_read": 0.03, - "cache_write": 0.38 + "cache_write": 0.375 }, "limit": { "context": 204800, @@ -132014,7 +143941,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-02-12", - "last_updated": "2026-02-19", + "last_updated": "2026-02-12", "modalities": { "input": [ "text" @@ -132044,7 +143971,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-02-12", - "last_updated": "2026-03-13", + "last_updated": "2026-02-13", "modalities": { "input": [ "text" @@ -132061,8 +143988,8 @@ "cache_write": 0.375 }, "limit": { - "context": 0, - "output": 0 + "context": 204800, + "output": 131000 } }, { @@ -132077,9 +144004,7 @@ "last_updated": "2026-03-18", "modalities": { "input": [ - "text", - "image", - "pdf" + "text" ], "output": [ "text" @@ -132109,8 +144034,7 @@ "last_updated": "2026-03-18", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" @@ -132128,6 +144052,37 @@ "output": 131100 } }, + { + "id": "vercel/minimax/minimax-m3", + "name": "MiniMax M3", + "family": "minimax-m3", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-05-31", + "last_updated": "2026-06-01", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 + }, + "limit": { + "context": 1000000, + "output": 1000000 + } + }, { "id": "vercel/mistral/codestral", "name": "Codestral (latest)", @@ -132164,7 +144119,7 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, + "temperature": true, "release_date": "2025-05-28", "last_updated": "2025-05-28", "modalities": { @@ -132176,10 +144131,7 @@ ] }, "open_weights": false, - "cost": { - "input": 0.15, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 8192, "output": 1536 @@ -132205,7 +144157,10 @@ ] }, "open_weights": false, - "cost": {}, + "cost": { + "input": 0.4, + "output": 2.0 + }, "limit": { "context": 256000, "output": 256000 @@ -132220,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": [ @@ -132249,18 +144204,22 @@ "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" ] }, "open_weights": false, - "cost": {}, + "cost": { + "input": 0.1, + "output": 0.3 + }, "limit": { "context": 256000, "output": 256000 @@ -132333,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": [ @@ -132420,7 +144379,7 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, + "temperature": true, "release_date": "2023-12-11", "last_updated": "2023-12-11", "modalities": { @@ -132432,10 +144391,7 @@ ] }, "open_weights": false, - "cost": { - "input": 0.1, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 8192, "output": 1536 @@ -132509,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" @@ -132538,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": [ @@ -132550,12 +144507,12 @@ }, "open_weights": false, "cost": { - "input": 0.04, - "output": 0.17 + "input": 0.15, + "output": 0.15 }, "limit": { - "context": 60288, - "output": 16000 + "context": 128000, + "output": 128000 } }, { @@ -132563,11 +144520,11 @@ "name": "Mistral Small (latest)", "family": "mistral-small", "attachment": true, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2025-06", - "release_date": "2026-03-16", + "release_date": "2024-09-17", "last_updated": "2026-03-16", "modalities": { "input": [ @@ -132580,41 +144537,12 @@ }, "open_weights": true, "cost": { - "input": 0.15, - "output": 0.6 + "input": 0.1, + "output": 0.3 }, "limit": { - "context": 256000, - "output": 256000 - } - }, - { - "id": "vercel/mistral/mixtral-8x22b-instruct", - "name": "Mixtral 8x22B", - "family": "mixtral", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-04", - "release_date": "2024-04-17", - "last_updated": "2024-04-17", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 2.0, - "output": 6.0 - }, - "limit": { - "context": 64000, - "output": 64000 + "context": 32000, + "output": 4000 } }, { @@ -132680,14 +144608,13 @@ { "id": "vercel/moonshotai/kimi-k2", "name": "Kimi K2 Instruct", - "family": "kimi", + "family": "kimi-k2", "attachment": false, "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2024-10", - "release_date": "2025-07-14", - "last_updated": "2025-07-14", + "release_date": "2025-07-11", + "last_updated": "2025-09-05", "modalities": { "input": [ "text" @@ -132696,14 +144623,14 @@ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { - "input": 1.0, - "output": 3.0 + "input": 0.57, + "output": 2.3 }, "limit": { "context": 131072, - "output": 16384 + "output": 131072 } }, { @@ -132729,88 +144656,28 @@ "cost": { "input": 0.47, "output": 2.0, - "cache_read": 0.14 + "cache_read": 0.141 }, "limit": { "context": 216144, "output": 216144 } }, - { - "id": "vercel/moonshotai/kimi-k2-thinking-turbo", - "name": "Kimi K2 Thinking Turbo", - "family": "kimi-thinking", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-08", - "release_date": "2025-11-06", - "last_updated": "2025-11-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.15, - "output": 8.0, - "cache_read": 0.15 - }, - "limit": { - "context": 262114, - "output": 262114 - } - }, - { - "id": "vercel/moonshotai/kimi-k2-turbo", - "name": "Kimi K2 Turbo", - "family": "kimi", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-08", - "release_date": "2025-09-05", - "last_updated": "2025-09-05", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 2.4, - "output": 10.0 - }, - "limit": { - "context": 256000, - "output": 16384 - } - }, { "id": "vercel/moonshotai/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, "knowledge": "2025-01", "release_date": "2026-01-26", - "last_updated": "2026-01-26", + "last_updated": "2026-01", "modalities": { "input": [ "text", - "image", - "video" + "image" ], "output": [ "text" @@ -132819,28 +144686,29 @@ "open_weights": true, "cost": { "input": 0.6, - "output": 1.2 + "output": 3.0, + "cache_read": 0.1 }, "limit": { - "context": 262144, - "output": 262144 + "context": 262114, + "output": 262114 } }, { "id": "vercel/moonshotai/kimi-k2.6", "name": "Kimi K2.6", - "family": "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-24", + "last_updated": "2026-04-21", "modalities": { "input": [ "text", - "image", - "pdf" + "image" ], "output": [ "text" @@ -132857,6 +144725,70 @@ "output": 262000 } }, + { + "id": "vercel/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", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.19 + }, + "limit": { + "context": 256000, + "output": 32768 + } + }, + { + "id": "vercel/moonshotai/kimi-k2.7-code-highspeed", + "name": "Kimi K2.7 Code High Speed", + "family": "kimi-k2", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-06-15", + "last_updated": "2026-06-12", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.9, + "output": 8.0, + "cache_read": 0.38 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, { "id": "vercel/morph/morph-v3-fast", "name": "Morph v3 Fast", @@ -132922,8 +144854,8 @@ "tool_call": false, "temperature": true, "knowledge": "2024-10", - "release_date": "2024-12", - "last_updated": "2024-12", + "release_date": "2025-12-15", + "last_updated": "2025-12-15", "modalities": { "input": [ "text" @@ -132934,7 +144866,7 @@ }, "open_weights": false, "cost": { - "input": 0.06, + "input": 0.05, "output": 0.24 }, "limit": { @@ -132947,11 +144879,11 @@ "name": "NVIDIA Nemotron 3 Super 120B A12B", "family": "nemotron", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": false, "temperature": true, - "release_date": "2026-03-18", - "last_updated": "2026-03-30", + "release_date": "2026-03-11", + "last_updated": "2026-03-11", "modalities": { "input": [ "text" @@ -132970,6 +144902,35 @@ "output": 32000 } }, + { + "id": "vercel/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": false, + "cost": { + "input": 0.6, + "output": 2.4, + "cache_read": 0.12 + }, + "limit": { + "context": 1000000, + "output": 65000 + } + }, { "id": "vercel/nvidia/nemotron-nano-12b-v2-vl", "name": "Nvidia Nemotron Nano 12B V2 VL", @@ -132979,8 +144940,8 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2024-12", - "last_updated": "2024-12", + "release_date": "2025-10-28", + "last_updated": "2025-10-28", "modalities": { "input": [ "text", @@ -133021,46 +144982,14 @@ }, "open_weights": false, "cost": { - "input": 0.04, - "output": 0.16 + "input": 0.06, + "output": 0.23 }, "limit": { "context": 131072, "output": 131072 } }, - { - "id": "vercel/openai/codex-mini", - "name": "Codex Mini", - "family": "gpt-codex-mini", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-10", - "release_date": "2025-05-16", - "last_updated": "2025-05-16", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.5, - "output": 6.0, - "cache_read": 0.38 - }, - "limit": { - "context": 200000, - "output": 100000 - } - }, { "id": "vercel/openai/gpt-3.5-turbo", "name": "GPT-3.5 Turbo", @@ -133071,7 +145000,7 @@ "temperature": true, "knowledge": "2021-09", "release_date": "2023-03-01", - "last_updated": "2023-03-01", + "last_updated": "2023-11-06", "modalities": { "input": [ "text" @@ -133099,7 +145028,7 @@ "tool_call": false, "temperature": true, "knowledge": "2021-09", - "release_date": "2023-03-01", + "release_date": "2023-09-18", "last_updated": "2023-03-01", "modalities": { "input": [ @@ -133317,7 +145246,7 @@ "tool_call": false, "temperature": true, "knowledge": "2023-09", - "release_date": "2025-01", + "release_date": "2025-03-12", "last_updated": "2025-01", "modalities": { "input": [ @@ -133337,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", @@ -133394,7 +145379,7 @@ "cost": { "input": 1.25, "output": 10.0, - "cache_read": 0.13 + "cache_read": 0.125 }, "limit": { "context": 128000, @@ -133503,8 +145488,8 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-08-07", - "last_updated": "2025-08-07", + "release_date": "2025-10-06", + "last_updated": "2025-10-06", "modalities": { "input": [ "text", @@ -133512,8 +145497,7 @@ "pdf" ], "output": [ - "text", - "image" + "text" ] }, "open_weights": false, @@ -133535,8 +145519,8 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-08-07", - "last_updated": "2025-08-07", + "release_date": "2025-11-12", + "last_updated": "2025-11-13", "modalities": { "input": [ "text", @@ -133551,7 +145535,7 @@ "cost": { "input": 1.25, "output": 10.0, - "cache_read": 0.13 + "cache_read": 0.125 }, "limit": { "context": 400000, @@ -133567,8 +145551,8 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-08-07", - "last_updated": "2025-08-07", + "release_date": "2025-11-19", + "last_updated": "2025-11-13", "modalities": { "input": [ "text", @@ -133583,7 +145567,7 @@ "cost": { "input": 1.25, "output": 10.0, - "cache_read": 0.13 + "cache_read": 0.125 }, "limit": { "context": 400000, @@ -133599,8 +145583,8 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-05-16", - "last_updated": "2025-05-16", + "release_date": "2025-11-12", + "last_updated": "2025-11-13", "modalities": { "input": [ "text", @@ -133615,7 +145599,7 @@ "cost": { "input": 0.25, "output": 2.0, - "cache_read": 0.03 + "cache_read": 0.025 }, "limit": { "context": 400000, @@ -133627,11 +145611,11 @@ "name": "GPT-5.1 Instant", "family": "gpt", "attachment": true, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-08-07", + "release_date": "2025-11-12", "last_updated": "2025-08-07", "modalities": { "input": [ @@ -133640,15 +145624,14 @@ "pdf" ], "output": [ - "text", - "image" + "text" ] }, "open_weights": false, "cost": { "input": 1.25, "output": 10.0, - "cache_read": 0.13 + "cache_read": 0.125 }, "limit": { "context": 128000, @@ -133662,9 +145645,9 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-10", - "release_date": "2025-08-07", + "release_date": "2025-11-12", "last_updated": "2025-08-07", "modalities": { "input": [ @@ -133681,7 +145664,7 @@ "cost": { "input": 1.25, "output": 10.0, - "cache_read": 0.13 + "cache_read": 0.125 }, "limit": { "context": 400000, @@ -133697,8 +145680,8 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-08-07", - "last_updated": "2025-08-07", + "release_date": "2025-12-11", + "last_updated": "2025-12-11", "modalities": { "input": [ "text", @@ -133713,7 +145696,7 @@ "cost": { "input": 1.75, "output": 14.0, - "cache_read": 0.18 + "cache_read": 0.175 }, "limit": { "context": 400000, @@ -133725,11 +145708,11 @@ "name": "GPT-5.2 Chat", "family": "gpt", "attachment": true, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-08-07", + "release_date": "2025-12-11", "last_updated": "2025-08-07", "modalities": { "input": [ @@ -133745,7 +145728,7 @@ "cost": { "input": 1.75, "output": 14.0, - "cache_read": 0.18 + "cache_read": 0.175 }, "limit": { "context": 128000, @@ -133761,8 +145744,8 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-12", - "last_updated": "2025-12", + "release_date": "2025-12-18", + "last_updated": "2025-12-11", "modalities": { "input": [ "text", @@ -133793,8 +145776,8 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-08-07", - "last_updated": "2025-08-07", + "release_date": "2025-12-11", + "last_updated": "2025-12-11", "modalities": { "input": [ "text", @@ -133820,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", @@ -133854,8 +145837,9 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-24", - "last_updated": "2026-02-24", + "knowledge": "2025-08-31", + "release_date": "2026-02-05", + "last_updated": "2026-02-05", "modalities": { "input": [ "text", @@ -133885,8 +145869,9 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-08-31", "release_date": "2026-03-05", - "last_updated": "2026-03-06", + "last_updated": "2026-03-05", "modalities": { "input": [ "text", @@ -133916,6 +145901,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", "modalities": { @@ -133947,6 +145933,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", "modalities": { @@ -133961,7 +145948,7 @@ }, "open_weights": false, "cost": { - "input": 0.19999999999999998, + "input": 0.2, "output": 1.25, "cache_read": 0.02 }, @@ -133978,8 +145965,9 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-08-31", "release_date": "2026-03-05", - "last_updated": "2026-03-06", + "last_updated": "2026-03-05", "modalities": { "input": [ "text", @@ -134008,8 +145996,9 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-12-01", "release_date": "2026-04-24", - "last_updated": "2026-04-24", + "last_updated": "2026-04-23", "modalities": { "input": [ "text", @@ -134039,8 +146028,9 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-12-01", "release_date": "2026-04-24", - "last_updated": "2026-04-24", + "last_updated": "2026-04-23", "modalities": { "input": [ "text", @@ -134061,6 +146051,122 @@ "output": 128000 } }, + { + "id": "vercel/openai/gpt-image-1", + "name": "GPT Image 1", + "family": "gpt-image", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-03-25", + "last_updated": "2025-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 40.0, + "cache_read": 1.25 + }, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/openai/gpt-image-1-mini", + "name": "GPT Image 1 Mini", + "family": "gpt-image", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-10-06", + "last_updated": "2025-10-06", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 8.0, + "cache_read": 0.2 + }, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/openai/gpt-image-1.5", + "name": "GPT Image 1.5", + "family": "gpt-image", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-12-16", + "last_updated": "2025-11-25", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 32.0, + "cache_read": 1.25 + }, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/openai/gpt-image-2", + "name": "GPT Image 2", + "family": "gpt-image", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 30.0, + "cache_read": 1.25 + }, + "limit": { + "context": 0, + "output": 0 + } + }, { "id": "vercel/openai/gpt-oss-120b", "name": "GPT OSS 120B", @@ -134111,12 +146217,12 @@ }, "open_weights": true, "cost": { - "input": 0.07, - "output": 0.3 + "input": 0.05, + "output": 0.2 }, "limit": { "context": 131072, - "output": 32768 + "output": 8192 } }, { @@ -134128,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": [ @@ -134140,15 +146246,108 @@ }, "open_weights": false, "cost": { - "input": 0.08, + "input": 0.075, "output": 0.3, - "cache_read": 0.04 + "cache_read": 0.037 }, "limit": { "context": 131072, "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", @@ -134220,9 +146419,9 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-10", - "release_date": "2024-06-26", + "release_date": "2025-06-26", "last_updated": "2024-06-26", "modalities": { "input": [ @@ -134282,10 +146481,10 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-10", - "release_date": "2025-04-16", - "last_updated": "2025-04-16", + "release_date": "2025-06-10", + "last_updated": "2025-06-10", "modalities": { "input": [ "text", @@ -134344,7 +146543,7 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, + "temperature": true, "release_date": "2024-01-25", "last_updated": "2024-01-25", "modalities": { @@ -134356,10 +146555,7 @@ ] }, "open_weights": false, - "cost": { - "input": 0.13, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 8192, "output": 1536 @@ -134372,7 +146568,7 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, + "temperature": true, "release_date": "2024-01-25", "last_updated": "2024-01-25", "modalities": { @@ -134384,10 +146580,7 @@ ] }, "open_weights": false, - "cost": { - "input": 0.02, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 8192, "output": 1536 @@ -134400,7 +146593,7 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, + "temperature": true, "release_date": "2022-12-15", "last_updated": "2022-12-15", "modalities": { @@ -134412,39 +146605,109 @@ ] }, "open_weights": false, - "cost": { - "input": 0.1, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 8192, "output": 1536 } }, { - "id": "vercel/perplexity/sonar", - "name": "Sonar Reasoning", - "family": "sonar-reasoning", + "id": "vercel/openai/tts-1", + "name": "TTS-1", + "family": "o", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": false, "temperature": true, - "knowledge": "2025-09", - "release_date": "2025-02-19", - "last_updated": "2025-02-19", + "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": { - "input": 1.0, - "output": 5.0 + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/perplexity/sonar", + "name": "Sonar", + "family": "sonar", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-02", + "release_date": "2025-02-19", + "last_updated": "2025-02-19", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] }, + "open_weights": false, + "cost": {}, "limit": { "context": 127000, "output": 8000 @@ -134471,10 +146734,7 @@ ] }, "open_weights": false, - "cost": { - "input": 3.0, - "output": 15.0 - }, + "cost": {}, "limit": { "context": 200000, "output": 8000 @@ -134500,39 +146760,57 @@ ] }, "open_weights": false, - "cost": { - "input": 2.0, - "output": 8.0 - }, + "cost": {}, "limit": { "context": 127000, "output": 8000 } }, { - "id": "vercel/prime-intellect/intellect-3", - "name": "INTELLECT 3", - "family": "intellect", + "id": "vercel/prodia/flux-fast-schnell", + "name": "Flux Schnell", + "family": "flux", "attachment": false, - "reasoning": true, - "tool_call": true, + "reasoning": false, + "tool_call": false, "temperature": true, - "knowledge": "2024-10", - "release_date": "2025-11-26", - "last_updated": "2025-11-26", + "release_date": "2024-08-02", + "last_updated": "2026-06-08", "modalities": { "input": [ "text" ], "output": [ - "text" + "image" ] }, "open_weights": false, - "cost": { - "input": 0.2, - "output": 1.1 + "cost": {}, + "limit": { + "context": 512, + "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 @@ -134545,8 +146823,8 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, - "release_date": "2024-03", + "temperature": true, + "release_date": "2024-03-13", "last_updated": "2024-03", "modalities": { "input": [ @@ -134570,8 +146848,8 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, - "release_date": "2024-10", + "temperature": true, + "release_date": "2024-10-30", "last_updated": "2024-10", "modalities": { "input": [ @@ -134589,15 +146867,165 @@ } }, { - "id": "vercel/vercel/v0-1.0-md", - "name": "v0-1.0-md", - "family": "v0", + "id": "vercel/recraft/recraft-v4", + "name": "Recraft V4", + "family": "recraft", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-02-17", + "last_updated": "2026-02-17", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/recraft/recraft-v4-pro", + "name": "Recraft V4 Pro", + "family": "recraft", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-02-17", + "last_updated": "2026-02-17", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/recraft/recraft-v4.1", + "name": "Recraft V4.1", + "family": "recraft", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-05-14", + "last_updated": "2026-05-14", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/recraft/recraft-v4.1-pro", + "name": "Recraft V4.1 Pro", + "family": "recraft", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-05-14", + "last_updated": "2026-05-14", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/recraft/recraft-v4.1-utility", + "name": "Recraft V4.1 Utility", + "family": "recraft", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-05-14", + "last_updated": "2026-05-14", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/recraft/recraft-v4.1-utility-pro", + "name": "Recraft V4.1 Utility Pro", + "family": "recraft", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-05-14", + "last_updated": "2026-05-14", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/sakana/fugu-ultra", + "name": "Fugu Ultra", + "family": "aura", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-05-22", - "last_updated": "2025-05-22", + "release_date": "2026-06-21", + "last_updated": "2026-06-21", "modalities": { "input": [ "text", @@ -134609,40 +147037,123 @@ }, "open_weights": false, "cost": { - "input": 3.0, - "output": 15.0 + "input": 5.0, + "output": 30.0, + "cache_read": 0.5 }, "limit": { - "context": 128000, + "context": 1000000, + "output": 1000000 + } + }, + { + "id": "vercel/stepfun/step-3.5-flash", + "name": "StepFun 3.5 Flash", + "family": "step", + "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": false, + "cost": { + "input": 0.09, + "output": 0.3, + "cache_read": 0.02 + }, + "limit": { + "context": 262114, + "output": 262114 + } + }, + { + "id": "vercel/stepfun/step-3.7-flash", + "name": "Step 3.7 Flash", + "family": "step", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2026-01-01", + "release_date": "2026-05-28", + "last_updated": "2026-05-29", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.2, + "output": 1.15, + "cache_read": 0.04 + }, + "limit": { + "context": 256000, + "output": 256000 + } + }, + { + "id": "vercel/voyage/rerank-2.5", + "name": "Voyage Rerank 2.5", + "family": "voyage", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-08-11", + "last_updated": "2025-08-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 32000, "output": 32000 } }, { - "id": "vercel/vercel/v0-1.5-md", - "name": "v0-1.5-md", - "family": "v0", - "attachment": true, - "reasoning": true, - "tool_call": true, + "id": "vercel/voyage/rerank-2.5-lite", + "name": "Voyage Rerank 2.5 Lite", + "family": "voyage", + "attachment": false, + "reasoning": false, + "tool_call": false, "temperature": true, - "release_date": "2025-06-09", - "last_updated": "2025-06-09", + "release_date": "2025-08-11", + "last_updated": "2025-08-11", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" ] }, "open_weights": false, - "cost": { - "input": 3.0, - "output": 15.0 - }, + "cost": {}, "limit": { - "context": 128000, + "context": 32000, "output": 32000 } }, @@ -134653,8 +147164,8 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, - "release_date": "2024-09", + "temperature": true, + "release_date": "2025-01-07", "last_updated": "2024-09", "modalities": { "input": [ @@ -134665,10 +147176,7 @@ ] }, "open_weights": false, - "cost": { - "input": 0.18, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 8192, "output": 1536 @@ -134681,7 +147189,7 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, + "temperature": true, "release_date": "2025-05-20", "last_updated": "2025-05-20", "modalities": { @@ -134693,10 +147201,7 @@ ] }, "open_weights": false, - "cost": { - "input": 0.06, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 8192, "output": 1536 @@ -134709,7 +147214,7 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, + "temperature": true, "release_date": "2025-05-20", "last_updated": "2025-05-20", "modalities": { @@ -134721,10 +147226,7 @@ ] }, "open_weights": false, - "cost": { - "input": 0.02, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 8192, "output": 1536 @@ -134738,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": [ @@ -134763,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": [ @@ -134788,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": [ @@ -134812,8 +147314,8 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, - "release_date": "2024-01", + "temperature": true, + "release_date": "2024-01-01", "last_updated": "2024-01", "modalities": { "input": [ @@ -134824,10 +147326,7 @@ ] }, "open_weights": false, - "cost": { - "input": 0.12, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 8192, "output": 1536 @@ -134840,8 +147339,8 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, - "release_date": "2024-09", + "temperature": true, + "release_date": "2024-12-04", "last_updated": "2024-09", "modalities": { "input": [ @@ -134852,10 +147351,7 @@ ] }, "open_weights": false, - "cost": { - "input": 0.18, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 8192, "output": 1536 @@ -134868,8 +147364,8 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, - "release_date": "2024-03", + "temperature": true, + "release_date": "2024-06-03", "last_updated": "2024-03", "modalities": { "input": [ @@ -134880,10 +147376,7 @@ ] }, "open_weights": false, - "cost": { - "input": 0.12, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 8192, "output": 1536 @@ -134896,8 +147389,8 @@ "attachment": false, "reasoning": false, "tool_call": false, - "temperature": false, - "release_date": "2024-03", + "temperature": true, + "release_date": "2024-04-15", "last_updated": "2024-03", "modalities": { "input": [ @@ -134908,77 +147401,14 @@ ] }, "open_weights": false, - "cost": { - "input": 0.12, - "output": 0.0 - }, + "cost": {}, "limit": { "context": 8192, "output": 1536 } }, { - "id": "vercel/xai/grok-4-fast", - "name": "Grok 4 Fast Reasoning", - "family": "grok", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-10", - "release_date": "2025-07-09", - "last_updated": "2025-07-09", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.2, - "output": 0.5, - "cache_read": 0.05 - }, - "limit": { - "context": 2000000, - "output": 256000 - } - }, - { - "id": "vercel/xai/grok-4.1-fast", - "name": "Grok 4.1 Fast Reasoning", - "family": "grok", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-10", - "release_date": "2025-07-09", - "last_updated": "2025-07-09", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.2, - "output": 0.5, - "cache_read": 0.05 - }, - "limit": { - "context": 2000000, - "output": 30000 - } - }, - { - "id": "vercel/xai/grok-4.1-fast-non", + "id": "vercel/xai/grok-4.1-fast-non-reasoning", "name": "Grok 4.1 Fast Non-Reasoning", "family": "grok", "attachment": false, @@ -134986,11 +147416,13 @@ "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": [ - "text" + "text", + "image", + "pdf" ], "output": [ "text" @@ -135003,19 +147435,51 @@ "cache_read": 0.05 }, "limit": { - "context": 2000000, - "output": 30000 + "context": 1000000, + "output": 1000000 } }, { - "id": "vercel/xai/grok-4.20", - "name": "Grok 4.20 Reasoning", + "id": "vercel/xai/grok-4.1-fast-reasoning", + "name": "Grok 4.1 Fast Reasoning", "family": "grok", - "attachment": true, + "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-03-09", + "knowledge": "2024-10", + "release_date": "2025-11-19", + "last_updated": "2025-07-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.2, + "output": 0.5, + "cache_read": 0.05 + }, + "limit": { + "context": 1000000, + "output": 1000000 + } + }, + { + "id": "vercel/xai/grok-4.20-multi-agent", + "name": "Grok 4.20 Multi-Agent", + "family": "grok", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-10", "last_updated": "2026-03-23", "modalities": { "input": [ @@ -135029,38 +147493,9 @@ }, "open_weights": false, "cost": { - "input": 2.0, - "output": 6.0, - "cache_read": 0.19999999999999998 - }, - "limit": { - "context": 2000000, - "output": 2000000 - } - }, - { - "id": "vercel/xai/grok-4.20-multi-agent", - "name": "Grok 4.20 Multi-Agent", - "family": "grok", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-03-09", - "last_updated": "2026-03-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 2.0, - "output": 6.0, - "cache_read": 0.19999999999999998 + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 }, "limit": { "context": 2000000, @@ -135079,7 +147514,9 @@ "last_updated": "2026-03-13", "modalities": { "input": [ - "text" + "text", + "image", + "pdf" ], "output": [ "text" @@ -135087,9 +147524,9 @@ }, "open_weights": false, "cost": { - "input": 2.0, - "output": 6.0, - "cache_read": 0.19999999999999998 + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 }, "limit": { "context": 2000000, @@ -135097,14 +147534,14 @@ } }, { - "id": "vercel/xai/grok-4.20-non", + "id": "vercel/xai/grok-4.20-non-reasoning", "name": "Grok 4.20 Non-Reasoning", "family": "grok", "attachment": true, "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2026-03-09", + "release_date": "2026-03-10", "last_updated": "2026-03-23", "modalities": { "input": [ @@ -135118,9 +147555,9 @@ }, "open_weights": false, "cost": { - "input": 2.0, - "output": 6.0, - "cache_read": 0.19999999999999998 + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 }, "limit": { "context": 2000000, @@ -135149,9 +147586,40 @@ }, "open_weights": false, "cost": { - "input": 2.0, - "output": 6.0, - "cache_read": 0.19999999999999998 + "input": 1.25, + "output": 2.5, + "cache_read": 0.4 + }, + "limit": { + "context": 2000000, + "output": 2000000 + } + }, + { + "id": "vercel/xai/grok-4.20-reasoning", + "name": "Grok 4.20 Reasoning", + "family": "grok", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-10", + "last_updated": "2026-03-23", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 }, "limit": { "context": 2000000, @@ -135180,9 +147648,9 @@ }, "open_weights": false, "cost": { - "input": 2.0, - "output": 6.0, - "cache_read": 0.19999999999999998 + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 }, "limit": { "context": 2000000, @@ -135198,7 +147666,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-04-30", - "last_updated": "2026-05-01", + "last_updated": "2026-04-17", "modalities": { "input": [ "text", @@ -135213,13 +147681,44 @@ "cost": { "input": 1.25, "output": 2.5, - "cache_read": 0.19999999999999998 + "cache_read": 0.2 }, "limit": { "context": 1000000, "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", @@ -135229,7 +147728,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-05-20", - "last_updated": "2026-05-21", + "last_updated": "2026-04-16", "modalities": { "input": [ "text", @@ -135243,7 +147742,7 @@ "cost": { "input": 1.0, "output": 2.0, - "cache_read": 0.19999999999999998 + "cache_read": 0.2 }, "limit": { "context": 256000, @@ -135277,22 +147776,148 @@ } }, { - "id": "vercel/xai/grok-imagine-image-pro", - "name": "Grok Imagine Image Pro", + "id": "vercel/xai/grok-imagine-video", + "name": "Grok Imagine", "family": "grok", "attachment": false, "reasoning": false, "tool_call": false, "temperature": true, "release_date": "2026-01-28", - "last_updated": "2026-02-19", + "last_updated": "2026-01-28", "modalities": { "input": [ "text" ], "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "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", + "family": "grok", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-05-30", + "last_updated": "2026-05-30", + "modalities": { + "input": [ + "text" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "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", - "image" + "audio" + ], + "output": [ + "text", + "audio" ] }, "open_weights": false, @@ -135311,8 +147936,8 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-12-17", - "last_updated": "2025-12-17", + "release_date": "2025-12-16", + "last_updated": "2026-02-04", "modalities": { "input": [ "text" @@ -135324,7 +147949,8 @@ "open_weights": false, "cost": { "input": 0.1, - "output": 0.29 + "output": 0.3, + "cache_read": 0.01 }, "limit": { "context": 262144, @@ -135339,8 +147965,9 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2024-12", "release_date": "2026-03-18", - "last_updated": "2026-03-20", + "last_updated": "2026-03-18", "modalities": { "input": [ "text" @@ -135353,7 +147980,7 @@ "cost": { "input": 1.0, "output": 3.0, - "cache_read": 0.19999999999999998 + "cache_read": 0.2 }, "limit": { "context": 1000000, @@ -135368,14 +147995,14 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2024-12", "release_date": "2026-04-22", - "last_updated": "2026-05-01", + "last_updated": "2026-04-22", "modalities": { "input": [ "text", "image", - "audio", - "video" + "pdf" ], "output": [ "text" @@ -135383,9 +148010,9 @@ }, "open_weights": false, "cost": { - "input": 0.39999999999999997, - "output": 2.0, - "cache_read": 0.08 + "input": 0.14, + "output": 0.28, + "cache_read": 0.0028 }, "limit": { "context": 1050000, @@ -135400,11 +148027,13 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2024-12", "release_date": "2026-04-22", - "last_updated": "2026-05-01", + "last_updated": "2026-04-22", "modalities": { "input": [ - "text" + "text", + "pdf" ], "output": [ "text" @@ -135412,9 +148041,9 @@ }, "open_weights": false, "cost": { - "input": 1.0, - "output": 3.0, - "cache_read": 0.19999999999999998 + "input": 0.435, + "output": 0.87, + "cache_read": 0.0036 }, "limit": { "context": 1050000, @@ -135443,11 +148072,12 @@ "open_weights": true, "cost": { "input": 0.6, - "output": 2.2 + "output": 2.2, + "cache_read": 0.11 }, "limit": { - "context": 131072, - "output": 131072 + "context": 128000, + "output": 96000 } }, { @@ -135472,7 +148102,8 @@ "open_weights": true, "cost": { "input": 0.2, - "output": 1.1 + "output": 1.1, + "cache_read": 0.03 }, "limit": { "context": 128000, @@ -135502,11 +148133,12 @@ "open_weights": true, "cost": { "input": 0.6, - "output": 1.8 + "output": 1.8, + "cache_read": 0.11 }, "limit": { "context": 66000, - "output": 66000 + "output": 16000 } }, { @@ -135530,8 +148162,9 @@ }, "open_weights": true, "cost": { - "input": 0.45, - "output": 1.8 + "input": 0.6, + "output": 2.2, + "cache_read": 0.11 }, "limit": { "context": 200000, @@ -135548,7 +148181,7 @@ "temperature": true, "knowledge": "2024-10", "release_date": "2025-09-30", - "last_updated": "2025-09-30", + "last_updated": "2025-12-08", "modalities": { "input": [ "text", @@ -135619,12 +148252,12 @@ }, "open_weights": false, "cost": { - "input": 0.43, - "output": 1.75, - "cache_read": 0.08 + "input": 0.6, + "output": 2.2, + "cache_read": 0.12 }, "limit": { - "context": 202752, + "context": 200000, "output": 120000 } }, @@ -135636,8 +148269,9 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-03-13", - "last_updated": "2026-03-13", + "knowledge": "2025-04", + "release_date": "2026-01-19", + "last_updated": "2026-01-19", "modalities": { "input": [ "text" @@ -135649,7 +148283,7 @@ "open_weights": false, "cost": { "input": 0.07, - "output": 0.39999999999999997 + "output": 0.4 }, "limit": { "context": 200000, @@ -135665,8 +148299,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-01", - "last_updated": "2025-01", + "release_date": "2026-01-19", + "last_updated": "2026-01-19", "modalities": { "input": [ "text" @@ -135695,7 +148329,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-02-12", - "last_updated": "2026-02-19", + "last_updated": "2026-02-12", "modalities": { "input": [ "text" @@ -135706,13 +148340,13 @@ }, "open_weights": true, "cost": { - "input": 1.0, - "output": 3.2, + "input": 0.95, + "output": 3.15, "cache_read": 0.2 }, "limit": { "context": 202800, - "output": 131072 + "output": 131100 } }, { @@ -135724,7 +148358,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-03-15", - "last_updated": "2026-03-17", + "last_updated": "2026-03-16", "modalities": { "input": [ "text" @@ -135753,26 +148387,82 @@ "tool_call": true, "temperature": true, "release_date": "2026-04-07", - "last_updated": "2026-04-16", + "last_updated": "2026-04-07", "modalities": { "input": [ - "text", - "image", - "pdf" + "text" ], "output": [ "text" ] }, "open_weights": false, + "cost": { + "input": 1.3, + "output": 4.3, + "cache_read": 0.26 + }, + "limit": { + "context": 202000, + "output": 202000 + } + }, + { + "id": "vercel/zai/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-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, "cost": { "input": 1.4, "output": 4.4, "cache_read": 0.26 }, "limit": { - "context": 202752, - "output": 202752 + "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, + "output": 128000 } }, { @@ -135784,7 +148474,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-04-01", - "last_updated": "2026-04-03", + "last_updated": "2026-04-01", "modalities": { "input": [ "text", @@ -135934,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", @@ -136245,10 +148964,130 @@ "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", - "family": "kimi-k2.6", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -136266,8 +149105,8 @@ }, "open_weights": true, "cost": { - "input": 0.15, - "output": 0.6 + "input": 0.3, + "output": 1.2 }, "limit": { "context": 262144, @@ -136303,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", @@ -136391,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-03-27", - "last_updated": "2026-03-27", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "modalities": { "input": [ "text" @@ -136408,13 +149218,13 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.85, "output": 3.1 }, "limit": { - "context": 200000, + "context": 393216, "output": 131072 } }, @@ -136449,10 +149259,40 @@ "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", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "reasoning": true, "tool_call": true, @@ -136641,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" @@ -136689,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, @@ -136698,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" @@ -136718,7 +149588,8 @@ "open_weights": true, "cost": { "input": 0.1, - "output": 0.1 + "output": 0.1, + "cache_read": 0.1 }, "limit": { "context": 262144, @@ -136727,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, @@ -136735,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" @@ -136747,7 +149618,8 @@ "open_weights": true, "cost": { "input": 0.1, - "output": 0.1 + "output": 0.1, + "cache_read": 0.1 }, "limit": { "context": 262144, @@ -136763,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" @@ -136775,7 +149647,8 @@ "open_weights": true, "cost": { "input": 0.1, - "output": 0.3 + "output": 0.3, + "cache_read": 0.1 }, "limit": { "context": 262144, @@ -136784,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" @@ -136804,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, @@ -136820,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" @@ -136832,13 +149826,133 @@ "open_weights": true, "cost": { "input": 0.55, - "output": 1.65 + "output": 1.65, + "cache_read": 0.55 }, "limit": { "context": 161000, "output": 161000 } }, + { + "id": "wandb/deepseek-ai/DeepSeek-V4-Flash", + "name": "DeepSeek V4 Flash", + "family": "deepseek", + "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.07 + }, + "limit": { + "context": 1048576, + "output": 1048576 + } + }, + { + "id": "wandb/deepseek-ai/DeepSeek-V4-Pro", + "name": "DeepSeek V4 Pro", + "family": "deepseek", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.74, + "output": 3.48, + "cache_read": 0.14 + }, + "limit": { + "context": 1048576, + "output": 1048576 + } + }, + { + "id": "wandb/google/gemma-4-31B-it", + "name": "Gemma 4 31B", + "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.12, + "output": 0.35, + "cache_read": 0.09 + }, + "limit": { + "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", @@ -136848,7 +149962,7 @@ "tool_call": true, "temperature": true, "release_date": "2024-07-23", - "last_updated": "2026-03-12", + "last_updated": "2024-07-23", "modalities": { "input": [ "text" @@ -136860,7 +149974,8 @@ "open_weights": true, "cost": { "input": 0.8, - "output": 0.8 + "output": 0.8, + "cache_read": 0.8 }, "limit": { "context": 128000, @@ -136869,15 +149984,15 @@ }, { "id": "wandb/meta-llama/Llama-3.1-8B-Instruct", - "name": "Meta-Llama-3.1-8B-Instruct", + "name": "Llama 3.1 8B", "family": "llama", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2023-12", "release_date": "2024-07-23", - "last_updated": "2026-03-12", + "last_updated": "2024-07-23", "modalities": { "input": [ "text" @@ -136889,7 +150004,8 @@ "open_weights": true, "cost": { "input": 0.22, - "output": 0.22 + "output": 0.22, + "cache_read": 0.22 }, "limit": { "context": 128000, @@ -136898,15 +150014,15 @@ }, { "id": "wandb/meta-llama/Llama-3.3-70B-Instruct", - "name": "Llama-3.3-70B-Instruct", + "name": "Llama 3.3 70B", "family": "llama", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2023-12", - "release_date": "2024-12-06", - "last_updated": "2026-03-12", + "release_date": "2024-12-01", + "last_updated": "2024-12-01", "modalities": { "input": [ "text" @@ -136918,54 +150034,25 @@ "open_weights": true, "cost": { "input": 0.71, - "output": 0.71 + "output": 0.71, + "cache_read": 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", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.17, - "output": 0.66 - }, - "limit": { - "context": 64000, - "output": 64000 - } - }, { "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" @@ -136977,7 +150064,8 @@ "open_weights": true, "cost": { "input": 0.08, - "output": 0.35 + "output": 0.35, + "cache_read": 0.08 }, "limit": { "context": 128000, @@ -136987,13 +150075,14 @@ { "id": "wandb/moonshotai/Kimi-K2.5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2", "attachment": true, "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", @@ -137005,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, @@ -137015,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" @@ -137034,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, @@ -137046,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" @@ -137059,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, @@ -137074,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" @@ -137117,24 +150272,25 @@ }, "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, "tool_call": true, "temperature": true, - "release_date": "2026-03-27", - "last_updated": "2026-03-27", + "release_date": "2026-04-07", + "last_updated": "2026-04-07", "modalities": { "input": [ "text" @@ -137143,51 +150299,48 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "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": "x-ai/grok-4.20", - "name": "Grok 4.20 (Reasoning)", - "family": "grok", - "attachment": true, + "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-03-09", - "last_updated": "2026-03-09", + "release_date": "2026-06-16", + "last_updated": "2026-06-16", "modalities": { "input": [ - "text", - "image", - "pdf" + "text" ], "output": [ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { - "input": 1.25, - "output": 2.5, - "cache_read": 0.2 + "input": 1.39, + "output": 4.4, + "cache_read": 0.26 }, "limit": { - "context": 2000000, - "output": 30000 + "context": 262144, + "output": 262144 } }, { - "id": "x-ai/grok-4.20-0309-non", + "id": "x-ai/grok-4.20-0309-non-reasoning", "name": "Grok 4.20 (Non-Reasoning)", "family": "grok", "attachment": true, @@ -137213,7 +150366,38 @@ "cache_read": 0.2 }, "limit": { - "context": 2000000, + "context": 1000000, + "output": 30000 + } + }, + { + "id": "x-ai/grok-4.20-0309-reasoning", + "name": "Grok 4.20 (Reasoning)", + "family": "grok", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-09", + "last_updated": "2026-03-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 + }, + "limit": { + "context": 1000000, "output": 30000 } }, @@ -137244,7 +150428,7 @@ "cache_read": 0.2 }, "limit": { - "context": 2000000, + "context": 1000000, "output": 30000 } }, @@ -137279,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", @@ -137380,6 +150595,7 @@ "input": [ "text", "image", + "video", "pdf" ], "output": [ @@ -137393,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", @@ -137628,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", @@ -137863,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", @@ -138108,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" @@ -138119,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, @@ -138138,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", @@ -138153,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, @@ -138172,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" @@ -138183,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, @@ -138202,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", @@ -138216,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, @@ -138235,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" @@ -138246,15 +151360,76 @@ }, "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, "output": 131072 } }, + { + "id": "xiaomi/mimo-v2.5-pro-ultraspeed", + "name": "MiMo-V2.5-Pro-UltraSpeed", + "family": "mimo", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-12", + "release_date": "2026-06-08", + "last_updated": "2026-06-09", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.305, + "output": 2.61, + "cache_read": 0.0108 + }, + "limit": { + "context": 1048576, + "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", @@ -138285,6 +151460,37 @@ "output": 384000 } }, + { + "id": "xpersona/xpersona-gpt-5.5", + "name": "GPT-5.5", + "family": "gpt", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-12-30", + "release_date": "2026-05-29", + "last_updated": "2026-05-29", + "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": "zai-coding-plan/glm-4.5-air", "name": "GLM-4.5-Air", @@ -138407,6 +151613,36 @@ "output": 131072 } }, + { + "id": "zai-coding-plan/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, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "zai-coding-plan/glm-5v-turbo", "name": "GLM-5V-Turbo", @@ -138727,8 +151963,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-11", - "last_updated": "2026-02-11", + "release_date": "2026-02-12", + "last_updated": "2026-02-12", "modalities": { "input": [ "text" @@ -138787,8 +152023,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-03-27", - "last_updated": "2026-03-27", + "release_date": "2026-04-07", + "last_updated": "2026-04-07", "modalities": { "input": [ "text" @@ -138797,7 +152033,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 1.4, "output": 4.4, @@ -138809,6 +152045,36 @@ "output": 131072 } }, + { + "id": "zai/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, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "zai/glm-5v-turbo", "name": "GLM-5V-Turbo", @@ -138842,6 +152108,62 @@ "output": 131072 } }, + { + "id": "zeldoc/z-code", + "name": "Z-Code", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-04-15", + "last_updated": "2026-04-15", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "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": 65536 + } + }, { "id": "zenmux/anthropic/claude-3.5-haiku", "name": "Claude 3.5 Haiku", @@ -138905,6 +152227,39 @@ "output": 64000 } }, + { + "id": "zenmux/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": "zenmux/anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5", @@ -139095,6 +152450,39 @@ "output": 128000 } }, + { + "id": "zenmux/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, + "cache_write": 6.25 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "zenmux/anthropic/claude-sonnet-4", "name": "Claude Sonnet 4", @@ -139190,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", @@ -139498,6 +152952,40 @@ "output": 64000 } }, + { + "id": "zenmux/google/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": "zenmux/google/gemini-3.1-flash-lite-preview", "name": "Gemini 3.1 Flash Lite Preview", @@ -139562,6 +153050,40 @@ "output": 64000 } }, + { + "id": "zenmux/google/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": "zenmux/inclusionai/ling-1t", "name": "Ling-1T", @@ -139620,6 +153142,35 @@ "output": 64000 } }, + { + "id": "zenmux/inclusionai/ring-2.6-1t", + "name": "inclusionAI: Ring-2.6-1T", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-12-31", + "release_date": "2026-05-07", + "last_updated": "2026-05-14", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 2.5, + "cache_read": 0.06 + }, + "limit": { + "context": 262000, + "output": 65000 + } + }, { "id": "zenmux/kuaishou/kat-coder-pro-v2", "name": "KAT-Coder-Pro-V2", @@ -139824,6 +153375,36 @@ "output": 131070 } }, + { + "id": "zenmux/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.6, + "output": 2.4 + }, + "limit": { + "context": 512000, + "output": 128000 + } + }, { "id": "zenmux/moonshotai/kimi-k2", "name": "Kimi K2 0905", @@ -139973,6 +153554,70 @@ "output": 262140 } }, + { + "id": "zenmux/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.95, + "output": 4.0, + "cache_read": 0.16 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "zenmux/moonshotai/kimi-k2.7-code-free", + "name": "Kimi K2.7 Code (Free)", + "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, + "cache_read": 0.0 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "zenmux/openai/gpt-5", "name": "GPT-5", @@ -140450,6 +154095,37 @@ "output": 128000 } }, + { + "id": "zenmux/openai/gpt-5.5-instant", + "name": "GPT-5.5 Instant", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-12-01", + "release_date": "2026-05-05", + "last_updated": "2026-05-28", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 30.0, + "cache_read": 0.5 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, { "id": "zenmux/openai/gpt-5.5-pro", "name": "GPT-5.5 Pro", @@ -140626,6 +154302,68 @@ "output": 64000 } }, + { + "id": "zenmux/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.5, + "cache_write": 3.125 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "zenmux/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" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.4, + "output": 1.6, + "cache_read": 0.08, + "cache_write": 0.5 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, { "id": "zenmux/sapiens-ai/agnes-1.5-lite", "name": "Agnes 1.5 Lite", @@ -140739,31 +154477,61 @@ } }, { - "id": "zenmux/stepfun/step-3.5-flash-free", - "name": "Step 3.5 Flash (Free)", - "attachment": false, + "id": "zenmux/stepfun/step-3.7-flash", + "name": "Step 3.7 Flash", + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-01-01", - "release_date": "2026-02-02", - "last_updated": "2026-02-02", + "knowledge": "2026-01-01", + "release_date": "2026-05-29", + "last_updated": "2026-05-29", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, - "open_weights": false, + "open_weights": true, + "cost": { + "input": 0.2, + "output": 1.15 + }, + "limit": { + "context": 256000, + "output": 256000 + } + }, + { + "id": "zenmux/stepfun/step-3.7-flash-free", + "name": "Step 3.7 Flash (Free)", + "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.0, "output": 0.0 }, "limit": { "context": 256000, - "output": 64000 + "output": 256000 } }, { @@ -141073,7 +154841,7 @@ } }, { - "id": "zenmux/x-ai/grok-4.1-fast-non", + "id": "zenmux/x-ai/grok-4.1-fast-non-reasoning", "name": "Grok 4.1 Fast Non Reasoning", "attachment": true, "reasoning": false, @@ -141133,7 +154901,7 @@ } }, { - "id": "zenmux/x-ai/grok-4.2-fast-non", + "id": "zenmux/x-ai/grok-4.2-fast-non-reasoning", "name": "Grok 4.2 Fast Non Reasoning", "attachment": true, "reasoning": false, @@ -141162,6 +154930,69 @@ "output": 30000 } }, + { + "id": "zenmux/x-ai/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": 1.25, + "output": 2.5, + "cache_read": 0.2, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 1000000 + } + }, + { + "id": "zenmux/x-ai/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": 1.0, + "output": 2.0, + "cache_read": 0.2 + }, + "limit": { + "context": 256000, + "output": 256000 + } + }, { "id": "zenmux/x-ai/grok-code-fast-1", "name": "Grok Code Fast 1", @@ -141698,6 +155529,64 @@ "output": 131072 } }, + { + "id": "zenmux/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.4, + "output": 4.5, + "cache_read": 0.26 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "zenmux/z-ai/glm-5.2-free", + "name": "GLM 5.2 (Free)", + "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, + "cache_read": 0.0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "zenmux/z-ai/glm-5v-turbo", "name": "GLM 5V Turbo", @@ -141882,6 +155771,36 @@ "output": 131072 } }, + { + "id": "zhipuai-coding-plan/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, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "zhipuai-coding-plan/glm-5v-turbo", "name": "GLM-5V-Turbo", @@ -142244,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": { @@ -142254,6 +156173,36 @@ "output": 131072 } }, + { + "id": "zhipuai/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": false, + "cost": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.26, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "zhipuai/glm-5v-turbo", "name": "GLM-5V-Turbo", diff --git a/crates/goose/src/providers/canonical/data/provider_metadata.json b/crates/goose-provider-types/src/canonical/data/provider_metadata.json similarity index 84% rename from crates/goose/src/providers/canonical/data/provider_metadata.json rename to crates/goose-provider-types/src/canonical/data/provider_metadata.json index cb0e228bb2..94656c79be 100644 --- a/crates/goose/src/providers/canonical/data/provider_metadata.json +++ b/crates/goose-provider-types/src/canonical/data/provider_metadata.json @@ -1,103 +1,4 @@ [ - { - "id": "upstage", - "display_name": "Upstage", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.upstage.ai/v1/solar", - "doc": "https://developers.upstage.ai/docs/apis/chat", - "env": [ - "UPSTAGE_API_KEY" - ], - "model_count": 3 - }, - { - "id": "clarifai", - "display_name": "Clarifai", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.clarifai.com/v2/ext/openai/v1", - "doc": "https://docs.clarifai.com/compute/inference/", - "env": [ - "CLARIFAI_PAT" - ], - "model_count": 12 - }, - { - "id": "ollama-cloud", - "display_name": "Ollama Cloud", - "npm": "@ai-sdk/openai-compatible", - "api": "https://ollama.com/v1", - "doc": "https://docs.ollama.com/cloud", - "env": [ - "OLLAMA_API_KEY" - ], - "model_count": 40 - }, - { - "id": "the-grid-ai", - "display_name": "The Grid AI", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.thegrid.ai/v1", - "doc": "https://thegrid.ai/docs", - "env": [ - "THEGRIDAI_API_KEY" - ], - "model_count": 9 - }, - { - "id": "fireworks-ai", - "display_name": "Fireworks AI", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.fireworks.ai/inference/v1/", - "doc": "https://fireworks.ai/docs/", - "env": [ - "FIREWORKS_API_KEY" - ], - "model_count": 12 - }, - { - "id": "ambient", - "display_name": "Ambient", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.ambient.xyz/v1", - "doc": "https://ambient.xyz", - "env": [ - "AMBIENT_API_KEY" - ], - "model_count": 2 - }, - { - "id": "stackit", - "display_name": "STACKIT", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.openai-compat.model-serving.eu01.onstackit.cloud/v1", - "doc": "https://docs.stackit.cloud/products/data-and-ai/ai-model-serving/basics/available-shared-models", - "env": [ - "STACKIT_API_KEY" - ], - "model_count": 8 - }, - { - "id": "ovhcloud", - "display_name": "OVHcloud AI Endpoints", - "npm": "@ai-sdk/openai-compatible", - "api": "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1", - "doc": "https://www.ovhcloud.com/en/public-cloud/ai-endpoints/catalog//", - "env": [ - "OVHCLOUD_API_KEY" - ], - "model_count": 15 - }, - { - "id": "iflowcn", - "display_name": "iFlow", - "npm": "@ai-sdk/openai-compatible", - "api": "https://apis.iflow.cn/v1", - "doc": "https://platform.iflow.cn/en/docs", - "env": [ - "IFLOW_API_KEY" - ], - "model_count": 14 - }, { "id": "302ai", "display_name": "302.AI", @@ -110,15 +11,37 @@ "model_count": 97 }, { - "id": "nano-gpt", - "display_name": "NanoGPT", + "id": "abacus", + "display_name": "Abacus", "npm": "@ai-sdk/openai-compatible", - "api": "https://nano-gpt.com/api/v1", - "doc": "https://docs.nano-gpt.com", + "api": "https://routellm.abacus.ai/v1", + "doc": "https://abacus.ai/help/api", "env": [ - "NANO_GPT_API_KEY" + "ABACUS_API_KEY" ], - "model_count": 525 + "model_count": 65 + }, + { + "id": "abliteration-ai", + "display_name": "abliteration.ai", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.abliteration.ai/v1", + "doc": "https://docs.abliteration.ai/models", + "env": [ + "ABLIT_KEY" + ], + "model_count": 1 + }, + { + "id": "alibaba", + "display_name": "Alibaba", + "npm": "@ai-sdk/openai-compatible", + "api": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + "doc": "https://www.alibabacloud.com/help/en/model-studio/models", + "env": [ + "DASHSCOPE_API_KEY" + ], + "model_count": 51 }, { "id": "alibaba-cn", @@ -129,29 +52,95 @@ "env": [ "DASHSCOPE_API_KEY" ], - "model_count": 82 + "model_count": 84 }, { - "id": "digitalocean", - "display_name": "DigitalOcean", + "id": "alibaba-coding-plan", + "display_name": "Alibaba Coding Plan", "npm": "@ai-sdk/openai-compatible", - "api": "https://inference.do-ai.run/v1", - "doc": "https://docs.digitalocean.com/products/gradient-ai-platform/details/models/", + "api": "https://coding-intl.dashscope.aliyuncs.com/v1", + "doc": "https://www.alibabacloud.com/help/en/model-studio/coding-plan", "env": [ - "DIGITALOCEAN_ACCESS_TOKEN" + "ALIBABA_CODING_PLAN_API_KEY" ], - "model_count": 78 + "model_count": 12 }, { - "id": "submodel", - "display_name": "submodel", + "id": "alibaba-coding-plan-cn", + "display_name": "Alibaba Coding Plan (China)", "npm": "@ai-sdk/openai-compatible", - "api": "https://llm.submodel.ai/v1", - "doc": "https://submodel.gitbook.io", + "api": "https://coding.dashscope.aliyuncs.com/v1", + "doc": "https://help.aliyun.com/zh/model-studio/coding-plan", "env": [ - "SUBMODEL_INSTAGEN_ACCESS_KEY" + "ALIBABA_CODING_PLAN_API_KEY" ], - "model_count": 9 + "model_count": 12 + }, + { + "id": "alibaba-token-plan", + "display_name": "Alibaba Token Plan", + "npm": "@ai-sdk/openai-compatible", + "api": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + "doc": "https://www.alibabacloud.com/help/en/model-studio/token-plan-overview", + "env": [ + "ALIBABA_TOKEN_PLAN_API_KEY" + ], + "model_count": 18 + }, + { + "id": "alibaba-token-plan-cn", + "display_name": "Alibaba Token Plan (China)", + "npm": "@ai-sdk/openai-compatible", + "api": "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + "doc": "https://www.alibabacloud.com/help/zh/model-studio/token-plan-overview", + "env": [ + "ALIBABA_TOKEN_PLAN_API_KEY" + ], + "model_count": 18 + }, + { + "id": "ambient", + "display_name": "Ambient", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.ambient.xyz/v1", + "doc": "https://ambient.xyz", + "env": [ + "AMBIENT_API_KEY" + ], + "model_count": 3 + }, + { + "id": "anyapi", + "display_name": "AnyAPI", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.anyapi.ai/v1", + "doc": "https://docs.anyapi.ai", + "env": [ + "ANYAPI_API_KEY" + ], + "model_count": 30 + }, + { + "id": "atomic-chat", + "display_name": "Atomic Chat", + "npm": "@ai-sdk/openai-compatible", + "api": "http://127.0.0.1:1337/v1", + "doc": "https://atomic.chat", + "env": [ + "ATOMIC_CHAT_API_KEY" + ], + "model_count": 5 + }, + { + "id": "auriko", + "display_name": "Auriko", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.auriko.ai/v1", + "doc": "https://docs.auriko.ai", + "env": [ + "AURIKO_API_KEY" + ], + "model_count": 15 }, { "id": "bailing", @@ -165,125 +154,26 @@ "model_count": 2 }, { - "id": "kimi-for-coding", - "display_name": "Kimi For Coding", - "npm": "@ai-sdk/anthropic", - "api": "https://api.kimi.com/coding/v1", - "doc": "https://www.kimi.com/coding/docs/en/third-party-agents.html", - "env": [ - "KIMI_API_KEY" - ], - "model_count": 3 - }, - { - "id": "dinference", - "display_name": "DInference", + "id": "baseten", + "display_name": "Baseten", "npm": "@ai-sdk/openai-compatible", - "api": "https://api.dinference.com/v1", - "doc": "https://dinference.com", + "api": "https://inference.baseten.co/v1", + "doc": "https://docs.baseten.co/inference/model-apis/overview", "env": [ - "DINFERENCE_API_KEY" - ], - "model_count": 5 - }, - { - "id": "novita-ai", - "display_name": "NovitaAI", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.novita.ai/openai", - "doc": "https://novita.ai/docs/guides/introduction", - "env": [ - "NOVITA_API_KEY" - ], - "model_count": 104 - }, - { - "id": "kilo", - "display_name": "Kilo Gateway", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.kilo.ai/api/gateway", - "doc": "https://kilo.ai", - "env": [ - "KILO_API_KEY" - ], - "model_count": 345 - }, - { - "id": "regolo-ai", - "display_name": "Regolo AI", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.regolo.ai/v1", - "doc": "https://docs.regolo.ai/", - "env": [ - "REGOLO_API_KEY" + "BASETEN_API_KEY" ], "model_count": 13 }, { - "id": "deepseek", - "display_name": "DeepSeek", + "id": "berget", + "display_name": "Berget.AI", "npm": "@ai-sdk/openai-compatible", - "api": "https://api.deepseek.com", - "doc": "https://api-docs.deepseek.com/quick_start/pricing", + "api": "https://api.berget.ai/v1", + "doc": "https://api.berget.ai", "env": [ - "DEEPSEEK_API_KEY" + "BERGET_API_KEY" ], - "model_count": 4 - }, - { - "id": "orcarouter", - "display_name": "OrcaRouter", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.orcarouter.ai/v1", - "doc": "https://docs.orcarouter.ai", - "env": [ - "ORCAROUTER_API_KEY" - ], - "model_count": 81 - }, - { - "id": "moonshotai-cn", - "display_name": "Moonshot AI (China)", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.moonshot.cn/v1", - "doc": "https://platform.moonshot.cn/docs/api/chat", - "env": [ - "MOONSHOT_API_KEY" - ], - "model_count": 7 - }, - { - "id": "minimax-cn-coding-plan", - "display_name": "MiniMax Token Plan (minimaxi.com)", - "npm": "@ai-sdk/anthropic", - "api": "https://api.minimaxi.com/anthropic/v1", - "doc": "https://platform.minimaxi.com/docs/token-plan/intro", - "env": [ - "MINIMAX_API_KEY" - ], - "model_count": 6 - }, - { - "id": "inception", - "display_name": "Inception", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.inceptionlabs.ai/v1/", - "doc": "https://platform.inceptionlabs.ai/docs", - "env": [ - "INCEPTION_API_KEY" - ], - "model_count": 2 - }, - { - "id": "kuae-cloud-coding-plan", - "display_name": "KUAE Cloud Coding Plan", - "npm": "@ai-sdk/openai-compatible", - "api": "https://coding-plan-endpoint.kuaecloud.net/v1", - "doc": "https://docs.mthreads.com/kuaecloud/kuaecloud-doc-online/coding_plan/", - "env": [ - "KUAE_API_KEY" - ], - "model_count": 1 + "model_count": 8 }, { "id": "chutes", @@ -294,7 +184,63 @@ "env": [ "CHUTES_API_KEY" ], - "model_count": 39 + "model_count": 13 + }, + { + "id": "clarifai", + "display_name": "Clarifai", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.clarifai.com/v2/ext/openai/v1", + "doc": "https://docs.clarifai.com/compute/inference/", + "env": [ + "CLARIFAI_PAT" + ], + "model_count": 12 + }, + { + "id": "claudinio", + "display_name": "Claudinio", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.claudin.io/v1", + "doc": "https://claudin.io", + "env": [ + "CLAUDINIO_API_KEY" + ], + "model_count": 2 + }, + { + "id": "cloudferro-sherlock", + "display_name": "CloudFerro Sherlock", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api-sherlock.cloudferro.com/openai/v1/", + "doc": "https://docs.sherlock.cloudferro.com/", + "env": [ + "CLOUDFERRO_SHERLOCK_API_KEY" + ], + "model_count": 5 + }, + { + "id": "cloudflare-workers-ai", + "display_name": "Cloudflare Workers AI", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", + "doc": "https://developers.cloudflare.com/workers-ai/models/", + "env": [ + "CLOUDFLARE_ACCOUNT_ID", + "CLOUDFLARE_API_KEY" + ], + "model_count": 22 + }, + { + "id": "cortecs", + "display_name": "Cortecs", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.cortecs.ai/v1", + "doc": "https://api.cortecs.ai/v1/models", + "env": [ + "CORTECS_API_KEY" + ], + "model_count": 56 }, { "id": "crof", @@ -305,51 +251,18 @@ "env": [ "CROF_API_KEY" ], - "model_count": 21 + "model_count": 23 }, { - "id": "frogbot", - "display_name": "FrogBot", + "id": "crossmodel", + "display_name": "CrossModel", "npm": "@ai-sdk/openai-compatible", - "api": "https://app.frogbot.ai/api/v1", - "doc": "https://docs.frogbot.ai", + "api": "https://api.crossmodel.ai/v1", + "doc": "https://www.crossmodel.ai/docs", "env": [ - "FROGBOT_API_KEY" + "CROSSMODEL_API_KEY" ], - "model_count": 26 - }, - { - "id": "alibaba", - "display_name": "Alibaba", - "npm": "@ai-sdk/openai-compatible", - "api": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", - "doc": "https://www.alibabacloud.com/help/en/model-studio/models", - "env": [ - "DASHSCOPE_API_KEY" - ], - "model_count": 50 - }, - { - "id": "xiaomi", - "display_name": "Xiaomi", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.xiaomimimo.com/v1", - "doc": "https://platform.xiaomimimo.com/#/docs", - "env": [ - "XIAOMI_API_KEY" - ], - "model_count": 5 - }, - { - "id": "vivgrid", - "display_name": "Vivgrid", - "npm": "@ai-sdk/openai", - "api": "https://api.vivgrid.com/v1", - "doc": "https://docs.vivgrid.com/models", - "env": [ - "VIVGRID_API_KEY" - ], - "model_count": 13 + "model_count": 37 }, { "id": "databricks", @@ -364,37 +277,92 @@ "model_count": 25 }, { - "id": "siliconflow-cn", - "display_name": "SiliconFlow (China)", + "id": "deepseek", + "display_name": "DeepSeek", "npm": "@ai-sdk/openai-compatible", - "api": "https://api.siliconflow.cn/v1", - "doc": "https://cloud.siliconflow.com/models", + "api": "https://api.deepseek.com", + "doc": "https://api-docs.deepseek.com/quick_start/pricing", "env": [ - "SILICONFLOW_CN_API_KEY" + "DEEPSEEK_API_KEY" + ], + "model_count": 4 + }, + { + "id": "digitalocean", + "display_name": "DigitalOcean", + "npm": "@ai-sdk/openai-compatible", + "api": "https://inference.do-ai.run/v1", + "doc": "https://docs.digitalocean.com/products/gradient-ai-platform/details/models/", + "env": [ + "DIGITALOCEAN_ACCESS_TOKEN" ], "model_count": 82 }, { - "id": "zhipuai-coding-plan", - "display_name": "Zhipu AI Coding Plan", + "id": "dinference", + "display_name": "DInference", "npm": "@ai-sdk/openai-compatible", - "api": "https://open.bigmodel.cn/api/coding/paas/v4", - "doc": "https://docs.bigmodel.cn/cn/coding-plan/overview", + "api": "https://api.dinference.com/v1", + "doc": "https://dinference.com", "env": [ - "ZHIPU_API_KEY" + "DINFERENCE_API_KEY" ], - "model_count": 6 + "model_count": 5 }, { - "id": "neuralwatt", - "display_name": "Neuralwatt", + "id": "drun", + "display_name": "D.Run (China)", "npm": "@ai-sdk/openai-compatible", - "api": "https://api.neuralwatt.com/v1", - "doc": "https://portal.neuralwatt.com/docs", + "api": "https://chat.d.run/v1", + "doc": "https://www.d.run", "env": [ - "NEURALWATT_API_KEY" + "DRUN_API_KEY" ], - "model_count": 14 + "model_count": 3 + }, + { + "id": "evroc", + "display_name": "evroc", + "npm": "@ai-sdk/openai-compatible", + "api": "https://models.think.evroc.com/v1", + "doc": "https://docs.evroc.com/products/think/overview.html", + "env": [ + "EVROC_API_KEY" + ], + "model_count": 15 + }, + { + "id": "fastrouter", + "display_name": "FastRouter", + "npm": "@ai-sdk/openai-compatible", + "api": "https://go.fastrouter.ai/api/v1", + "doc": "https://fastrouter.ai/models", + "env": [ + "FASTROUTER_API_KEY" + ], + "model_count": 47 + }, + { + "id": "fireworks-ai", + "display_name": "Fireworks AI", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.fireworks.ai/inference/v1/", + "doc": "https://fireworks.ai/docs/", + "env": [ + "FIREWORKS_API_KEY" + ], + "model_count": 16 + }, + { + "id": "freemodel", + "display_name": "FreeModel", + "npm": "@ai-sdk/anthropic", + "api": "https://cc.freemodel.dev/v1", + "doc": "https://freemodel.dev", + "env": [ + "FREEMODEL_API_KEY" + ], + "model_count": 10 }, { "id": "friendli", @@ -407,6 +375,17 @@ ], "model_count": 6 }, + { + "id": "frogbot", + "display_name": "FrogBot", + "npm": "@ai-sdk/openai-compatible", + "api": "https://app.frogbot.ai/api/v1", + "doc": "https://docs.frogbot.ai", + "env": [ + "FROGBOT_API_KEY" + ], + "model_count": 26 + }, { "id": "github-copilot", "display_name": "GitHub Copilot", @@ -416,7 +395,95 @@ "env": [ "GITHUB_TOKEN" ], - "model_count": 22 + "model_count": 25 + }, + { + "id": "github-models", + "display_name": "GitHub Models", + "npm": "@ai-sdk/openai-compatible", + "api": "https://models.github.ai/inference", + "doc": "https://docs.github.com/en/github-models", + "env": [ + "GITHUB_TOKEN" + ], + "model_count": 55 + }, + { + "id": "gmicloud", + "display_name": "GMI Cloud", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.gmi-serving.com/v1", + "doc": "https://docs.gmicloud.ai/inference-engine/api-reference/llm-api-reference", + "env": [ + "GMICLOUD_API_KEY" + ], + "model_count": 13 + }, + { + "id": "helicone", + "display_name": "Helicone", + "npm": "@ai-sdk/openai-compatible", + "api": "https://ai-gateway.helicone.ai/v1", + "doc": "https://helicone.ai/models", + "env": [ + "HELICONE_API_KEY" + ], + "model_count": 90 + }, + { + "id": "hpc-ai", + "display_name": "HPC-AI", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.hpc-ai.com/inference/v1", + "doc": "https://www.hpc-ai.com/doc/docs/quickstart/", + "env": [ + "HPC_AI_API_KEY" + ], + "model_count": 3 + }, + { + "id": "huggingface", + "display_name": "Hugging Face", + "npm": "@ai-sdk/openai-compatible", + "api": "https://router.huggingface.co/v1", + "doc": "https://huggingface.co/docs/inference-providers", + "env": [ + "HF_TOKEN" + ], + "model_count": 51 + }, + { + "id": "iflowcn", + "display_name": "iFlow", + "npm": "@ai-sdk/openai-compatible", + "api": "https://apis.iflow.cn/v1", + "doc": "https://platform.iflow.cn/en/docs", + "env": [ + "IFLOW_API_KEY" + ], + "model_count": 14 + }, + { + "id": "inception", + "display_name": "Inception", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.inceptionlabs.ai/v1/", + "doc": "https://platform.inceptionlabs.ai/docs", + "env": [ + "INCEPTION_API_KEY" + ], + "model_count": 2 + }, + { + "id": "inceptron", + "display_name": "Inceptron", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.inceptron.io/v1", + "doc": "https://docs.inceptron.io", + "env": [ + "INCEPTRON_API_KEY" + ], + "model_count": 6 }, { "id": "inference", @@ -430,15 +497,456 @@ "model_count": 9 }, { - "id": "huggingface", - "display_name": "Hugging Face", + "id": "io-net", + "display_name": "IO.NET", "npm": "@ai-sdk/openai-compatible", - "api": "https://router.huggingface.co/v1", - "doc": "https://huggingface.co/docs/inference-providers", + "api": "https://api.intelligence.io.solutions/api/v1", + "doc": "https://io.net/docs/guides/intelligence/io-intelligence", "env": [ - "HF_TOKEN" + "IOINTELLIGENCE_API_KEY" ], - "model_count": 24 + "model_count": 17 + }, + { + "id": "jiekou", + "display_name": "Jiekou.AI", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.jiekou.ai/openai", + "doc": "https://docs.jiekou.ai/docs/support/quickstart?utm_source=github_models.dev", + "env": [ + "JIEKOU_API_KEY" + ], + "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", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.kilo.ai/api/gateway", + "doc": "https://kilo.ai", + "env": [ + "KILO_API_KEY" + ], + "model_count": 346 + }, + { + "id": "kimi-for-coding", + "display_name": "Kimi For Coding", + "npm": "@ai-sdk/anthropic", + "api": "https://api.kimi.com/coding/v1", + "doc": "https://www.kimi.com/code/docs/en/third-party-tools/other-coding-agents.html", + "env": [ + "KIMI_API_KEY" + ], + "model_count": 4 + }, + { + "id": "kuae-cloud-coding-plan", + "display_name": "KUAE Cloud Coding Plan", + "npm": "@ai-sdk/openai-compatible", + "api": "https://coding-plan-endpoint.kuaecloud.net/v1", + "doc": "https://docs.mthreads.com/kuaecloud/kuaecloud-doc-online/coding_plan/", + "env": [ + "KUAE_API_KEY" + ], + "model_count": 1 + }, + { + "id": "lilac", + "display_name": "Lilac", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.getlilac.com/v1", + "doc": "https://docs.getlilac.com/inference/models", + "env": [ + "LILAC_API_KEY" + ], + "model_count": 4 + }, + { + "id": "llmgateway", + "display_name": "LLM Gateway", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.llmgateway.io/v1", + "doc": "https://llmgateway.io/docs", + "env": [ + "LLMGATEWAY_API_KEY" + ], + "model_count": 181 + }, + { + "id": "llmtr", + "display_name": "LLMTR", + "npm": "@ai-sdk/openai-compatible", + "api": "https://llmtr.com/v1", + "doc": "https://llmtr.com/docs", + "env": [ + "LLMTR_API_KEY" + ], + "model_count": 6 + }, + { + "id": "lmstudio", + "display_name": "LMStudio", + "npm": "@ai-sdk/openai-compatible", + "api": "http://127.0.0.1:1234/v1", + "doc": "https://lmstudio.ai/models", + "env": [ + "LMSTUDIO_API_KEY" + ], + "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", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.lucidquery.com/v1", + "doc": "https://lucidquery.com/docs", + "env": [ + "LUCIDQUERY_API_KEY" + ], + "model_count": 4 + }, + { + "id": "meganova", + "display_name": "Meganova", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.meganova.ai/v1", + "doc": "https://docs.meganova.ai", + "env": [ + "MEGANOVA_API_KEY" + ], + "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", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.llama.com/compat/v1/", + "doc": "https://llama.developer.meta.com/docs/models", + "env": [ + "LLAMA_API_KEY" + ], + "model_count": 7 + }, + { + "id": "minimax", + "display_name": "MiniMax (minimax.io)", + "npm": "@ai-sdk/anthropic", + "api": "https://api.minimax.io/anthropic/v1", + "doc": "https://platform.minimax.io/docs/guides/quickstart", + "env": [ + "MINIMAX_API_KEY" + ], + "model_count": 7 + }, + { + "id": "minimax-cn", + "display_name": "MiniMax (minimaxi.com)", + "npm": "@ai-sdk/anthropic", + "api": "https://api.minimaxi.com/anthropic/v1", + "doc": "https://platform.minimaxi.com/docs/guides/quickstart", + "env": [ + "MINIMAX_API_KEY" + ], + "model_count": 7 + }, + { + "id": "minimax-cn-coding-plan", + "display_name": "MiniMax Token Plan (minimaxi.com)", + "npm": "@ai-sdk/anthropic", + "api": "https://api.minimaxi.com/anthropic/v1", + "doc": "https://platform.minimaxi.com/docs/token-plan/intro", + "env": [ + "MINIMAX_API_KEY" + ], + "model_count": 7 + }, + { + "id": "minimax-coding-plan", + "display_name": "MiniMax Token Plan (minimax.io)", + "npm": "@ai-sdk/anthropic", + "api": "https://api.minimax.io/anthropic/v1", + "doc": "https://platform.minimax.io/docs/token-plan/intro", + "env": [ + "MINIMAX_API_KEY" + ], + "model_count": 7 + }, + { + "id": "mixlayer", + "display_name": "Mixlayer", + "npm": "@ai-sdk/openai-compatible", + "api": "https://models.mixlayer.ai/v1", + "doc": "https://docs.mixlayer.com", + "env": [ + "MIXLAYER_API_KEY" + ], + "model_count": 5 + }, + { + "id": "moark", + "display_name": "Moark", + "npm": "@ai-sdk/openai-compatible", + "api": "https://moark.com/v1", + "doc": "https://moark.com/docs/openapi/v1#tag/%E6%96%87%E6%9C%AC%E7%94%9F%E6%88%90", + "env": [ + "MOARK_API_KEY" + ], + "model_count": 2 + }, + { + "id": "modelscope", + "display_name": "ModelScope", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api-inference.modelscope.cn/v1", + "doc": "https://modelscope.cn/docs/model-service/API-Inference/intro", + "env": [ + "MODELSCOPE_API_KEY" + ], + "model_count": 7 + }, + { + "id": "moonshotai", + "display_name": "Moonshot AI", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.moonshot.ai/v1", + "doc": "https://platform.moonshot.ai/docs/api/chat", + "env": [ + "MOONSHOT_API_KEY" + ], + "model_count": 9 + }, + { + "id": "moonshotai-cn", + "display_name": "Moonshot AI (China)", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.moonshot.cn/v1", + "doc": "https://platform.moonshot.cn/docs/api/chat", + "env": [ + "MOONSHOT_API_KEY" + ], + "model_count": 9 + }, + { + "id": "morph", + "display_name": "Morph", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.morphllm.com/v1", + "doc": "https://docs.morphllm.com/api-reference/introduction", + "env": [ + "MORPH_API_KEY" + ], + "model_count": 3 + }, + { + "id": "nano-gpt", + "display_name": "NanoGPT", + "npm": "@ai-sdk/openai-compatible", + "api": "https://nano-gpt.com/api/v1", + "doc": "https://docs.nano-gpt.com", + "env": [ + "NANO_GPT_API_KEY" + ], + "model_count": 617 + }, + { + "id": "nearai", + "display_name": "NEAR AI Cloud", + "npm": "@ai-sdk/openai-compatible", + "api": "https://cloud-api.near.ai/v1", + "doc": "https://docs.near.ai/", + "env": [ + "NEARAI_API_KEY" + ], + "model_count": 37 + }, + { + "id": "nebius", + "display_name": "Nebius Token Factory", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.tokenfactory.nebius.com/v1", + "doc": "https://docs.tokenfactory.nebius.com/", + "env": [ + "NEBIUS_API_KEY" + ], + "model_count": 30 + }, + { + "id": "neon", + "display_name": "Neon", + "npm": "@ai-sdk/openai-compatible", + "api": "${NEON_AI_GATEWAY_BASE_URL}/ai-gateway/mlflow/v1", + "doc": "https://neon.com/docs", + "env": [ + "NEON_AI_GATEWAY_BASE_URL", + "NEON_AI_GATEWAY_TOKEN" + ], + "model_count": 36 + }, + { + "id": "neuralwatt", + "display_name": "Neuralwatt", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.neuralwatt.com/v1", + "doc": "https://portal.neuralwatt.com/docs", + "env": [ + "NEURALWATT_API_KEY" + ], + "model_count": 18 + }, + { + "id": "nova", + "display_name": "Nova", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.nova.amazon.com/v1", + "doc": "https://nova.amazon.com/dev/documentation", + "env": [ + "NOVA_API_KEY" + ], + "model_count": 2 + }, + { + "id": "novita-ai", + "display_name": "NovitaAI", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.novita.ai/openai", + "doc": "https://novita.ai/docs/guides/introduction", + "env": [ + "NOVITA_API_KEY" + ], + "model_count": 105 + }, + { + "id": "nvidia", + "display_name": "Nvidia", + "npm": "@ai-sdk/openai-compatible", + "api": "https://integrate.api.nvidia.com/v1", + "doc": "https://docs.api.nvidia.com/nim/", + "env": [ + "NVIDIA_API_KEY" + ], + "model_count": 84 + }, + { + "id": "ollama-cloud", + "display_name": "Ollama Cloud", + "npm": "@ai-sdk/openai-compatible", + "api": "https://ollama.com/v1", + "doc": "https://docs.ollama.com/cloud", + "env": [ + "OLLAMA_API_KEY" + ], + "model_count": 43 + }, + { + "id": "opencode", + "display_name": "OpenCode Zen", + "npm": "@ai-sdk/openai-compatible", + "api": "https://opencode.ai/zen/v1", + "doc": "https://opencode.ai/docs/zen", + "env": [ + "OPENCODE_API_KEY" + ], + "model_count": 76 + }, + { + "id": "opencode-go", + "display_name": "OpenCode Go", + "npm": "@ai-sdk/openai-compatible", + "api": "https://opencode.ai/zen/go/v1", + "doc": "https://opencode.ai/docs/zen", + "env": [ + "OPENCODE_API_KEY" + ], + "model_count": 19 + }, + { + "id": "orcarouter", + "display_name": "OrcaRouter", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.orcarouter.ai/v1", + "doc": "https://docs.orcarouter.ai", + "env": [ + "ORCAROUTER_API_KEY" + ], + "model_count": 81 + }, + { + "id": "ovhcloud", + "display_name": "OVHcloud AI Endpoints", + "npm": "@ai-sdk/openai-compatible", + "api": "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1", + "doc": "https://www.ovhcloud.com/en/public-cloud/ai-endpoints/catalog//", + "env": [ + "OVHCLOUD_API_KEY" + ], + "model_count": 14 + }, + { + "id": "perplexity-agent", + "display_name": "Perplexity Agent", + "npm": "@ai-sdk/openai", + "api": "https://api.perplexity.ai/v1", + "doc": "https://docs.perplexity.ai/docs/agent-api/models", + "env": [ + "PERPLEXITY_API_KEY" + ], + "model_count": 18 + }, + { + "id": "poe", + "display_name": "Poe", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.poe.com/v1", + "doc": "https://creator.poe.com/docs/external-applications/openai-compatible-api", + "env": [ + "POE_API_KEY" + ], + "model_count": 137 + }, + { + "id": "poolside", + "display_name": "Poolside", + "npm": "@ai-sdk/openai-compatible", + "api": "https://inference.poolside.ai/v1", + "doc": "https://platform.poolside.ai", + "env": [ + "POOLSIDE_API_KEY" + ], + "model_count": 2 }, { "id": "privatemode-ai", @@ -452,128 +960,6 @@ ], "model_count": 5 }, - { - "id": "snowflake-cortex", - "display_name": "Snowflake Cortex", - "npm": "@ai-sdk/openai-compatible", - "api": "https://${SNOWFLAKE_ACCOUNT}.snowflakecomputing.com/api/v2/cortex/v1", - "doc": "https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-rest-api", - "env": [ - "SNOWFLAKE_ACCOUNT", - "SNOWFLAKE_CORTEX_PAT" - ], - "model_count": 11 - }, - { - "id": "moonshotai", - "display_name": "Moonshot AI", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.moonshot.ai/v1", - "doc": "https://platform.moonshot.ai/docs/api/chat", - "env": [ - "MOONSHOT_API_KEY" - ], - "model_count": 7 - }, - { - "id": "llmgateway", - "display_name": "LLM Gateway", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.llmgateway.io/v1", - "doc": "https://llmgateway.io/docs", - "env": [ - "LLMGATEWAY_API_KEY" - ], - "model_count": 189 - }, - { - "id": "moark", - "display_name": "Moark", - "npm": "@ai-sdk/openai-compatible", - "api": "https://moark.com/v1", - "doc": "https://moark.com/docs/openapi/v1#tag/%E6%96%87%E6%9C%AC%E7%94%9F%E6%88%90", - "env": [ - "MOARK_API_KEY" - ], - "model_count": 2 - }, - { - "id": "github-models", - "display_name": "GitHub Models", - "npm": "@ai-sdk/openai-compatible", - "api": "https://models.github.ai/inference", - "doc": "https://docs.github.com/en/github-models", - "env": [ - "GITHUB_TOKEN" - ], - "model_count": 55 - }, - { - "id": "xiaomi-token-plan-cn", - "display_name": "Xiaomi Token Plan (China)", - "npm": "@ai-sdk/openai-compatible", - "api": "https://token-plan-cn.xiaomimimo.com/v1", - "doc": "https://platform.xiaomimimo.com/#/docs", - "env": [ - "XIAOMI_API_KEY" - ], - "model_count": 8 - }, - { - "id": "lmstudio", - "display_name": "LMStudio", - "npm": "@ai-sdk/openai-compatible", - "api": "http://127.0.0.1:1234/v1", - "doc": "https://lmstudio.ai/models", - "env": [ - "LMSTUDIO_API_KEY" - ], - "model_count": 3 - }, - { - "id": "zenmux", - "display_name": "ZenMux", - "npm": "@ai-sdk/openai-compatible", - "api": "https://zenmux.ai/api/v1", - "doc": "https://docs.zenmux.ai", - "env": [ - "ZENMUX_API_KEY" - ], - "model_count": 96 - }, - { - "id": "claudinio", - "display_name": "Claudinio", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.claudin.io/v1", - "doc": "https://claudin.io", - "env": [ - "CLAUDINIO_API_KEY" - ], - "model_count": 1 - }, - { - "id": "alibaba-coding-plan", - "display_name": "Alibaba Coding Plan", - "npm": "@ai-sdk/openai-compatible", - "api": "https://coding-intl.dashscope.aliyuncs.com/v1", - "doc": "https://www.alibabacloud.com/help/en/model-studio/coding-plan", - "env": [ - "ALIBABA_CODING_PLAN_API_KEY" - ], - "model_count": 11 - }, - { - "id": "modelscope", - "display_name": "ModelScope", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api-inference.modelscope.cn/v1", - "doc": "https://modelscope.cn/docs/model-service/API-Inference/intro", - "env": [ - "MODELSCOPE_API_KEY" - ], - "model_count": 7 - }, { "id": "qihang-ai", "display_name": "QiHang", @@ -585,413 +971,6 @@ ], "model_count": 9 }, - { - "id": "poe", - "display_name": "Poe", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.poe.com/v1", - "doc": "https://creator.poe.com/docs/external-applications/openai-compatible-api", - "env": [ - "POE_API_KEY" - ], - "model_count": 137 - }, - { - "id": "umans-ai-coding-plan", - "display_name": "Umans AI Coding Plan", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.code.umans.ai/v1", - "doc": "https://app.umans.ai/offers/code/docs", - "env": [ - "UMANS_AI_CODING_PLAN_API_KEY" - ], - "model_count": 5 - }, - { - "id": "firepass", - "display_name": "Fireworks (Firepass)", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.fireworks.ai/inference/v1/", - "doc": "https://docs.fireworks.ai/firepass", - "env": [ - "FIREPASS_API_KEY" - ], - "model_count": 1 - }, - { - "id": "gmicloud", - "display_name": "GMI Cloud", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.gmi-serving.com/v1", - "doc": "https://docs.gmicloud.ai/inference-engine/api-reference/llm-api-reference", - "env": [ - "GMICLOUD_API_KEY" - ], - "model_count": 8 - }, - { - "id": "mixlayer", - "display_name": "Mixlayer", - "npm": "@ai-sdk/openai-compatible", - "api": "https://models.mixlayer.ai/v1", - "doc": "https://docs.mixlayer.com", - "env": [ - "MIXLAYER_API_KEY" - ], - "model_count": 5 - }, - { - "id": "minimax-coding-plan", - "display_name": "MiniMax Token Plan (minimax.io)", - "npm": "@ai-sdk/anthropic", - "api": "https://api.minimax.io/anthropic/v1", - "doc": "https://platform.minimax.io/docs/token-plan/intro", - "env": [ - "MINIMAX_API_KEY" - ], - "model_count": 6 - }, - { - "id": "evroc", - "display_name": "evroc", - "npm": "@ai-sdk/openai-compatible", - "api": "https://models.think.evroc.com/v1", - "doc": "https://docs.evroc.com/products/think/overview.html", - "env": [ - "EVROC_API_KEY" - ], - "model_count": 13 - }, - { - "id": "nvidia", - "display_name": "Nvidia", - "npm": "@ai-sdk/openai-compatible", - "api": "https://integrate.api.nvidia.com/v1", - "doc": "https://docs.api.nvidia.com/nim/", - "env": [ - "NVIDIA_API_KEY" - ], - "model_count": 93 - }, - { - "id": "routing-run", - "display_name": "routing.run", - "npm": "@ai-sdk/openai-compatible", - "api": "https://ai.routing.sh/v1", - "doc": "https://docs.routing.run/api-reference/models", - "env": [ - "ROUTING_RUN_API_KEY" - ], - "model_count": 26 - }, - { - "id": "xiaomi-token-plan-ams", - "display_name": "Xiaomi Token Plan (Europe)", - "npm": "@ai-sdk/openai-compatible", - "api": "https://token-plan-ams.xiaomimimo.com/v1", - "doc": "https://platform.xiaomimimo.com/#/docs", - "env": [ - "XIAOMI_API_KEY" - ], - "model_count": 8 - }, - { - "id": "zhipuai", - "display_name": "Zhipu AI", - "npm": "@ai-sdk/openai-compatible", - "api": "https://open.bigmodel.cn/api/paas/v4", - "doc": "https://docs.z.ai/guides/overview/pricing", - "env": [ - "ZHIPU_API_KEY" - ], - "model_count": 12 - }, - { - "id": "io-net", - "display_name": "IO.NET", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.intelligence.io.solutions/api/v1", - "doc": "https://io.net/docs/guides/intelligence/io-intelligence", - "env": [ - "IOINTELLIGENCE_API_KEY" - ], - "model_count": 17 - }, - { - "id": "lilac", - "display_name": "Lilac", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.getlilac.com/v1", - "doc": "https://docs.getlilac.com/inference/models", - "env": [ - "LILAC_API_KEY" - ], - "model_count": 4 - }, - { - "id": "stepfun-ai", - "display_name": "StepFun", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.stepfun.ai/step_plan/v1", - "doc": "https://platform.stepfun.ai/docs/en/step-plan/integrations/open-code", - "env": [ - "STEPFUN_API_KEY" - ], - "model_count": 2 - }, - { - "id": "tencent-coding-plan", - "display_name": "Tencent Coding Plan (China)", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.lkeap.cloud.tencent.com/coding/v3", - "doc": "https://cloud.tencent.com/document/product/1772/128947", - "env": [ - "TENCENT_CODING_PLAN_API_KEY" - ], - "model_count": 8 - }, - { - "id": "opencode-go", - "display_name": "OpenCode Go", - "npm": "@ai-sdk/openai-compatible", - "api": "https://opencode.ai/zen/go/v1", - "doc": "https://opencode.ai/docs/zen", - "env": [ - "OPENCODE_API_KEY" - ], - "model_count": 16 - }, - { - "id": "cortecs", - "display_name": "Cortecs", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.cortecs.ai/v1", - "doc": "https://api.cortecs.ai/v1/models", - "env": [ - "CORTECS_API_KEY" - ], - "model_count": 49 - }, - { - "id": "auriko", - "display_name": "Auriko", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.auriko.ai/v1", - "doc": "https://docs.auriko.ai", - "env": [ - "AURIKO_API_KEY" - ], - "model_count": 15 - }, - { - "id": "wafer.ai", - "display_name": "Wafer", - "npm": "@ai-sdk/openai-compatible", - "api": "https://pass.wafer.ai/v1", - "doc": "https://docs.wafer.ai/wafer-pass", - "env": [ - "WAFER_API_KEY" - ], - "model_count": 7 - }, - { - "id": "berget", - "display_name": "Berget.AI", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.berget.ai/v1", - "doc": "https://api.berget.ai", - "env": [ - "BERGET_API_KEY" - ], - "model_count": 7 - }, - { - "id": "requesty", - "display_name": "Requesty", - "npm": "@ai-sdk/openai-compatible", - "api": "https://router.requesty.ai/v1", - "doc": "https://requesty.ai/solution/llm-routing/models", - "env": [ - "REQUESTY_API_KEY" - ], - "model_count": 38 - }, - { - "id": "atomic-chat", - "display_name": "Atomic Chat", - "npm": "@ai-sdk/openai-compatible", - "api": "http://127.0.0.1:1337/v1", - "doc": "https://atomic.chat", - "env": [ - "ATOMIC_CHAT_API_KEY" - ], - "model_count": 5 - }, - { - "id": "stepfun", - "display_name": "StepFun (China)", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.stepfun.com/v1", - "doc": "https://platform.stepfun.com/docs/zh/overview/concept", - "env": [ - "STEPFUN_API_KEY" - ], - "model_count": 4 - }, - { - "id": "vultr", - "display_name": "Vultr", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.vultrinference.com/v1", - "doc": "https://api.vultrinference.com/", - "env": [ - "VULTR_API_KEY" - ], - "model_count": 7 - }, - { - "id": "zai-coding-plan", - "display_name": "Z.AI Coding Plan", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.z.ai/api/coding/paas/v4", - "doc": "https://docs.z.ai/devpack/overview", - "env": [ - "ZHIPU_API_KEY" - ], - "model_count": 5 - }, - { - "id": "synthetic", - "display_name": "Synthetic", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.synthetic.new/openai/v1", - "doc": "https://synthetic.new/pricing", - "env": [ - "SYNTHETIC_API_KEY" - ], - "model_count": 33 - }, - { - "id": "cloudferro-sherlock", - "display_name": "CloudFerro Sherlock", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api-sherlock.cloudferro.com/openai/v1/", - "doc": "https://docs.sherlock.cloudferro.com/", - "env": [ - "CLOUDFERRO_SHERLOCK_API_KEY" - ], - "model_count": 5 - }, - { - "id": "helicone", - "display_name": "Helicone", - "npm": "@ai-sdk/openai-compatible", - "api": "https://ai-gateway.helicone.ai/v1", - "doc": "https://helicone.ai/models", - "env": [ - "HELICONE_API_KEY" - ], - "model_count": 90 - }, - { - "id": "zai", - "display_name": "Z.AI", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.z.ai/api/paas/v4", - "doc": "https://docs.z.ai/guides/overview/pricing", - "env": [ - "ZHIPU_API_KEY" - ], - "model_count": 13 - }, - { - "id": "nova", - "display_name": "Nova", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.nova.amazon.com/v1", - "doc": "https://nova.amazon.com/dev/documentation", - "env": [ - "NOVA_API_KEY" - ], - "model_count": 2 - }, - { - "id": "nearai", - "display_name": "NEAR AI Cloud", - "npm": "@ai-sdk/openai-compatible", - "api": "https://cloud-api.near.ai/v1", - "doc": "https://docs.near.ai/", - "env": [ - "NEARAI_API_KEY" - ], - "model_count": 37 - }, - { - "id": "inceptron", - "display_name": "Inceptron", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.inceptron.io/v1", - "doc": "https://docs.inceptron.io", - "env": [ - "INCEPTRON_API_KEY" - ], - "model_count": 4 - }, - { - "id": "xpersona", - "display_name": "Xpersona", - "npm": "@ai-sdk/openai-compatible", - "api": "https://www.xpersona.co/v1", - "doc": "https://www.xpersona.co/docs", - "env": [ - "XPERSONA_API_KEY" - ], - "model_count": 1 - }, - { - "id": "perplexity-agent", - "display_name": "Perplexity Agent", - "npm": "@ai-sdk/openai", - "api": "https://api.perplexity.ai/v1", - "doc": "https://docs.perplexity.ai/docs/agent-api/models", - "env": [ - "PERPLEXITY_API_KEY" - ], - "model_count": 18 - }, - { - "id": "jiekou", - "display_name": "Jiekou.AI", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.jiekou.ai/openai", - "doc": "https://docs.jiekou.ai/docs/support/quickstart?utm_source=github_models.dev", - "env": [ - "JIEKOU_API_KEY" - ], - "model_count": 61 - }, - { - "id": "abliteration-ai", - "display_name": "abliteration.ai", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.abliteration.ai/v1", - "doc": "https://docs.abliteration.ai/models", - "env": [ - "ABLIT_KEY" - ], - "model_count": 1 - }, - { - "id": "minimax-cn", - "display_name": "MiniMax (minimaxi.com)", - "npm": "@ai-sdk/anthropic", - "api": "https://api.minimaxi.com/anthropic/v1", - "doc": "https://platform.minimaxi.com/docs/guides/quickstart", - "env": [ - "MINIMAX_API_KEY" - ], - "model_count": 6 - }, { "id": "qiniu-ai", "display_name": "Qiniu", @@ -1004,115 +983,49 @@ "model_count": 91 }, { - "id": "morph", - "display_name": "Morph", + "id": "regolo-ai", + "display_name": "Regolo AI", "npm": "@ai-sdk/openai-compatible", - "api": "https://api.morphllm.com/v1", - "doc": "https://docs.morphllm.com/api-reference/introduction", + "api": "https://api.regolo.ai/v1", + "doc": "https://docs.regolo.ai/", "env": [ - "MORPH_API_KEY" + "REGOLO_API_KEY" + ], + "model_count": 13 + }, + { + "id": "requesty", + "display_name": "Requesty", + "npm": "@ai-sdk/openai-compatible", + "api": "https://router.requesty.ai/v1", + "doc": "https://requesty.ai/solution/llm-routing/models", + "env": [ + "REQUESTY_API_KEY" + ], + "model_count": 38 + }, + { + "id": "routing-run", + "display_name": "routing.run", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.routing.run/v1", + "doc": "https://docs.routing.run/api-reference/models", + "env": [ + "ROUTING_RUN_API_KEY" + ], + "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": "xiaomi-token-plan-sgp", - "display_name": "Xiaomi Token Plan (Singapore)", - "npm": "@ai-sdk/openai-compatible", - "api": "https://token-plan-sgp.xiaomimimo.com/v1", - "doc": "https://platform.xiaomimimo.com/#/docs", - "env": [ - "XIAOMI_API_KEY" - ], - "model_count": 8 - }, - { - "id": "fastrouter", - "display_name": "FastRouter", - "npm": "@ai-sdk/openai-compatible", - "api": "https://go.fastrouter.ai/api/v1", - "doc": "https://fastrouter.ai/models", - "env": [ - "FASTROUTER_API_KEY" - ], - "model_count": 15 - }, - { - "id": "siliconflow", - "display_name": "SiliconFlow", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.siliconflow.com/v1", - "doc": "https://cloud.siliconflow.com/models", - "env": [ - "SILICONFLOW_API_KEY" - ], - "model_count": 76 - }, - { - "id": "abacus", - "display_name": "Abacus", - "npm": "@ai-sdk/openai-compatible", - "api": "https://routellm.abacus.ai/v1", - "doc": "https://abacus.ai/help/api", - "env": [ - "ABACUS_API_KEY" - ], - "model_count": 65 - }, - { - "id": "drun", - "display_name": "D.Run (China)", - "npm": "@ai-sdk/openai-compatible", - "api": "https://chat.d.run/v1", - "doc": "https://www.d.run", - "env": [ - "DRUN_API_KEY" - ], - "model_count": 3 - }, - { - "id": "wandb", - "display_name": "Weights & Biases", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.inference.wandb.ai/v1", - "doc": "https://docs.wandb.ai/guides/integrations/inference/", - "env": [ - "WANDB_API_KEY" - ], - "model_count": 18 - }, - { - "id": "meganova", - "display_name": "Meganova", - "npm": "@ai-sdk/openai-compatible", - "api": "https://api.meganova.ai/v1", - "doc": "https://docs.meganova.ai", - "env": [ - "MEGANOVA_API_KEY" - ], - "model_count": 19 - }, - { - "id": "opencode", - "display_name": "OpenCode Zen", - "npm": "@ai-sdk/openai-compatible", - "api": "https://opencode.ai/zen/v1", - "doc": "https://opencode.ai/docs/zen", - "env": [ - "OPENCODE_API_KEY" - ], - "model_count": 66 - }, - { - "id": "poolside", - "display_name": "Poolside", - "npm": "@ai-sdk/openai-compatible", - "api": "https://inference.poolside.ai/v1", - "doc": "https://platform.poolside.ai", - "env": [ - "POOLSIDE_API_KEY" - ], - "model_count": 2 - }, { "id": "sarvam", "display_name": "Sarvam AI", @@ -1124,28 +1037,6 @@ ], "model_count": 2 }, - { - "id": "baseten", - "display_name": "Baseten", - "npm": "@ai-sdk/openai-compatible", - "api": "https://inference.baseten.co/v1", - "doc": "https://docs.baseten.co/development/model-apis/overview", - "env": [ - "BASETEN_API_KEY" - ], - "model_count": 14 - }, - { - "id": "lucidquery", - "display_name": "LucidQuery AI", - "npm": "@ai-sdk/openai-compatible", - "api": "https://lucidquery.com/api/v1", - "doc": "https://lucidquery.com/api/docs", - "env": [ - "LUCIDQUERY_API_KEY" - ], - "model_count": 2 - }, { "id": "scaleway", "display_name": "Scaleway", @@ -1155,18 +1046,129 @@ "env": [ "SCALEWAY_API_KEY" ], - "model_count": 16 + "model_count": 17 }, { - "id": "hpc-ai", - "display_name": "HPC-AI", + "id": "siliconflow", + "display_name": "SiliconFlow", "npm": "@ai-sdk/openai-compatible", - "api": "https://api.hpc-ai.com/inference/v1", - "doc": "https://www.hpc-ai.com/doc/docs/quickstart/", + "api": "https://api.siliconflow.com/v1", + "doc": "https://cloud.siliconflow.com/models", "env": [ - "HPC_AI_API_KEY" + "SILICONFLOW_API_KEY" ], - "model_count": 3 + "model_count": 49 + }, + { + "id": "siliconflow-cn", + "display_name": "SiliconFlow (China)", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.siliconflow.cn/v1", + "doc": "https://cloud.siliconflow.com/models", + "env": [ + "SILICONFLOW_CN_API_KEY" + ], + "model_count": 47 + }, + { + "id": "snowflake-cortex", + "display_name": "Snowflake Cortex", + "npm": "@ai-sdk/openai-compatible", + "api": "https://${SNOWFLAKE_ACCOUNT}.snowflakecomputing.com/api/v2/cortex/v1", + "doc": "https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-rest-api", + "env": [ + "SNOWFLAKE_ACCOUNT", + "SNOWFLAKE_CORTEX_PAT" + ], + "model_count": 18 + }, + { + "id": "stackit", + "display_name": "STACKIT", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.openai-compat.model-serving.eu01.onstackit.cloud/v1", + "doc": "https://docs.stackit.cloud/products/data-and-ai/ai-model-serving/basics/available-shared-models", + "env": [ + "STACKIT_API_KEY" + ], + "model_count": 8 + }, + { + "id": "stepfun", + "display_name": "StepFun", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.stepfun.com/v1", + "doc": "https://platform.stepfun.com/docs/zh/overview/concept", + "env": [ + "STEPFUN_API_KEY" + ], + "model_count": 8 + }, + { + "id": "stepfun-ai", + "display_name": "StepFun AI", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.stepfun.ai/step_plan/v1", + "doc": "https://platform.stepfun.ai/docs/en/step-plan/integrations/open-code", + "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 + }, + { + "id": "submodel", + "display_name": "submodel", + "npm": "@ai-sdk/openai-compatible", + "api": "https://llm.submodel.ai/v1", + "doc": "https://submodel.gitbook.io", + "env": [ + "SUBMODEL_INSTAGEN_ACCESS_KEY" + ], + "model_count": 9 + }, + { + "id": "synthetic", + "display_name": "Synthetic", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.synthetic.new/openai/v1", + "doc": "https://synthetic.new/pricing", + "env": [ + "SYNTHETIC_API_KEY" + ], + "model_count": 7 + }, + { + "id": "tencent-coding-plan", + "display_name": "Tencent Coding Plan (China)", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.lkeap.cloud.tencent.com/coding/v3", + "doc": "https://cloud.tencent.com/document/product/1772/128947", + "env": [ + "TENCENT_CODING_PLAN_API_KEY" + ], + "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", @@ -1177,61 +1179,247 @@ "env": [ "TENCENT_TOKENHUB_API_KEY" ], - "model_count": 1 + "model_count": 2 }, { - "id": "alibaba-coding-plan-cn", - "display_name": "Alibaba Coding Plan (China)", + "id": "the-grid-ai", + "display_name": "The Grid AI", "npm": "@ai-sdk/openai-compatible", - "api": "https://coding.dashscope.aliyuncs.com/v1", - "doc": "https://help.aliyun.com/zh/model-studio/coding-plan", + "api": "https://api.thegrid.ai/v1", + "doc": "https://thegrid.ai/docs", "env": [ - "ALIBABA_CODING_PLAN_API_KEY" + "THEGRIDAI_API_KEY" ], - "model_count": 11 + "model_count": 9 }, { - "id": "cloudflare-workers-ai", - "display_name": "Cloudflare Workers AI", + "id": "tinfoil", + "display_name": "Tinfoil", "npm": "@ai-sdk/openai-compatible", - "api": "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", - "doc": "https://developers.cloudflare.com/workers-ai/models/", + "api": "https://inference.tinfoil.sh/v1", + "doc": "https://docs.tinfoil.sh", "env": [ - "CLOUDFLARE_ACCOUNT_ID", - "CLOUDFLARE_API_KEY" + "TINFOIL_API_KEY" ], - "model_count": 20 + "model_count": 7 }, { - "id": "nebius", - "display_name": "Nebius Token Factory", + "id": "trustedrouter", + "display_name": "TrustedRouter", "npm": "@ai-sdk/openai-compatible", - "api": "https://api.tokenfactory.nebius.com/v1", - "doc": "https://docs.tokenfactory.nebius.com/", + "api": "https://api.trustedrouter.com/v1", + "doc": "https://trustedrouter.com/docs", "env": [ - "NEBIUS_API_KEY" + "TRUSTEDROUTER_API_KEY" ], - "model_count": 31 + "model_count": 7 }, { - "id": "minimax", - "display_name": "MiniMax (minimax.io)", - "npm": "@ai-sdk/anthropic", - "api": "https://api.minimax.io/anthropic/v1", - "doc": "https://platform.minimax.io/docs/guides/quickstart", + "id": "umans-ai", + "display_name": "Umans AI", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.code.umans.ai/v1", + "doc": "https://app.umans.ai/offers/code/docs/orgs", "env": [ - "MINIMAX_API_KEY" + "UMANS_AI_API_KEY" + ], + "model_count": 5 + }, + { + "id": "umans-ai-coding-plan", + "display_name": "Umans AI Coding Plan", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.code.umans.ai/v1", + "doc": "https://app.umans.ai/offers/code/docs", + "env": [ + "UMANS_AI_CODING_PLAN_API_KEY" ], "model_count": 6 }, { - "id": "meta-llama", - "display_name": "Llama", + "id": "upstage", + "display_name": "Upstage", "npm": "@ai-sdk/openai-compatible", - "api": "https://api.llama.com/compat/v1/", - "doc": "https://llama.developer.meta.com/docs/models", + "api": "https://api.upstage.ai/v1/solar", + "doc": "https://developers.upstage.ai/docs/apis/chat", "env": [ - "LLAMA_API_KEY" + "UPSTAGE_API_KEY" + ], + "model_count": 3 + }, + { + "id": "vivgrid", + "display_name": "Vivgrid", + "npm": "@ai-sdk/openai", + "api": "https://api.vivgrid.com/v1", + "doc": "https://docs.vivgrid.com/models", + "env": [ + "VIVGRID_API_KEY" + ], + "model_count": 14 + }, + { + "id": "vultr", + "display_name": "Vultr", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.vultrinference.com/v1", + "doc": "https://api.vultrinference.com/", + "env": [ + "VULTR_API_KEY" + ], + "model_count": 10 + }, + { + "id": "wafer.ai", + "display_name": "Wafer", + "npm": "@ai-sdk/openai-compatible", + "api": "https://pass.wafer.ai/v1", + "doc": "https://docs.wafer.ai/wafer-pass", + "env": [ + "WAFER_API_KEY" + ], + "model_count": 8 + }, + { + "id": "wandb", + "display_name": "Weights & Biases", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.inference.wandb.ai/v1", + "doc": "https://docs.wandb.ai/guides/integrations/inference/", + "env": [ + "WANDB_API_KEY" + ], + "model_count": 29 + }, + { + "id": "xiaomi", + "display_name": "Xiaomi", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.xiaomimimo.com/v1", + "doc": "https://platform.xiaomimimo.com/#/docs", + "env": [ + "XIAOMI_API_KEY" + ], + "model_count": 6 + }, + { + "id": "xiaomi-token-plan-ams", + "display_name": "Xiaomi Token Plan (Europe)", + "npm": "@ai-sdk/openai-compatible", + "api": "https://token-plan-ams.xiaomimimo.com/v1", + "doc": "https://platform.xiaomimimo.com/#/docs", + "env": [ + "XIAOMI_API_KEY" + ], + "model_count": 7 + }, + { + "id": "xiaomi-token-plan-cn", + "display_name": "Xiaomi Token Plan (China)", + "npm": "@ai-sdk/openai-compatible", + "api": "https://token-plan-cn.xiaomimimo.com/v1", + "doc": "https://platform.xiaomimimo.com/#/docs", + "env": [ + "XIAOMI_API_KEY" + ], + "model_count": 7 + }, + { + "id": "xiaomi-token-plan-sgp", + "display_name": "Xiaomi Token Plan (Singapore)", + "npm": "@ai-sdk/openai-compatible", + "api": "https://token-plan-sgp.xiaomimimo.com/v1", + "doc": "https://platform.xiaomimimo.com/#/docs", + "env": [ + "XIAOMI_API_KEY" + ], + "model_count": 7 + }, + { + "id": "xpersona", + "display_name": "Xpersona", + "npm": "@ai-sdk/openai-compatible", + "api": "https://www.xpersona.co/v1", + "doc": "https://www.xpersona.co/docs", + "env": [ + "XPERSONA_API_KEY" + ], + "model_count": 3 + }, + { + "id": "zai", + "display_name": "Z.AI", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.z.ai/api/paas/v4", + "doc": "https://docs.z.ai/guides/overview/pricing", + "env": [ + "ZHIPU_API_KEY" + ], + "model_count": 14 + }, + { + "id": "zai-coding-plan", + "display_name": "Z.AI Coding Plan", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.z.ai/api/coding/paas/v4", + "doc": "https://docs.z.ai/devpack/overview", + "env": [ + "ZHIPU_API_KEY" + ], + "model_count": 6 + }, + { + "id": "zeldoc", + "display_name": "Zeldoc", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.zeldoc.ai/v1", + "doc": "https://docs.zeldoc.ai", + "env": [ + "ZELDOC_API_KEY" + ], + "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", + "npm": "@ai-sdk/openai-compatible", + "api": "https://zenmux.ai/api/v1", + "doc": "https://docs.zenmux.ai", + "env": [ + "ZENMUX_API_KEY" + ], + "model_count": 114 + }, + { + "id": "zhipuai", + "display_name": "Zhipu AI", + "npm": "@ai-sdk/openai-compatible", + "api": "https://open.bigmodel.cn/api/paas/v4", + "doc": "https://docs.z.ai/guides/overview/pricing", + "env": [ + "ZHIPU_API_KEY" + ], + "model_count": 13 + }, + { + "id": "zhipuai-coding-plan", + "display_name": "Zhipu AI Coding Plan", + "npm": "@ai-sdk/openai-compatible", + "api": "https://open.bigmodel.cn/api/coding/paas/v4", + "doc": "https://docs.bigmodel.cn/cn/coding-plan/overview", + "env": [ + "ZHIPU_API_KEY" ], "model_count": 7 } diff --git a/crates/goose/src/providers/canonical/model.rs b/crates/goose-provider-types/src/canonical/model.rs similarity index 50% rename from crates/goose/src/providers/canonical/model.rs rename to crates/goose-provider-types/src/canonical/model.rs index e23368155a..ad287d68ce 100644 --- a/crates/goose/src/providers/canonical/model.rs +++ b/crates/goose-provider-types/src/canonical/model.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use crate::conversation::token_usage::Usage; + /// Modality types for model input/output #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -52,6 +54,30 @@ pub struct Pricing { pub cache_write: Option, } +impl Pricing { + pub fn estimate_cost(&self, usage: &Usage) -> Option { + let input_price = self.input?; + let output_price = self.output?; + let cache_read_price = self.cache_read.unwrap_or(input_price); + let cache_write_price = self.cache_write.unwrap_or(input_price); + + let input_tokens = usage.input_tokens.unwrap_or(0).max(0) as f64; + let output_tokens = usage.output_tokens.unwrap_or(0).max(0) as f64; + let cache_read_tokens = usage.cache_read_input_tokens.unwrap_or(0).max(0) as f64; + let cache_write_tokens = usage.cache_write_input_tokens.unwrap_or(0).max(0) as f64; + let uncached_input_tokens = + (input_tokens - cache_read_tokens - cache_write_tokens).max(0.0); + + Some( + (uncached_input_tokens * input_price + + cache_read_tokens * cache_read_price + + cache_write_tokens * cache_write_price + + output_tokens * output_price) + / 1_000_000.0, + ) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Limit { /// Maximum context window size in tokens @@ -62,6 +88,14 @@ pub struct Limit { pub output: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ThinkingMode { + Enabled, + Adaptive, + AlwaysOnAdaptive, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CanonicalModel { /// Model identifier (e.g., "anthropic/claude-3-5-sonnet") @@ -82,6 +116,10 @@ pub struct CanonicalModel { #[serde(skip_serializing_if = "Option::is_none")] pub reasoning: Option, + /// Request shape to use when enabling thinking/reasoning. + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking_mode: Option, + /// Whether the model supports tool calling #[serde(default)] pub tool_call: bool, @@ -118,3 +156,64 @@ pub struct CanonicalModel { #[serde(default)] pub limit: Limit, } + +#[cfg(test)] +mod tests { + use super::*; + + fn pricing( + input: Option, + output: Option, + cache_read: Option, + cache_write: Option, + ) -> Pricing { + Pricing { + input, + output, + cache_read, + cache_write, + } + } + + #[test] + fn estimate_cost_prices_cache_tokens_at_cache_rates() { + let pricing = pricing(Some(5.0), Some(25.0), Some(0.5), Some(6.25)); + let usage = + Usage::new(Some(10_000), Some(1_000), None).with_cache_tokens(Some(8_000), Some(1_000)); + + let cost = pricing.estimate_cost(&usage).unwrap(); + let expected = + (1_000.0 * 5.0 + 8_000.0 * 0.5 + 1_000.0 * 6.25 + 1_000.0 * 25.0) / 1_000_000.0; + assert!((cost - expected).abs() < f64::EPSILON); + } + + #[test] + fn estimate_cost_handles_missing_prices() { + let usage = + Usage::new(Some(1_000), Some(100), None).with_cache_tokens(Some(600), Some(200)); + + // Unpriced cache tokens fall back to the input rate. + let cost = pricing(Some(2.0), Some(10.0), None, None) + .estimate_cost(&usage) + .unwrap(); + assert_eq!(cost, (1_000.0 * 2.0 + 100.0 * 10.0) / 1_000_000.0); + + // Missing input or output pricing means no estimate at all. + assert!(pricing(None, Some(10.0), None, None) + .estimate_cost(&usage) + .is_none()); + assert!(pricing(Some(2.0), None, None, None) + .estimate_cost(&usage) + .is_none()); + } + + #[test] + fn estimate_cost_clamps_cache_tokens_exceeding_input() { + let pricing = pricing(Some(5.0), Some(25.0), Some(0.5), Some(6.25)); + let usage = Usage::new(Some(100), Some(10), None).with_cache_tokens(Some(150), Some(50)); + + let cost = pricing.estimate_cost(&usage).unwrap(); + let expected = (150.0 * 0.5 + 50.0 * 6.25 + 10.0 * 25.0) / 1_000_000.0; + assert!((cost - expected).abs() < f64::EPSILON); + } +} diff --git a/crates/goose/src/providers/canonical/name_builder.rs b/crates/goose-provider-types/src/canonical/name_builder.rs similarity index 78% rename from crates/goose/src/providers/canonical/name_builder.rs rename to crates/goose-provider-types/src/canonical/name_builder.rs index e48fdb7a55..7dadd1e847 100644 --- a/crates/goose/src/providers/canonical/name_builder.rs +++ b/crates/goose-provider-types/src/canonical/name_builder.rs @@ -12,7 +12,6 @@ static STRIP_PATTERNS: Lazy> = Lazy::new(|| { Regex::new(r"-\d{4}$").unwrap(), Regex::new(r"-\d{4}-\d{2}-\d{2}$").unwrap(), Regex::new(r"-bedrock$").unwrap(), - Regex::new(r"-reasoning$").unwrap(), ] }); @@ -36,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 { @@ -47,9 +49,11 @@ 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", + "ollama_cloud" => "ollama-cloud", _ => provider, } } @@ -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 === @@ -448,16 +469,16 @@ mod tests { // === Grok (X.AI) === assert_eq!( - map_to_canonical_model("databricks", "grok-4.20", r), - Some("x-ai/grok-4.20".to_string()) + map_to_canonical_model("databricks", "grok-4.3", r), + Some("x-ai/grok-4.3".to_string()) ); assert_eq!( - map_to_canonical_model("databricks", "databricks-grok-4.20", r), - Some("x-ai/grok-4.20".to_string()) + map_to_canonical_model("databricks", "databricks-grok-4.3", r), + Some("x-ai/grok-4.3".to_string()) ); assert_eq!( - map_to_canonical_model("databricks", "kgoose-grok-4.20", r), - Some("x-ai/grok-4.20".to_string()) + map_to_canonical_model("databricks", "kgoose-grok-4.3", r), + Some("x-ai/grok-4.3".to_string()) ); // === Cohere Command === @@ -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), @@ -493,8 +514,8 @@ mod tests { Some("deepseek/deepseek-chat".to_string()) ); assert_eq!( - map_to_canonical_model("databricks", "x-ai-grok-4.20", r), - Some("x-ai/grok-4.20".to_string()) + map_to_canonical_model("databricks", "x-ai-grok-4.3", r), + Some("x-ai/grok-4.3".to_string()) ); // === Zhipu AI === @@ -516,10 +537,6 @@ mod tests { map_to_canonical_model("gcp_vertex_ai", "gemini-2.5-pro", r), Some("google-vertex/gemini-2.5-pro".to_string()) ); - assert_eq!( - map_to_canonical_model("gcp_vertex_ai", "claude-3-5-sonnet", r), - Some("google-vertex/claude-3.5-sonnet".to_string()) - ); assert_eq!( map_to_canonical_model("gcp_vertex_ai", "claude-sonnet-4@20250514", r), Some("google-vertex/claude-sonnet-4".to_string()) @@ -541,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/src/providers/canonical/registry.rs b/crates/goose-provider-types/src/canonical/registry.rs similarity index 100% rename from crates/goose/src/providers/canonical/registry.rs rename to crates/goose-provider-types/src/canonical/registry.rs diff --git a/crates/goose/src/conversation/mod.rs b/crates/goose-provider-types/src/conversation.rs similarity index 81% rename from crates/goose/src/conversation/mod.rs rename to crates/goose-provider-types/src/conversation.rs index 31d3f52d61..7f8276579d 100644 --- a/crates/goose/src/conversation/mod.rs +++ b/crates/goose-provider-types/src/conversation.rs @@ -7,6 +7,7 @@ use thiserror::Error; use utoipa::ToSchema; pub mod message; +pub mod token_usage; mod tool_result_serde; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)] @@ -42,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 @@ -130,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); } @@ -242,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, ] @@ -497,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 @@ -504,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() @@ -1289,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/src/conversation/message.rs b/crates/goose-provider-types/src/conversation/message.rs similarity index 93% rename from crates/goose/src/conversation/message.rs rename to crates/goose-provider-types/src/conversation/message.rs index 83ab932119..1c095089c7 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose-provider-types/src/conversation/message.rs @@ -1,11 +1,12 @@ +use crate::conversation::token_usage::{CostSource, ProviderUsage}; use crate::conversation::tool_result_serde; -use crate::mcp_utils::{extract_text_from_resource, ToolResult}; +use crate::mcp_utils::extract_text_from_resource; use crate::utils::sanitize_unicode_tags; use chrono::Utc; use rmcp::model::{ - AnnotateAble, CallToolRequestParams, CallToolResult, Content, ImageContent, JsonObject, - PromptMessage, PromptMessageContent, PromptMessageRole, RawContent, RawImageContent, - RawTextContent, Role, TextContent, + AnnotateAble, CallToolRequestParams, CallToolResult, Content, ElicitationAction, ImageContent, + JsonObject, PromptMessage, PromptMessageContent, PromptMessageRole, RawContent, + RawImageContent, RawTextContent, Role, TextContent, }; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::HashSet; @@ -74,6 +75,7 @@ where /// Provider-specific metadata for tool requests/responses. /// Allows providers to store custom data without polluting the core model. pub type ProviderMetadata = serde_json::Map; +pub type ToolResult = Result; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -211,9 +213,16 @@ pub enum ActionRequiredData { ElicitationResponse { id: String, user_data: serde_json::Value, + #[serde(default = "default_elicitation_action")] + #[schema(value_type = String)] + action: ElicitationAction, }, } +fn default_elicitation_action() -> ElicitationAction { + ElicitationAction::Accept +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ActionRequired { @@ -244,6 +253,7 @@ pub struct FrontendToolRequest { #[serde(rename_all = "camelCase")] pub enum SystemNotificationType { ThinkingMessage, + ProgressMessage, InlineMessage, CreditsExhausted, } @@ -477,11 +487,13 @@ impl MessageContent { pub fn action_required_elicitation_response>( id: S, user_data: serde_json::Value, + action: ElicitationAction, ) -> Self { MessageContent::ActionRequired(ActionRequired { data: ActionRequiredData::ElicitationResponse { id: id.into(), user_data, + action, }, }) } @@ -656,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")] @@ -666,6 +721,13 @@ pub struct MessageMetadata { pub agent_visible: bool, #[serde(skip_serializing_if = "Option::is_none")] pub inference: Option, + /// Whether this message is a steer injected into an active run. UI-only: + /// surfaced as `_meta.goose.steer` so clients can mark the steer boundary + /// 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 { @@ -674,6 +736,8 @@ impl Default for MessageMetadata { user_visible: true, agent_visible: true, inference: None, + steer: false, + usage: None, } } } @@ -742,6 +806,11 @@ impl MessageMetadata { self.inference = Some(inference); self } + + pub fn with_steer(mut self) -> Self { + self.steer = true; + self + } } #[derive(ToSchema, Clone, PartialEq, Serialize, Deserialize, Debug)] @@ -1027,6 +1096,11 @@ impl Message { self } + pub fn with_steer(mut self) -> Self { + self.metadata.steer = true; + self + } + pub fn with_inference_if_assistant(self, inference: InferenceMetadata) -> Self { if self.role == Role::Assistant && self.metadata.inference.is_none() { self.with_inference(inference) @@ -1062,21 +1136,31 @@ pub struct TokenState { pub input_tokens: i32, pub output_tokens: i32, pub total_tokens: i32, + #[serde(default)] + pub cache_read_tokens: i32, + #[serde(default)] + pub cache_write_tokens: i32, pub accumulated_input_tokens: i32, pub accumulated_output_tokens: i32, pub accumulated_total_tokens: i32, + #[serde(default)] + pub accumulated_cache_read_tokens: i32, + #[serde(default)] + pub accumulated_cache_write_tokens: i32, pub accumulated_cost: Option, } #[cfg(test)] mod tests { - use crate::conversation::message::{Message, MessageContent, MessageMetadata}; + use crate::conversation::message::{ + ActionRequiredData, Message, MessageContent, MessageMetadata, + }; use crate::conversation::*; use rmcp::model::{ AnnotateAble, CallToolRequestParams, PromptMessage, PromptMessageContent, PromptMessageRole, RawEmbeddedResource, RawImageContent, ResourceContents, }; - use rmcp::model::{ErrorCode, ErrorData}; + use rmcp::model::{ElicitationAction, ErrorCode, ErrorData}; use rmcp::object; use serde_json::Value; @@ -1233,6 +1317,29 @@ mod tests { assert!(thinking.signature.is_empty()); } + #[test] + fn test_elicitation_response_defaults_action_to_accept() { + let action_required: ActionRequiredData = serde_json::from_value(serde_json::json!({ + "actionType": "elicitationResponse", + "id": "request-123", + "user_data": { "name": "goose" } + })) + .unwrap(); + + let ActionRequiredData::ElicitationResponse { + id, + user_data, + action, + } = action_required + else { + panic!("Expected elicitation response"); + }; + + assert_eq!(id, "request-123"); + assert_eq!(user_data, serde_json::json!({ "name": "goose" })); + assert_eq!(action, ElicitationAction::Accept); + } + #[test] fn test_agent_visible_content_preserves_thinking_for_provider() { let message = Message::assistant() diff --git a/crates/goose-provider-types/src/conversation/token_usage.rs b/crates/goose-provider-types/src/conversation/token_usage.rs new file mode 100644 index 0000000000..4fa2db5c52 --- /dev/null +++ b/crates/goose-provider-types/src/conversation/token_usage.rs @@ -0,0 +1,227 @@ +use std::ops::{Add, AddAssign}; + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderUsage { + pub model: String, + pub usage: Usage, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stats: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost_source: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CostSource { + ProviderReported, + Estimated, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProviderStats { + pub time_to_first_token_ms: Option, + pub model_load_ms: Option, + pub elapsed_ms: Option, + pub output_tokens: Option, + pub draft: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DraftStats { + pub model: Option, + pub draft_tokens: usize, + pub accepted_tokens: usize, + pub target_tokens: usize, + pub rounds: usize, + pub accept_rate: f64, +} + +impl ProviderUsage { + pub fn new(model: String, usage: Usage) -> Self { + Self { + model, + usage, + stats: None, + cost: None, + cost_source: None, + } + } + + pub fn with_stats(mut self, stats: ProviderStats) -> Self { + self.stats = Some(stats); + self + } + + pub fn with_cost(mut self, cost: f64, source: CostSource) -> Self { + self.cost = Some(cost); + self.cost_source = Some(source); + self + } +} + +/// `input_tokens` is the total input including cache read/write tokens; +/// the cache fields are breakdown subsets of it. Parsers for providers +/// that report cache tokens separately from input (e.g. Anthropic, +/// Bedrock) must fold them into `input_tokens`. +#[derive(Debug, Clone, Serialize, Deserialize, Default, Copy, PartialEq, Eq, ToSchema)] +pub struct Usage { + /// All prompt tokens, including any served from or written to cache. + /// `cache_read_input_tokens` and `cache_write_input_tokens` are subsets of this. + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, + pub cache_read_input_tokens: Option, + pub cache_write_input_tokens: Option, +} + +fn sum_optionals(a: Option, b: Option) -> Option +where + T: Add, +{ + match (a, b) { + (Some(x), Some(y)) => Some(x + y), + (Some(x), None) => Some(x), + (None, Some(y)) => Some(y), + (None, None) => None, + } +} + +impl Add for Usage { + type Output = Self; + + fn add(self, other: Self) -> Self { + Self::new( + sum_optionals(self.input_tokens, other.input_tokens), + sum_optionals(self.output_tokens, other.output_tokens), + sum_optionals(self.total_tokens, other.total_tokens), + ) + .with_cache_tokens( + sum_optionals(self.cache_read_input_tokens, other.cache_read_input_tokens), + sum_optionals( + self.cache_write_input_tokens, + other.cache_write_input_tokens, + ), + ) + } +} + +impl AddAssign for Usage { + fn add_assign(&mut self, rhs: Self) { + *self = *self + rhs; + } +} + +impl Usage { + pub fn new( + input_tokens: Option, + output_tokens: Option, + total_tokens: Option, + ) -> Self { + let calculated_total = if total_tokens.is_none() { + match (input_tokens, output_tokens) { + (Some(input), Some(output)) => Some(input.saturating_add(output)), + (Some(input), None) => Some(input), + (None, Some(output)) => Some(output), + (None, None) => None, + } + } else { + total_tokens + }; + + Self { + input_tokens, + output_tokens, + total_tokens: calculated_total, + cache_read_input_tokens: None, + cache_write_input_tokens: None, + } + } + + pub fn with_cache_tokens( + mut self, + cache_read_input_tokens: Option, + cache_write_input_tokens: Option, + ) -> Self { + self.cache_read_input_tokens = cache_read_input_tokens; + self.cache_write_input_tokens = cache_write_input_tokens; + self + } + + /// For providers whose reported `input_tokens`/`total_tokens` exclude + /// cache tokens (e.g. Anthropic, Bedrock): folds the cache breakdown in. + pub fn from_cache_exclusive_input( + input_tokens: Option, + output_tokens: Option, + total_tokens: Option, + cache_read_input_tokens: Option, + cache_write_input_tokens: Option, + ) -> Self { + let cache_tokens = cache_read_input_tokens + .unwrap_or(0) + .saturating_add(cache_write_input_tokens.unwrap_or(0)); + Self::new( + input_tokens.map(|v| v.saturating_add(cache_tokens)), + output_tokens, + total_tokens.map(|v| v.saturating_add(cache_tokens)), + ) + .with_cache_tokens(cache_read_input_tokens, cache_write_input_tokens) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use serde_json::json; + + #[test] + fn test_usage_serialization() -> Result<()> { + let usage = Usage::new(Some(10), Some(20), Some(30)); + let serialized = serde_json::to_string(&usage)?; + let deserialized: Usage = serde_json::from_str(&serialized)?; + + assert_eq!(usage.input_tokens, deserialized.input_tokens); + assert_eq!(usage.output_tokens, deserialized.output_tokens); + assert_eq!(usage.total_tokens, deserialized.total_tokens); + + // Test JSON structure + let json_value: serde_json::Value = serde_json::from_str(&serialized)?; + assert_eq!(json_value["input_tokens"], json!(10)); + assert_eq!(json_value["output_tokens"], json!(20)); + assert_eq!(json_value["total_tokens"], json!(30)); + + Ok(()) + } + + #[test] + fn test_from_cache_exclusive_input_folds_cache_into_input_and_total() { + let usage = + Usage::from_cache_exclusive_input(Some(10), Some(50), Some(60), Some(5000), Some(1000)); + + assert_eq!(usage.input_tokens, Some(6010)); + assert_eq!(usage.output_tokens, Some(50)); + assert_eq!(usage.total_tokens, Some(6060)); + assert_eq!(usage.cache_read_input_tokens, Some(5000)); + assert_eq!(usage.cache_write_input_tokens, Some(1000)); + } + + #[test] + fn test_usage_addition_includes_cached_tokens() { + let usage_a = + Usage::new(Some(100), Some(20), Some(120)).with_cache_tokens(Some(10), Some(5)); + let usage_b = Usage::new(Some(50), Some(8), Some(58)).with_cache_tokens(Some(4), Some(1)); + + let combined = usage_a + usage_b; + + assert_eq!(combined.input_tokens, Some(150)); + assert_eq!(combined.output_tokens, Some(28)); + assert_eq!(combined.total_tokens, Some(178)); + assert_eq!(combined.cache_read_input_tokens, Some(14)); + assert_eq!(combined.cache_write_input_tokens, Some(6)); + } +} diff --git a/crates/goose/src/conversation/tool_result_serde.rs b/crates/goose-provider-types/src/conversation/tool_result_serde.rs similarity index 99% rename from crates/goose/src/conversation/tool_result_serde.rs rename to crates/goose-provider-types/src/conversation/tool_result_serde.rs index 54578bcc08..b0d6c73786 100644 --- a/crates/goose/src/conversation/tool_result_serde.rs +++ b/crates/goose-provider-types/src/conversation/tool_result_serde.rs @@ -1,4 +1,4 @@ -use crate::mcp_utils::ToolResult; +use crate::conversation::message::ToolResult; use rmcp::model::{CallToolRequestParams, ErrorCode, ErrorData, JsonObject}; use serde::ser::SerializeStruct; use serde::{Deserialize, Deserializer, Serialize, Serializer}; diff --git a/crates/goose/src/providers/errors.rs b/crates/goose-provider-types/src/errors.rs similarity index 85% rename from crates/goose/src/providers/errors.rs rename to crates/goose-provider-types/src/errors.rs index 46a2aa4ca0..ed38de7e25 100644 --- a/crates/goose/src/providers/errors.rs +++ b/crates/goose-provider-types/src/errors.rs @@ -2,6 +2,8 @@ use reqwest::StatusCode; use std::time::Duration; use thiserror::Error; +use crate::request_log::LogError; + #[derive(Error, Debug, Clone, PartialEq)] pub enum ProviderError { #[error("Authentication error: {0}")] @@ -42,9 +44,19 @@ pub enum ProviderError { details: String, top_up_url: Option, }, + + #[error("Provider refused request: {details}")] + Refusal { + details: String, + category: Option, + }, } impl ProviderError { + pub fn stream_decode_error(error: impl std::fmt::Display) -> Self { + ProviderError::NetworkError(format!("Stream decode error: {error}")) + } + pub fn telemetry_type(&self) -> &'static str { match self { ProviderError::Authentication(_) => "auth", @@ -58,12 +70,22 @@ impl ProviderError { ProviderError::NotImplemented(_) => "not_implemented", ProviderError::EndpointNotFound(_) => "endpoint_not_found", ProviderError::CreditsExhausted { .. } => "credits_exhausted", + ProviderError::Refusal { .. } => "refusal", } } pub fn is_endpoint_not_found(&self) -> bool { matches!(self, ProviderError::EndpointNotFound(_)) } + + /// Recover a typed `ProviderError` from a streaming decode error, falling + /// back to a retryable stream decode error for errors that did not + /// originate as one. + pub fn from_stream_error(error: anyhow::Error) -> Self { + error + .downcast() + .unwrap_or_else(ProviderError::stream_decode_error) + } } fn is_network_error(err: &reqwest::Error) -> bool { @@ -122,6 +144,12 @@ impl From for ProviderError { } } +impl From for ProviderError { + fn from(value: LogError) -> Self { + ProviderError::ExecutionError(value.to_string()) + } +} + #[derive(Debug)] pub enum GoogleErrorCode { BadRequest = 400, 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 54% rename from crates/goose/src/providers/formats/anthropic.rs rename to crates/goose-provider-types/src/formats/anthropic.rs index d39b0bacc4..5ceb5fe57c 100644 --- a/crates/goose/src/providers/formats/anthropic.rs +++ b/crates/goose-provider-types/src/formats/anthropic.rs @@ -1,9 +1,12 @@ +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::model::{ModelConfig, ThinkingEffort}; -use crate::providers::base::Usage; -use crate::providers::errors::ProviderError; -use crate::providers::utils::{convert_image, ImageFormat}; +use crate::model::ModelConfig; +use crate::thinking::ThinkingEffort; use anyhow::{anyhow, Result}; use rmcp::model::{object, CallToolRequestParams, ErrorCode, ErrorData, JsonObject, Role, Tool}; use rmcp::object as json_object; @@ -13,6 +16,8 @@ use std::fmt; use std::str::FromStr; use std::sync::Arc; +pub const ANTHROPIC_PROVIDER_NAME: &str = "anthropic"; + macro_rules! string_enum { ($name:ident { $($variant:ident => $str:literal),+ $(,)? }) => { #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -42,52 +47,74 @@ 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 - .get_config_param::( - "preserve_thinking_context", - "ANTHROPIC_PRESERVE_THINKING_CONTEXT", - ) + .request_param::("preserve_thinking_context") .unwrap_or(self.preserve_thinking_context); let preserve_unsigned_thinking = model_config - .get_config_param::( - "preserve_unsigned_thinking", - "ANTHROPIC_PRESERVE_UNSIGNED_THINKING", - ) + .request_param::("preserve_unsigned_thinking") .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, } } } -pub fn supports_adaptive_thinking(model_name: &str) -> bool { - let lower = model_name.to_lowercase(); - lower.contains("claude-opus-4-6") || lower.contains("claude-sonnet-4-6") +fn canonical_thinking_mode(provider_name: &str, model_name: &str) -> Option { + maybe_get_canonical_model(provider_name, model_name).and_then(|model| model.thinking_mode) +} + +fn canonical_reasoning(provider_name: &str, model_config: &ModelConfig) -> Option { + maybe_get_canonical_model(provider_name, &model_config.model_name) + .and_then(|model| model.reasoning) +} + +pub fn model_supports_temperature(provider_name: &str, model_config: &ModelConfig) -> bool { + maybe_get_canonical_model(provider_name, &model_config.model_name) + .and_then(|model| model.temperature) + .unwrap_or(true) } pub fn thinking_type(model_config: &ModelConfig) -> ThinkingType { - let model_lower = model_config.model_name.to_lowercase(); - if !model_lower.contains("claude") { + thinking_type_for_provider(ANTHROPIC_PROVIDER_NAME, model_config) +} + +pub fn thinking_type_for_provider(provider_name: &str, model_config: &ModelConfig) -> ThinkingType { + let mode = canonical_thinking_mode(provider_name, &model_config.model_name); + let reasoning = model_config + .reasoning + .or_else(|| canonical_reasoning(provider_name, model_config)); + + if reasoning != Some(true) { return ThinkingType::Disabled; } - let is_adaptive_model = supports_adaptive_thinking(&model_config.model_name); + if mode == Some(ThinkingMode::AlwaysOnAdaptive) { + return ThinkingType::Adaptive; + } + let effort = model_config.thinking_effort(); - if effort.is_none() && legacy_thinking_budget_tokens().is_some() { - return ThinkingType::Enabled; + if effort.is_none() && model_config.request_param::("budget_tokens").is_some() { + return match mode { + Some(ThinkingMode::Adaptive) => ThinkingType::Adaptive, + _ => ThinkingType::Enabled, + }; } match effort.unwrap_or(ThinkingEffort::Off) { ThinkingEffort::Off => ThinkingType::Disabled, - _ if is_adaptive_model => ThinkingType::Adaptive, + _ if mode == Some(ThinkingMode::Adaptive) => ThinkingType::Adaptive, _ => ThinkingType::Enabled, } } @@ -117,6 +144,8 @@ const EVENT_MESSAGE_STOP: &str = "message_stop"; const EVENT_CONTENT_BLOCK_START: &str = "content_block_start"; const EVENT_CONTENT_BLOCK_DELTA: &str = "content_block_delta"; const EVENT_CONTENT_BLOCK_STOP: &str = "content_block_stop"; +const STOP_REASON_REFUSAL: &str = "refusal"; +const REFUSAL_FALLBACK_DETAILS: &str = "No additional details were provided."; /// Coerce a tool call's optional arguments into the JSON value Anthropic /// expects for the `input` field of a `tool_use` content block. @@ -172,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!({}) + })); } } } @@ -222,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)); @@ -266,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, @@ -277,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; @@ -304,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!({ @@ -409,89 +508,64 @@ pub fn response_to_message(response: &Value) -> Result { Ok(message) } -/// Extract usage information from Anthropic's API response +fn usage_from_anthropic_fields(usage: &Value) -> Usage { + let field = |key: &str| { + usage + .get(key) + .and_then(|v| v.as_u64()) + .map(|v| v.min(i32::MAX as u64) as i32) + }; + + Usage::from_cache_exclusive_input( + Some(field("input_tokens").unwrap_or(0)), + Some(field("output_tokens").unwrap_or(0)), + None, + field("cache_read_input_tokens"), + field("cache_creation_input_tokens"), + ) +} + +/// Merge a `message_delta` usage into the usage captured at `message_start`. +/// Delta usage is cumulative (input grows during server tool use), so fields +/// present in the raw delta payload win over the start values. +fn merge_delta_usage(existing: &Usage, delta: &Usage, delta_data: &Value) -> Usage { + let reports = |key: &str| delta_data.get(key).is_some(); + + let output = if reports("output_tokens") { + delta.output_tokens + } else { + existing.output_tokens + }; + + if !reports("input_tokens") { + Usage::new(existing.input_tokens, output, None).with_cache_tokens( + existing.cache_read_input_tokens, + existing.cache_write_input_tokens, + ) + } else if reports("cache_read_input_tokens") || reports("cache_creation_input_tokens") { + Usage::new(delta.input_tokens, output, None).with_cache_tokens( + delta.cache_read_input_tokens, + delta.cache_write_input_tokens, + ) + } else { + Usage::from_cache_exclusive_input( + delta.input_tokens, + output, + None, + existing.cache_read_input_tokens, + existing.cache_write_input_tokens, + ) + } +} + pub fn get_usage(data: &Value) -> Result { - // Extract usage data if available if let Some(usage) = data.get("usage") { - // Get all token fields for analysis - let input_tokens = usage - .get("input_tokens") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - - let cache_creation_tokens = usage - .get("cache_creation_input_tokens") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - - let cache_read_tokens = usage - .get("cache_read_input_tokens") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - - let output_tokens = usage - .get("output_tokens") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - - // IMPORTANT: For display purposes, we want to show the ACTUAL total tokens consumed - // The cache pricing should only affect cost calculation, not token count display - let total_input_tokens = input_tokens + cache_creation_tokens + cache_read_tokens; - - // Convert to i32 with bounds checking - let total_input_i32 = total_input_tokens.min(i32::MAX as u64) as i32; - let output_tokens_i32 = output_tokens.min(i32::MAX as u64) as i32; - let total_tokens_i32 = - (total_input_i32 as i64 + output_tokens_i32 as i64).min(i32::MAX as i64) as i32; - - Ok(Usage::new( - Some(total_input_i32), - Some(output_tokens_i32), - Some(total_tokens_i32), - )) + Ok(usage_from_anthropic_fields(usage)) } else if data.as_object().is_some() { // Check if the data itself is the usage object (for message_delta events that might have usage at top level) - let input_tokens = data - .get("input_tokens") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - - let cache_creation_tokens = data - .get("cache_creation_input_tokens") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - - let cache_read_tokens = data - .get("cache_read_input_tokens") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - - let output_tokens = data - .get("output_tokens") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - - // If we found any token data, process it - if input_tokens > 0 - || cache_creation_tokens > 0 - || cache_read_tokens > 0 - || output_tokens > 0 - { - let total_input_tokens = input_tokens + cache_creation_tokens + cache_read_tokens; - - let total_input_i32 = total_input_tokens.min(i32::MAX as u64) as i32; - let output_tokens_i32 = output_tokens.min(i32::MAX as u64) as i32; - let total_tokens_i32 = - (total_input_i32 as i64 + output_tokens_i32 as i64).min(i32::MAX as i64) as i32; - - tracing::debug!("๐Ÿ” Anthropic ACTUAL token counts from direct object: input={}, output={}, total={}", - total_input_i32, output_tokens_i32, total_tokens_i32); - - Ok(Usage::new( - Some(total_input_i32), - Some(output_tokens_i32), - Some(total_tokens_i32), - )) + let usage = usage_from_anthropic_fields(data); + if usage.total_tokens.unwrap_or(0) > 0 { + Ok(usage) } else { tracing::debug!("๐Ÿ” Anthropic no token data found in object"); Ok(Usage::new(None, None, None)) @@ -506,12 +580,32 @@ 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() .unwrap_or(ThinkingEffort::High) } +pub fn adaptive_output_effort(model_config: &ModelConfig) -> ThinkingEffort { + match thinking_effort(model_config) { + ThinkingEffort::Off => ThinkingEffort::High, + effort => effort, + } +} + pub fn thinking_budget_tokens(model_config: &ModelConfig) -> i32 { if let Some(request_param) = model_config .request_params @@ -522,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); @@ -538,55 +628,55 @@ 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. +// Shared with the Bedrock formatter, which applies the same clamp. +pub const MIN_ANSWER_TOKENS: i32 = 1024; fn apply_thinking_config( payload: &mut Value, + provider_name: &str, model_config: &ModelConfig, max_tokens: i32, options: AnthropicFormatOptions, ) { let obj = payload.as_object_mut().unwrap(); - match thinking_type(model_config) { + match thinking_type_for_provider(provider_name, model_config) { ThinkingType::Adaptive => { obj.insert("thinking".to_string(), json!({"type": "adaptive"})); - let effort = thinking_effort(model_config).to_string(); + let effort = adaptive_output_effort(model_config).to_string(); obj.insert("output_config".to_string(), json!({"effort": effort})); } ThinkingType::Enabled => { - let budget_tokens = thinking_budget_tokens(model_config); - - obj.insert("max_tokens".to_string(), json!(max_tokens + budget_tokens)); - obj.insert( - "thinking".to_string(), - json!({ - "type": "enabled", - "budget_tokens": budget_tokens - }), - ); + let budget_tokens = thinking_budget_tokens(model_config) + .min(max_tokens.saturating_sub(MIN_ANSWER_TOKENS)); + if budget_tokens >= MIN_ANSWER_TOKENS { + obj.insert( + "thinking".to_string(), + json!({ + "type": "enabled", + "budget_tokens": budget_tokens + }), + ); + } } 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); - obj.insert("max_tokens".to_string(), json!(max_tokens + budget_tokens)); - obj.insert( - "thinking".to_string(), - json!({ - "type": "enabled", - "budget_tokens": budget_tokens - }), - ); + let budget_tokens = thinking_budget_tokens(model_config) + .min(max_tokens.saturating_sub(MIN_ANSWER_TOKENS)); + if budget_tokens >= MIN_ANSWER_TOKENS { + obj.insert( + "thinking".to_string(), + json!({ + "type": "enabled", + "budget_tokens": budget_tokens + }), + ); + } } if let Some(thinking) = obj.get_mut("thinking").and_then(|t| t.as_object_mut()) { @@ -595,23 +685,8 @@ 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( + provider_name: &str, model_config: &ModelConfig, system: &str, messages: &[Message], @@ -648,14 +723,22 @@ pub fn create_request_with_options( .insert("tools".to_string(), json!(tool_specs)); } - if let Some(temp) = model_config.temperature { - payload - .as_object_mut() - .unwrap() - .insert("temperature".to_string(), json!(temp)); + if model_supports_temperature(provider_name, model_config) { + if let Some(temp) = model_config.temperature { + payload + .as_object_mut() + .unwrap() + .insert("temperature".to_string(), json!(temp)); + } } - apply_thinking_config(&mut payload, model_config, max_tokens, options); + apply_thinking_config( + &mut payload, + provider_name, + model_config, + max_tokens, + options, + ); Ok(payload) } @@ -663,12 +746,7 @@ pub fn create_request_with_options( /// Process streaming response from Anthropic's API pub fn response_to_streaming_message( mut stream: S, -) -> impl futures::Stream< - Item = anyhow::Result<( - Option, - Option, - )>, -> + 'static +) -> impl futures::Stream, Option)>> + 'static where S: futures::Stream> + Unpin + Send + 'static, { @@ -702,9 +780,10 @@ where try_stream! { let mut accumulated_tool_calls: std::collections::HashMap = std::collections::HashMap::new(); let mut current_tool_id: Option = None; - let mut final_usage: Option = None; + 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?; @@ -744,7 +823,7 @@ where .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); - final_usage = Some(crate::providers::base::ProviderUsage::new(model, usage)); + final_usage = Some(provider_usage_with_cost(model, usage, usage_data, None)); } } continue; @@ -831,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( @@ -875,23 +956,51 @@ where let delta_usage = get_usage(usage_data).unwrap_or_default(); if let Some(existing_usage) = &final_usage { - let merged_input = existing_usage.usage.input_tokens.or(delta_usage.input_tokens); - let merged_output = delta_usage.output_tokens.or(existing_usage.usage.output_tokens); - let merged_total = match (merged_input, merged_output) { - (Some(input), Some(output)) => Some(input + output), - (Some(input), None) => Some(input), - (None, Some(output)) => Some(output), - (None, None) => None, - }; - - let merged_usage = crate::providers::base::Usage::new(merged_input, merged_output, merged_total); - final_usage = Some(crate::providers::base::ProviderUsage::new(existing_usage.model.clone(), merged_usage)); + let merged_usage = merge_delta_usage(&existing_usage.usage, &delta_usage, usage_data); + 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(crate::providers::base::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)) + .and_then(|v| v.as_str()) + .map(str::to_string); + let details = str_field("explanation") + .or_else(|| stop_details.map(|d| d.to_string())) + .unwrap_or_else(|| REFUSAL_FALLBACK_DETAILS.to_string()); + let category = str_field("category"); + // The refusal delta carries the request's usage; + // flush it so refused turns are still accounted. + if let Some(usage) = final_usage.take() { + yield (None, Some(usage)); + } + Err(ProviderError::Refusal { details, category })?; + } else if let Some(details) = stop_details { + // No specific handling for these stop details yet โ€” + // forward them rather than silently dropping the turn. + let mut message = Message::assistant().with_text(format!( + "The provider ended the response with: {details}" + )); + message.id = message_id.clone(); + yield (Some(message), None); } } continue; @@ -903,7 +1012,8 @@ where .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); - final_usage = Some(crate::providers::base::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; } @@ -915,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)); } @@ -929,6 +1071,39 @@ mod tests { 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!({ @@ -962,6 +1137,8 @@ mod tests { assert_eq!(usage.input_tokens, Some(24)); // 12 + 12 = 24 actual tokens assert_eq!(usage.output_tokens, Some(15)); assert_eq!(usage.total_tokens, Some(39)); // 24 + 15 + assert_eq!(usage.cache_read_input_tokens, Some(0)); + assert_eq!(usage.cache_write_input_tokens, Some(12)); Ok(()) } @@ -1005,6 +1182,8 @@ mod tests { assert_eq!(usage.input_tokens, Some(30)); // 15 + 15 = 30 actual tokens assert_eq!(usage.output_tokens, Some(20)); assert_eq!(usage.total_tokens, Some(50)); // 30 + 20 + assert_eq!(usage.cache_read_input_tokens, Some(0)); + assert_eq!(usage.cache_write_input_tokens, Some(15)); Ok(()) } @@ -1086,6 +1265,7 @@ mod tests { AnthropicFormatOptions { preserve_unsigned_thinking: true, preserve_thinking_context: false, + thinking_disabled: false, }, ); @@ -1182,6 +1362,8 @@ mod tests { assert_eq!(usage.input_tokens, Some(15007)); assert_eq!(usage.output_tokens, Some(50)); assert_eq!(usage.total_tokens, Some(15057)); // 15007 + 50 + assert_eq!(usage.cache_read_input_tokens, Some(5000)); + assert_eq!(usage.cache_write_input_tokens, Some(10000)); Ok(()) } @@ -1197,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"); @@ -1213,16 +1395,44 @@ mod tests { ("ANTHROPIC_PRESERVE_THINKING_CONTEXT", None::<&str>), ]); - let mut config = cfg_with_effort("claude-3-7-sonnet-20250219", "high"); - config.max_tokens = Some(4096); + 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(); assert!(budget > 0); - assert_eq!(payload["max_tokens"], 4096 + budget); + assert_eq!(payload["max_tokens"], 64000); + assert!(budget < 64000); + + Ok(()) + } + + #[test] + fn test_create_request_clamps_thinking_budget_to_fit_max_tokens() -> Result<()> { + let _guard = env_lock::lock_env([ + ("GOOSE_THINKING_EFFORT", None::<&str>), + ("ANTHROPIC_PRESERVE_THINKING_CONTEXT", None::<&str>), + ]); + + 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_with_default_options(&config, "system", &messages, &[])?; + let budget = payload["thinking"]["budget_tokens"].as_i64().unwrap(); + assert!(budget >= 1024); + assert!(budget <= 4096 - 1024); + assert_eq!(payload["max_tokens"], 4096); + + // Too small to fit any thinking alongside a response โ€” drop it. + config.max_tokens = Some(1500); + let payload = create_request_with_default_options(&config, "system", &messages, &[])?; + assert!(payload.get("thinking").is_none()); + assert_eq!(payload["max_tokens"], 1500); Ok(()) } @@ -1236,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()); @@ -1247,22 +1457,19 @@ 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>), ]); let mut config = cfg("glm-4.7"); - config.max_tokens = Some(4096); + config.max_tokens = Some(64000); let messages = vec![ Message::assistant().with_content(MessageContent::thinking("internal", "")), Message::user().with_text("Continue"), ]; - let payload = create_request_with_options( + let payload = create_request_with_options_provider( &config, "system", &messages, @@ -1270,13 +1477,14 @@ mod tests { AnthropicFormatOptions { preserve_unsigned_thinking: true, preserve_thinking_context: true, + thinking_disabled: false, }, )?; assert_eq!(payload["thinking"]["type"], "enabled"); - assert_eq!(payload["thinking"]["budget_tokens"], 16000); + assert!(payload["thinking"]["budget_tokens"].as_i64().unwrap() >= 1024); assert_eq!(payload["thinking"]["clear_thinking"], false); - assert_eq!(payload["max_tokens"], 4096 + 16000); + assert_eq!(payload["max_tokens"], 64000); assert_eq!(payload["messages"][0]["content"][0]["type"], "thinking"); assert_eq!(payload["messages"][0]["content"][0]["thinking"], "internal"); assert!(payload["messages"][0]["content"][0] @@ -1289,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>), ]); @@ -1302,12 +1507,13 @@ mod tests { let mut config = cfg("glm-4.7"); config.request_params = Some(params); + config.max_tokens = Some(64000); let messages = vec![ Message::assistant().with_content(MessageContent::thinking("internal", "")), 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"); @@ -1449,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"}); @@ -1495,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] @@ -1519,6 +1754,14 @@ mod tests { thinking_type(&cfg_with_effort("claude-opus-4-6", "high")), ThinkingType::Adaptive ); + assert_eq!( + thinking_type(&cfg_with_effort("claude-opus-4-7", "high")), + ThinkingType::Adaptive + ); + assert_eq!( + thinking_type(&cfg_with_effort("claude-opus-4-8", "high")), + ThinkingType::Adaptive + ); // Adaptive model with off โ†’ disabled assert_eq!( thinking_type(&cfg_with_effort("claude-opus-4-6", "off")), @@ -1526,25 +1769,49 @@ 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 ); } #[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); + fn test_thinking_type_always_on_adaptive() { + let _guard = env_lock::lock_env([("GOOSE_THINKING_EFFORT", None::<&str>)]); + + assert_eq!( + thinking_type(&cfg("claude-fable-5")), + ThinkingType::Adaptive + ); + assert_eq!( + thinking_type(&cfg_with_effort("claude-fable-5", "off")), + ThinkingType::Adaptive + ); + assert_eq!( + thinking_type(&cfg_with_effort("claude-fable-5", "high")), + ThinkingType::Adaptive + ); + } + + #[test] + fn test_create_request_fable_5_omits_temperature() -> Result<()> { + let _guard = env_lock::lock_env([("GOOSE_THINKING_EFFORT", None::<&str>)]); + let mut config = cfg("claude-fable-5"); + config.max_tokens = Some(4096); + config.temperature = Some(0.7); + let messages = vec![Message::user().with_text("Hello")]; + + let payload = create_request_with_default_options(&config, "system", &messages, &[])?; + + assert_eq!(payload["thinking"]["type"], "adaptive"); + assert!(payload.get("temperature").is_none()); + assert_eq!(payload["output_config"]["effort"], "high"); + + Ok(()) } #[test] @@ -1566,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 ); } @@ -1577,19 +1844,14 @@ mod tests { redacted_thinking: Vec, text: Vec, tool_calls: Vec, + tool_errors: Vec, } async fn collect_stream(events: &str) -> StreamedParts { - use futures::StreamExt; - - let lines: Vec> = - events.lines().map(|l| Ok(l.to_string())).collect(); - let stream = Box::pin(futures::stream::iter(lines)); - let mut msg_stream = std::pin::pin!(response_to_streaming_message(stream)); let mut parts = StreamedParts::default(); - while let Some(Ok((message, _usage))) = msg_stream.next().await { - if let Some(msg) = message { + for result in collect_stream_results(events).await { + if let Ok((Some(msg), _usage)) = result { for c in &msg.content { match c { MessageContent::Thinking(t) => { @@ -1603,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()), + }, _ => {} } } @@ -1738,4 +1999,547 @@ mod tests { assert_eq!(parts.text, vec!["Let me search for that."]); assert_eq!(parts.tool_calls, vec!["search"]); } + + async fn collect_stream_results( + events: &str, + ) -> Vec, Option)>> { + use futures::StreamExt; + + let lines: Vec> = + events.lines().map(|l| Ok(l.to_string())).collect(); + let stream = Box::pin(futures::stream::iter(lines)); + response_to_streaming_message(stream).collect().await + } + + #[tokio::test] + async fn test_streaming_preserves_cache_tokens_through_delta_merge() { + let events = concat!( + r#"data: {"type":"message_start","message":{"id":"msg_1","role":"assistant","content":[],"model":"claude-opus-4-6","usage":{"input_tokens":7,"cache_creation_input_tokens":10000,"cache_read_input_tokens":5000,"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":25}}"#, + "\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.usage.input_tokens, Some(15007)); + assert_eq!(usage.usage.output_tokens, Some(25)); + assert_eq!(usage.usage.cache_read_input_tokens, Some(5000)); + 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 + // message_delta usage is authoritative, not message_start. + let events = concat!( + r#"data: {"type":"message_start","message":{"id":"msg_1","role":"assistant","content":[],"model":"claude-opus-4-6","usage":{"input_tokens":2679,"cache_creation_input_tokens":100,"cache_read_input_tokens":200,"output_tokens":3}}}"#, + "\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":{"input_tokens":10682,"cache_creation_input_tokens":100,"cache_read_input_tokens":200,"output_tokens":510}}"#, + "\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.usage.input_tokens, Some(10982)); // 10682 + 100 + 200 + assert_eq!(usage.usage.output_tokens, Some(510)); + assert_eq!(usage.usage.cache_read_input_tokens, Some(200)); + assert_eq!(usage.usage.cache_write_input_tokens, Some(100)); + } + + #[test] + fn test_merge_delta_usage_raw_input_inherits_start_cache() { + let start = + Usage::new(Some(15007), Some(3), None).with_cache_tokens(Some(5000), Some(10000)); + let delta_data = json!({"input_tokens": 8, "output_tokens": 510}); + let delta = get_usage(&delta_data).unwrap(); + + let merged = merge_delta_usage(&start, &delta, &delta_data); + assert_eq!(merged.input_tokens, Some(15008)); // 8 + 5000 + 10000 + assert_eq!(merged.output_tokens, Some(510)); + assert_eq!(merged.cache_read_input_tokens, Some(5000)); + assert_eq!(merged.cache_write_input_tokens, Some(10000)); + } + + fn expect_refusal( + results: Vec, Option)>>, + ) -> (String, Option) { + let err = results + .into_iter() + .find_map(|r| r.err()) + .expect("refusal should surface as a stream error"); + match err.downcast_ref::() { + Some(ProviderError::Refusal { details, category }) => { + (details.clone(), category.clone()) + } + other => panic!("expected ProviderError::Refusal, got {:?}", other), + } + } + + #[tokio::test] + async fn test_streaming_refusal() { + let events = concat!( + r#"data: {"type":"message_start","message":{"id":"msg_1","role":"assistant","content":[],"model":"claude-opus-4-6","usage":{"input_tokens":10,"output_tokens":0}}}"#, + "\n", + r#"data: {"type":"message_delta","delta":{"stop_reason":"refusal","stop_details":{"explanation":"This request violates the usage policy.","category":"cyber"}},"usage":{"output_tokens":5}}"#, + "\n", + r#"data: {"type":"message_stop"}"#, + ); + + let results = collect_stream_results(events).await; + let usage = results + .iter() + .filter_map(|r| r.as_ref().ok()) + .find_map(|(_, usage)| usage.clone()) + .expect("a refused request should still yield its usage"); + assert_eq!(usage.usage.input_tokens, Some(10)); + assert_eq!(usage.usage.output_tokens, Some(5)); + + let (details, category) = expect_refusal(results); + assert_eq!(details, "This request violates the usage policy."); + assert_eq!(category.as_deref(), Some("cyber")); + } + + #[tokio::test] + async fn test_streaming_refusal_forwards_unrecognized_stop_details() { + let events = concat!( + r#"data: {"type":"message_start","message":{"id":"msg_1","role":"assistant","content":[],"model":"claude-opus-4-6","usage":{"input_tokens":10,"output_tokens":0}}}"#, + "\n", + r#"data: {"type":"message_delta","delta":{"stop_reason":"refusal","stop_details":{"code":42}},"usage":{"output_tokens":5}}"#, + "\n", + r#"data: {"type":"message_stop"}"#, + ); + + let (details, category) = expect_refusal(collect_stream_results(events).await); + assert!(details.contains("\"code\":42"), "details: {details}"); + assert_eq!(category, None); + } + + #[tokio::test] + async fn test_streaming_forwards_unhandled_stop_details() { + let events = concat!( + r#"data: {"type":"message_start","message":{"id":"msg_1","role":"assistant","content":[],"model":"claude-opus-4-6","usage":{"input_tokens":10,"output_tokens":0}}}"#, + "\n", + r#"data: {"type":"message_delta","delta":{"stop_reason":"model_context_window_exceeded","stop_details":{"reason":"context_window"}},"usage":{"output_tokens":5}}"#, + "\n", + r#"data: {"type":"message_stop"}"#, + ); + + let parts = collect_stream(events).await; + assert_eq!(parts.text.len(), 1); + assert!( + parts.text[0].starts_with("The provider ended the response with:"), + "text: {}", + parts.text[0] + ); + 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 85% rename from crates/goose/src/providers/formats/databricks.rs rename to crates/goose-provider-types/src/formats/databricks.rs index 38d84111b9..cbf0d2b9fc 100644 --- a/crates/goose/src/providers/formats/databricks.rs +++ b/crates/goose-provider-types/src/formats/databricks.rs @@ -1,13 +1,15 @@ use crate::conversation::message::{Message, MessageContent}; +use crate::formats::anthropic::{ + adaptive_output_effort, model_supports_temperature, thinking_budget_tokens, + thinking_type_for_provider, ThinkingType, +}; use crate::model::ModelConfig; -use crate::providers::formats::anthropic::{ - thinking_budget_tokens, thinking_effort, thinking_type, ThinkingType, -}; -use crate::providers::utils::{ - convert_image, detect_image_path, extract_reasoning_effort, is_openai_responses_model, - is_valid_function_name, load_image_file, openai_reasoning_effort_for_thinking, - safely_parse_json, sanitize_function_name, ImageFormat, + +use crate::formats::openai::{ + extract_reasoning_effort, is_openai_responses_model, is_valid_function_name, + openai_reasoning_effort_for_thinking, sanitize_function_name, }; +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, @@ -17,6 +19,8 @@ use serde::Serialize; use serde_json::{json, Value}; use std::borrow::Cow; +pub const DATABRICKS_PROVIDER_NAME: &str = "databricks"; + #[derive(Serialize)] struct DatabricksMessage { content: Value, @@ -30,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 @@ -185,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": "{}", + } + })); } } } @@ -241,15 +257,19 @@ fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec { obj.insert("thinking".to_string(), json!({ "type": "adaptive" })); obj.insert( "output_config".to_string(), - json!({ "effort": thinking_effort(model_config).to_string() }), + json!({ "effort": adaptive_output_effort(model_config).to_string() }), ); obj.insert( "max_completion_tokens".to_string(), @@ -270,8 +290,10 @@ fn apply_claude_thinking_config(payload: &mut Value, model_config: &ModelConfig) obj.insert("temperature".to_string(), json!(2)); } ThinkingType::Disabled => { - if let Some(temp) = model_config.temperature { - obj.insert("temperature".to_string(), json!(temp)); + if model_supports_temperature(provider_name, model_config) { + if let Some(temp) = model_config.temperature { + obj.insert("temperature".to_string(), json!(temp)); + } } obj.insert( "max_completion_tokens".to_string(), @@ -281,12 +303,10 @@ fn apply_claude_thinking_config(payload: &mut Value, model_config: &ModelConfig) } } -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)); @@ -298,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))); + } } } } @@ -583,6 +613,24 @@ pub fn create_request( messages: &[Message], tools: &[Tool], image_format: &ImageFormat, +) -> anyhow::Result { + create_request_for_provider( + DATABRICKS_PROVIDER_NAME, + model_config, + system, + messages, + tools, + image_format, + ) +} + +pub fn create_request_for_provider( + provider_name: &str, + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + image_format: &ImageFormat, ) -> anyhow::Result { if model_config.model_name.starts_with("o1-mini") { return Err(anyhow!( @@ -642,10 +690,10 @@ pub fn create_request( } if is_claude_model(&model_config.model_name) { - apply_claude_thinking_config(&mut payload, model_config); + apply_claude_thinking_config(&mut payload, provider_name, model_config); } else { // open ai reasoning models currently don't support temperature - if !is_openai_reasoning_model { + if !is_openai_reasoning_model && model_supports_temperature(provider_name, model_config) { if let Some(temp) = model_config.temperature { payload .as_object_mut() @@ -743,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(()) } @@ -996,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"), } @@ -1007,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)?; @@ -1036,7 +1115,6 @@ mod tests { max_tokens: Some(1024), toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1071,7 +1149,6 @@ mod tests { max_tokens: Some(1024), toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: Some(params), reasoning: None, }; @@ -1091,7 +1168,6 @@ mod tests { max_tokens: Some(1024), toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: Some(params), reasoning: None, }; @@ -1112,7 +1188,6 @@ mod tests { max_tokens: Some(1024), toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: Some(params), reasoning: None, }; @@ -1131,7 +1206,6 @@ mod tests { max_tokens: Some(1024), toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1150,7 +1224,6 @@ mod tests { max_tokens: Some(1024), toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1169,7 +1242,6 @@ mod tests { max_tokens: Some(1024), toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1181,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")); @@ -1198,9 +1270,54 @@ mod tests { Ok(()) } + #[test] + fn test_create_request_adaptive_thinking_for_new_anthropic_models() -> anyhow::Result<()> { + let _guard = env_lock::lock_env([("GOOSE_THINKING_EFFORT", None::<&str>)]); + + for name in [ + "databricks-claude-opus-4-7", + "databricks-claude-opus-4-8", + "databricks-claude-fable-5", + "global.anthropic.claude-fable-5", + ] { + 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")); + model_config.request_params = Some(params); + + let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?; + + assert_eq!(request["thinking"]["type"], "adaptive", "{name}"); + assert!(request.get("temperature").is_none(), "{name}"); + assert_eq!(request["max_completion_tokens"], 4096, "{name}"); + assert!(request.get("max_tokens").is_none(), "{name}"); + } + + Ok(()) + } + + #[test] + 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("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")); + model_config.request_params = Some(params); + + let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?; + + assert_eq!(request["thinking"]["type"], "adaptive"); + assert_eq!(request["output_config"]["effort"], "high"); + + Ok(()) + } + #[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")); @@ -1225,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)); @@ -1244,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", @@ -1292,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", @@ -1359,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 @@ -1392,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")); @@ -1569,7 +1731,6 @@ mod tests { max_tokens: Some(8192), toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1622,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 91% rename from crates/goose/src/providers/formats/google.rs rename to crates/goose-provider-types/src/formats/google.rs index b35c2db504..8d9d5e913a 100644 --- a/crates/goose/src/providers/formats/google.rs +++ b/crates/goose-provider-types/src/formats/google.rs @@ -1,7 +1,8 @@ +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::providers::base::Usage; -use crate::providers::errors::ProviderError; -use crate::providers::utils::{is_valid_function_name, sanitize_function_name}; +use crate::thinking::ThinkingEffort; use anyhow::Result; use rmcp::model::{ object, AnnotateAble, CallToolRequestParams, ErrorCode, ErrorData, RawContent, Role, Tool, @@ -16,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(); @@ -346,7 +347,13 @@ pub fn get_usage(data: &Value) -> Result { .get("totalTokenCount") .and_then(|v| v.as_u64()) .map(|v| v as i32); - Ok(Usage::new(input_tokens, output_tokens, total_tokens)) + // promptTokenCount already includes cachedContentTokenCount + let cached_tokens = usage_meta_data + .get("cachedContentTokenCount") + .and_then(|v| v.as_u64()) + .map(|v| v as i32); + Ok(Usage::new(input_tokens, output_tokens, total_tokens) + .with_cache_tokens(cached_tokens, None)) } else { tracing::debug!( "Failed to get usage data: {}", @@ -359,12 +366,7 @@ pub fn get_usage(data: &Value) -> Result { pub fn response_to_streaming_message( mut stream: S, -) -> impl futures::Stream< - Item = anyhow::Result<( - Option, - Option, - )>, -> + 'static +) -> impl futures::Stream, Option)>> + 'static where S: futures::Stream> + Unpin + Send + 'static, { @@ -372,7 +374,7 @@ where use futures::StreamExt; try_stream! { - let mut final_usage: Option = None; + let mut final_usage: Option = None; let mut last_signature: Option = None; let stream_id = Uuid::new_v4().to_string(); let mut incomplete_data: Option = None; @@ -437,7 +439,9 @@ where .get("status") .and_then(|s| s.as_str()) .unwrap_or("UNKNOWN"); - Err(anyhow::anyhow!("Google API error ({}): {}", status, message))?; + Err::<(), ProviderError>(ProviderError::RequestFailed(format!( + "Google API error ({status}): {message}" + )))?; } if let Ok(usage) = get_usage(&chunk) { @@ -446,7 +450,7 @@ where .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); - final_usage = Some(crate::providers::base::ProviderUsage::new(model, usage)); + final_usage = Some(ProviderUsage::new(model, usage)); } } @@ -533,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"); @@ -542,7 +567,6 @@ fn get_thinking_config(model_config: &ModelConfig) -> Option { } if is_gemini_3 { - use crate::model::ThinkingEffort; let effort = model_config .thinking_effort() .unwrap_or(ThinkingEffort::Off); @@ -563,7 +587,8 @@ fn get_thinking_config(model_config: &ModelConfig) -> Option { }) } else { let thinking_budget = match model_config - .get_config_param::("thinking_budget", "GEMINI25_THINKING_BUDGET") + .request_param::("thinking_budget") + .or(thinking_budget) { Some(budget) if budget >= 0 => budget, Some(budget) => { @@ -571,11 +596,11 @@ fn get_thinking_config(model_config: &ModelConfig) -> Option { "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, @@ -590,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 @@ -599,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), @@ -677,6 +722,26 @@ mod tests { assert_eq!(usage.input_tokens, Some(1)); assert_eq!(usage.output_tokens, Some(2)); assert_eq!(usage.total_tokens, Some(3)); + assert_eq!(usage.cache_read_input_tokens, None); + assert_eq!(usage.cache_write_input_tokens, None); + } + + #[test] + fn test_get_usage_with_cached_content() { + let data = json!({ + "usageMetadata": { + "promptTokenCount": 100, + "candidatesTokenCount": 20, + "totalTokenCount": 120, + "cachedContentTokenCount": 80 + } + }); + let usage = get_usage(&data).unwrap(); + assert_eq!(usage.input_tokens, Some(100)); + assert_eq!(usage.output_tokens, Some(20)); + assert_eq!(usage.total_tokens, Some(120)); + assert_eq!(usage.cache_read_input_tokens, Some(80)); + assert_eq!(usage.cache_write_input_tokens, None); } #[test] @@ -1370,6 +1435,19 @@ 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 crate::model::ModelConfig; @@ -1377,9 +1455,9 @@ data: [DONE]"#; // 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()); @@ -1389,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!( @@ -1399,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 98% rename from crates/goose/src/providers/formats/ollama.rs rename to crates/goose-provider-types/src/formats/ollama.rs index eb2c8ced32..b8d8ebbdc9 100644 --- a/crates/goose/src/providers/formats/ollama.rs +++ b/crates/goose-provider-types/src/formats/ollama.rs @@ -10,8 +10,10 @@ //! - qwen3-coder-32b use crate::conversation::message::{Message, MessageContent}; -use crate::providers::base::ProviderUsage; -use crate::providers::utils::is_valid_function_name; +use crate::{ + conversation::token_usage::ProviderUsage, + formats::openai::{self, is_valid_function_name}, +}; use async_stream::try_stream; use chrono; use futures::Stream; @@ -21,7 +23,7 @@ use serde_json::Value; use std::borrow::Cow; use uuid::Uuid; -pub use super::openai::{ +pub use crate::formats::openai::{ create_request, format_messages, format_tools, get_usage, validate_tool_schemas, }; @@ -82,7 +84,7 @@ pub fn parse_xml_tool_calls(content: &str) -> (Option, Vec anyhow::Result { - let message = super::openai::response_to_message(response)?; + let message = openai::response_to_message(response)?; let has_tool_requests = message .content @@ -163,7 +165,7 @@ where try_stream! { use futures::StreamExt; - let base_stream = super::openai::response_to_streaming_message(stream); + let base_stream = openai::response_to_streaming_message(stream); let mut base_stream = std::pin::pin!(base_stream); let mut accumulated_text = String::new(); diff --git a/crates/goose/src/providers/formats/openai.rs b/crates/goose-provider-types/src/formats/openai.rs similarity index 76% rename from crates/goose/src/providers/formats/openai.rs rename to crates/goose-provider-types/src/formats/openai.rs index b9d7ba285d..5069f73801 100644 --- a/crates/goose/src/providers/formats/openai.rs +++ b/crates/goose-provider-types/src/formats/openai.rs @@ -1,17 +1,18 @@ use crate::conversation::message::{Message, MessageContent, ProviderMetadata}; +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::{parse_tool_arguments, truncation_error_message}; use crate::mcp_utils::extract_text_from_resource; use crate::model::ModelConfig; -use crate::providers::base::{split_think_blocks, ProviderUsage, ThinkFilter, Usage}; -use crate::providers::errors::ProviderError; -use crate::providers::utils::{ - convert_image, detect_image_path, extract_reasoning_effort, is_openai_responses_model, - is_valid_function_name, load_image_file, openai_reasoning_effort_for_thinking, - safely_parse_json, sanitize_function_name, ImageFormat, +use crate::thinking::{ + split_think_blocks, ThinkFilter, ThinkingEffort, GEMINI_THOUGHT_SIGNATURE_KEY, }; use anyhow::{anyhow, Error}; use async_stream::try_stream; use chrono; use futures::Stream; +use regex::Regex; use rmcp::model::{ object, AnnotateAble, CallToolRequestParams, Content, ErrorCode, ErrorData, RawContent, Role, Tool, @@ -21,6 +22,7 @@ use serde_json::{json, Value}; use std::borrow::Cow; use std::collections::HashMap; use std::ops::Deref; +use std::sync::OnceLock; type ToolCallData = HashMap< i32, @@ -39,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") } @@ -180,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 }); @@ -201,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)); @@ -258,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": "{}", + } })); } }, @@ -406,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 @@ -636,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( @@ -650,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(), + )); } } } @@ -714,6 +806,12 @@ pub fn get_usage(usage: &Value) -> Usage { let cache_read_input_tokens = usage .get("cache_read_input_tokens") .and_then(|v| v.as_i64()) + .or_else(|| { + usage + .get("prompt_tokens_details") + .and_then(|d| d.get("cached_tokens")) + .and_then(|v| v.as_i64()) + }) .map(|v| v as i32); let cache_write_input_tokens = usage @@ -734,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>, @@ -742,14 +847,13 @@ fn extract_usage_with_output_tokens( .usage .as_ref() .and_then(|u| { - chunk - .model - .as_deref() - .or(fallback_model) - .map(|model| ProviderUsage { - usage: get_usage(u), - model: model.to_string(), - }) + 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()) } @@ -865,7 +969,9 @@ fn strip_data_prefix(line: &str) -> Option<&str> { fn parse_streaming_chunk(line: &str) -> Result { let value: Value = serde_json::from_str(line).map_err(|e| { - ProviderError::RequestFailed(format!("Failed to parse streaming chunk: {e}: {line:?}")) + ProviderError::stream_decode_error(format!( + "Failed to parse streaming chunk: {e}: {line:?}" + )) })?; if let Some(error) = value.get("error") { @@ -885,7 +991,9 @@ fn parse_streaming_chunk(line: &str) -> Result { } serde_json::from_value(value).map_err(|e| { - ProviderError::RequestFailed(format!("Failed to parse streaming chunk: {e}: {line:?}")) + ProviderError::stream_decode_error(format!( + "Failed to parse streaming chunk: {e}: {line:?}" + )) }) } @@ -950,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())); } } @@ -1076,16 +1185,10 @@ 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( - crate::providers::formats::google::THOUGHT_SIGNATURE_KEY.to_string(), + GEMINI_THOUGHT_SIGNATURE_KEY.to_string(), json!(sig) ); Some(combined) @@ -1093,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); } } @@ -1332,6 +1458,107 @@ pub fn create_request_with_options( Ok(payload) } +/// Extract an explicit reasoning-effort suffix from a model name. +/// +/// Returns `(base_model_name, Some(effort))` when the user appended a +/// recognised suffix like `-high` or `-xhigh`, e.g. `gpt-5.4-high` โ†’ +/// `("gpt-5.4", Some("high"))`. +/// +/// When no suffix is present the effort is `None` โ€” callers should omit +/// the `reasoning` field entirely so the API applies its own per-model +/// default. This avoids hard-coding a default that may be invalid for +/// certain models (e.g. `gpt-5-pro` only accepts `high`; older o-series +/// models reject `none` and `xhigh`). +pub fn extract_reasoning_effort(model_name: &str) -> (String, Option) { + if !is_openai_responses_model(model_name) { + return (model_name.to_string(), None); + } + + static RE: OnceLock = OnceLock::new(); + let re = RE.get_or_init(|| { + Regex::new(r"(?i)^(?P.+)-(?Pnone|low|medium|high|xhigh)$").unwrap() + }); + + if let Some(captures) = re.captures(model_name) { + let base = captures["base"].to_string(); + let effort = captures["effort"].to_ascii_lowercase(); + return (base, Some(effort)); + } + + (model_name.to_string(), None) +} + +/// True when the model should use the OpenAI Responses API. +/// +/// The Responses API is backwards-compatible with all OpenAI reasoning +/// models, so every `o`-series (`o1`, `o3`, `o4`, โ€ฆ) and `gpt-5` variant +/// routes here. The matcher intentionally scans the full model identifier so +/// hosted aliases like `databricks-gpt-5.4`, `goose-o3-mini`, or +/// `headless-goose-o3-mini` work without provider-specific normalization. +pub fn is_openai_responses_model(model_name: &str) -> bool { + static RE: OnceLock = OnceLock::new(); + let re = + RE.get_or_init(|| Regex::new(r"(?i)(?:^|[-/])(?:o\d+(?:$|-)|gpt-5(?:$|[-.]))").unwrap()); + re.is_match(model_name) +} + +pub fn openai_reasoning_effort_for_thinking( + model_name: &str, + effort: ThinkingEffort, +) -> Option { + if effort == ThinkingEffort::Off { + return Some("none".to_string()); + } + + let supported = openai_reasoning_efforts_for_model(model_name); + let preferred: &[&str] = match effort { + ThinkingEffort::Off => unreachable!(), + ThinkingEffort::Low => &["low", "medium", "high", "xhigh"], + ThinkingEffort::Medium => &["medium", "high", "low", "xhigh"], + ThinkingEffort::High => &["high", "medium", "xhigh", "low"], + ThinkingEffort::Max => &["xhigh", "high", "medium", "low"], + }; + + preferred + .iter() + .find(|level| supported.contains(level)) + .map(|level| (*level).to_string()) +} + +fn openai_reasoning_efforts_for_model(model_name: &str) -> &'static [&'static str] { + let normalized = model_name.to_ascii_lowercase(); + + if normalized.contains("gpt-5") { + if normalized.contains("-pro") || normalized.contains("/pro") { + &["high"] + } else if normalized.contains("gpt-5.4") + || 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 { + &["low", "medium", "high"] + } + } else { + &["low", "medium", "high"] + } +} + +pub fn sanitize_function_name(name: &str) -> String { + static RE: OnceLock = OnceLock::new(); + let re = RE.get_or_init(|| Regex::new(r"[^a-zA-Z0-9_-]").unwrap()); + re.replace_all(name, "_").to_string() +} + +pub fn is_valid_function_name(name: &str) -> bool { + static RE: OnceLock = OnceLock::new(); + let re = RE.get_or_init(|| Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap()); + re.is_match(name) +} + #[cfg(test)] mod tests { use super::*; @@ -1343,6 +1570,10 @@ mod tests { use tokio::pin; use tokio_stream::{self, StreamExt}; + fn test_model_config(model_name: &str) -> ModelConfig { + ModelConfig::new(model_name) + } + #[test] fn test_validate_tool_schemas() { // Test case 1: Empty parameters object @@ -1471,31 +1702,6 @@ mod tests { let timeout_schema = &tools[0]["function"]["parameters"]["properties"]["timeout_secs"]; assert_eq!(timeout_schema["type"], "integer"); assert!(!timeout_schema["type"].is_array()); - - // Test case 5: Verify the actual ShellParams schema is compatible (no anyOf for timeout_secs) - use crate::agents::platform_extensions::developer::shell::ShellParams; - use schemars::schema_for; - let schema_value = serde_json::to_value(schema_for!(ShellParams)).unwrap(); - let schema_obj = schema_value.as_object().unwrap().clone(); - let tool = rmcp::model::Tool::new("shell", "run shell", schema_obj); - let mut tools = vec![json!({ - "type": "function", - "function": { - "name": tool.name, - "description": tool.description, - "parameters": tool.input_schema, - } - })]; - validate_tool_schemas(&mut tools); - let timeout = &tools[0]["function"]["parameters"]["properties"]["timeout_secs"]; - assert!( - timeout.get("anyOf").is_none(), - "timeout_secs should not have anyOf after validation, got: {timeout}" - ); - assert_eq!( - timeout["type"], "integer", - "timeout_secs should have type=integer" - ); } const OPENAI_TOOL_USE_RESPONSE: &str = r#"{ @@ -1804,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)?; @@ -1820,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"); @@ -1829,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)?; @@ -1844,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"), } @@ -1855,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)?; @@ -2051,17 +2332,7 @@ mod tests { #[test] fn test_create_request_gpt_4o() -> anyhow::Result<()> { // Test default medium reasoning effort for O3 model - let model_config = ModelConfig { - model_name: "gpt-4o".to_string(), - context_limit: Some(4096), - temperature: None, - max_tokens: Some(1024), - toolshim: false, - toolshim_model: None, - fast_model_config: None, - request_params: None, - reasoning: None, - }; + let model_config = test_model_config("gpt-4o").with_max_tokens(Some(1024)); let request = create_request( &model_config, "system", @@ -2094,17 +2365,7 @@ mod tests { // Unknown models on OpenAI-compatible local providers (llama_swap, // lmstudio) have no canonical record and no GOOSE_MAX_TOKENS, so the // request must not pin the legacy 4096 default. See issue #9007. - let model_config = ModelConfig { - model_name: "some-unknown-local-model".to_string(), - context_limit: None, - temperature: None, - max_tokens: None, - toolshim: false, - toolshim_model: None, - fast_model_config: None, - request_params: None, - reasoning: None, - }; + let model_config = test_model_config("some-unknown-local-model"); let request = create_request( &model_config, "system", @@ -2127,35 +2388,28 @@ mod tests { #[test] fn test_request_params_preserve_reserved_fields() -> anyhow::Result<()> { - let model_config = ModelConfig { - model_name: "glm-4.7".to_string(), - context_limit: Some(204800), - temperature: None, - max_tokens: Some(4096), - toolshim: false, - toolshim_model: None, - fast_model_config: None, - request_params: Some(std::collections::HashMap::from([ - ( - "thinking".to_string(), - json!({ - "type": "enabled", - "clear_thinking": false - }), - ), - ("stream".to_string(), json!(false)), - ( - "stream_options".to_string(), - json!({"include_usage": false}), - ), - ("model".to_string(), json!("wrong-model")), - ("messages".to_string(), json!([])), - ("max_tokens".to_string(), json!(1)), - ("temperature".to_string(), json!(2.0)), - ("provider_custom".to_string(), json!("allowed")), - ])), - reasoning: None, - }; + let params = std::collections::HashMap::from([ + ( + "thinking".to_string(), + json!({ + "type": "enabled", + "clear_thinking": false + }), + ), + ("stream".to_string(), json!(false)), + ( + "stream_options".to_string(), + json!({"include_usage": false}), + ), + ("model".to_string(), json!("wrong-model")), + ("messages".to_string(), json!([])), + ("max_tokens".to_string(), json!(1)), + ("temperature".to_string(), json!(2.0)), + ("provider_custom".to_string(), json!("allowed")), + ]); + let model_config = test_model_config("glm-4.7") + .with_max_tokens(Some(4096)) + .with_merged_request_params(params); let request = create_request( &model_config, @@ -2186,17 +2440,7 @@ mod tests { #[test] fn test_create_request_o1_default() -> anyhow::Result<()> { - let model_config = ModelConfig { - model_name: "o1".to_string(), - context_limit: Some(4096), - temperature: None, - max_tokens: Some(1024), - toolshim: false, - toolshim_model: None, - fast_model_config: None, - request_params: None, - reasoning: None, - }; + let model_config = test_model_config("o1").with_max_tokens(Some(1024)); let request = create_request( &model_config, "system", @@ -2230,19 +2474,9 @@ mod tests { #[test] fn test_create_request_o1_medium_effort() -> anyhow::Result<()> { - let mut params = std::collections::HashMap::new(); - params.insert("thinking_effort".to_string(), json!("medium")); - let model_config = ModelConfig { - model_name: "o1".to_string(), - context_limit: Some(4096), - temperature: None, - max_tokens: Some(1024), - toolshim: false, - toolshim_model: None, - fast_model_config: None, - request_params: Some(params), - reasoning: None, - }; + let model_config = test_model_config("o1") + .with_max_tokens(Some(1024)) + .with_thinking_effort(ThinkingEffort::Medium); let request = create_request( &model_config, "system", @@ -2261,19 +2495,9 @@ mod tests { #[test] fn test_create_request_o3_off_effort_preserves_none() -> anyhow::Result<()> { - let mut params = std::collections::HashMap::new(); - params.insert("thinking_effort".to_string(), json!("off")); - let model_config = ModelConfig { - model_name: "o3".to_string(), - context_limit: Some(4096), - temperature: None, - max_tokens: Some(1024), - toolshim: false, - toolshim_model: None, - fast_model_config: None, - request_params: Some(params), - reasoning: None, - }; + let model_config = test_model_config("o3") + .with_max_tokens(Some(1024)) + .with_thinking_effort(ThinkingEffort::Off); let request = create_request( &model_config, "system", @@ -2290,21 +2514,32 @@ 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 mut params = std::collections::HashMap::new(); - params.insert("thinking_effort".to_string(), json!("max")); - let model_config = ModelConfig { - model_name: "gpt-5.2-pro-2025-12-11".to_string(), - context_limit: Some(4096), - temperature: None, - max_tokens: Some(1024), - toolshim: false, - toolshim_model: None, - fast_model_config: None, - request_params: Some(params), - reasoning: None, - }; + let model_config = test_model_config("gpt-5.2-pro-2025-12-11") + .with_max_tokens(Some(1024)) + .with_thinking_effort(ThinkingEffort::Max); let request = create_request( &model_config, "system", @@ -2323,19 +2558,9 @@ mod tests { #[test] fn test_create_request_o3_custom_reasoning_effort() -> anyhow::Result<()> { - let mut params = std::collections::HashMap::new(); - params.insert("thinking_effort".to_string(), json!("high")); - let model_config = ModelConfig { - model_name: "o3-mini".to_string(), - context_limit: Some(4096), - temperature: None, - max_tokens: Some(1024), - toolshim: false, - toolshim_model: None, - fast_model_config: None, - request_params: Some(params), - reasoning: None, - }; + let model_config = test_model_config("o3-mini") + .with_max_tokens(Some(1024)) + .with_thinking_effort(ThinkingEffort::High); let request = create_request( &model_config, "system", @@ -2446,14 +2671,35 @@ mod tests { } #[test] - fn test_get_usage_reads_nested_usage_object() { + fn test_get_usage_reads_openai_prompt_tokens_details() { + let usage = get_usage(&json!({ + "prompt_tokens": 120, + "completion_tokens": 30, + "total_tokens": 150, + "prompt_tokens_details": { + "cached_tokens": 80 + } + })); + + assert_eq!(usage.input_tokens, Some(120)); + assert_eq!(usage.output_tokens, Some(30)); + assert_eq!(usage.total_tokens, Some(150)); + assert_eq!(usage.cache_read_input_tokens, Some(80)); + assert_eq!(usage.cache_write_input_tokens, None); + } + + #[test] + fn test_get_usage_reads_nested_usage_with_cache_fields() { let usage = get_usage(&json!({ "id": "chatcmpl_test", + "object": "chat.completion", "usage": { "prompt_tokens": 84, "completion_tokens": 21, "total_tokens": 105, - "cache_read_input_tokens": 60, + "prompt_tokens_details": { + "cached_tokens": 60 + }, "cache_creation_input_tokens": 10 } })); @@ -2699,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!( @@ -2968,17 +3250,7 @@ data: [DONE]"#; #[test] fn test_create_request_preserves_reasoning_content_for_legacy_compat() -> anyhow::Result<()> { - let model_config = ModelConfig { - model_name: "deepseek-reasoner".to_string(), - context_limit: Some(128000), - temperature: None, - max_tokens: Some(1024), - toolshim: false, - toolshim_model: None, - fast_model_config: None, - request_params: None, - reasoning: None, - }; + let model_config = test_model_config("deepseek-reasoner").with_max_tokens(Some(1024)); let message = Message::assistant() .with_content(MessageContent::thinking("preserve this", "")) .with_tool_request( @@ -3111,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"; @@ -3410,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] @@ -3454,4 +4022,124 @@ data: [DONE]"#; let parsed: DeltaToolCallFunction = serde_json::from_str(raw).unwrap(); assert_eq!(parsed.arguments, "{\"k\":1}"); } + + #[test] + fn test_is_openai_responses_model_matches_o_and_gpt5_families() { + for model in [ + "o3", + "o3-mini", + "o4-mini", + "gpt-5", + "gpt-5-pro", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5-4", + "gpt-5-2-pro", + "databricks-gpt-5.4", + "goose-gpt-5.4-high", + "headless-goose-o3-mini", + ] { + assert!(is_openai_responses_model(model), "{model} should match"); + } + } + + #[test] + fn test_is_openai_responses_model_rejects_other_families() { + for model in [ + "gpt-4o", + "claude-sonnet-4", + "databricks-claude-sonnet-4", + "llama-3-70b", + ] { + assert!( + !is_openai_responses_model(model), + "{model} should not match" + ); + } + } + + #[test] + fn test_extract_reasoning_effort_for_responses_models() { + for (model, expected_name, expected_effort) in [ + ("o3-none", "o3", Some("none")), + ("o3-xhigh", "o3", Some("xhigh")), + ("gpt-5-low", "gpt-5", Some("low")), + ("gpt-5.4", "gpt-5.4", None), + ( + "databricks-gpt-5.4-high", + "databricks-gpt-5.4", + Some("high"), + ), + ("databricks-o3-low", "databricks-o3", Some("low")), + ("goose-gpt-5-high", "goose-gpt-5", Some("high")), + ("gpt-4o", "gpt-4o", None), + ] { + let (name, effort) = extract_reasoning_effort(model); + assert_eq!(name, expected_name, "unexpected base model for {model}"); + assert_eq!( + effort.as_deref(), + expected_effort, + "unexpected effort for {model}" + ); + } + } + + #[test] + fn test_sanitize_function_name() { + assert_eq!(sanitize_function_name("hello-world"), "hello-world"); + assert_eq!(sanitize_function_name("hello world"), "hello_world"); + assert_eq!(sanitize_function_name("hello@world"), "hello_world"); + } + + #[test] + fn test_is_valid_function_name() { + assert!(is_valid_function_name("hello-world")); + assert!(is_valid_function_name("hello_world")); + 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/src/providers/formats/openai_responses.rs b/crates/goose-provider-types/src/formats/openai_responses.rs similarity index 87% rename from crates/goose/src/providers/formats/openai_responses.rs rename to crates/goose-provider-types/src/formats/openai_responses.rs index d5c2e15a3e..05aed70ff1 100644 --- a/crates/goose/src/providers/formats/openai_responses.rs +++ b/crates/goose-provider-types/src/formats/openai_responses.rs @@ -1,10 +1,11 @@ use crate::conversation::message::{Message, MessageContent}; -use crate::mcp_utils::extract_text_from_resource; -use crate::model::ModelConfig; -use crate::providers::base::{ProviderUsage, Usage}; -use crate::providers::utils::{ +use crate::conversation::token_usage::{ProviderUsage, Usage}; +use crate::errors::ProviderError; +use crate::formats::openai::{ extract_reasoning_effort, is_openai_responses_model, openai_reasoning_effort_for_thinking, }; +use crate::mcp_utils::extract_text_from_resource; +use crate::model::ModelConfig; use anyhow::{anyhow, Error}; use async_stream::try_stream; use chrono; @@ -52,19 +53,27 @@ fn reasoning_from_summary(summary: &[SummaryText]) -> Option { #[serde(rename_all = "snake_case")] pub enum ResponseOutputItem { Reasoning { - id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + id: Option, #[serde(default)] summary: Vec, }, Message { - id: String, - status: String, + // `id` and `status` are required when the OpenAI API emits these + // items, but Codex rollout files (which reuse the same shape on + // disk) sometimes omit them. Keep deserialization permissive. + #[serde(default, skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(default, 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, @@ -103,6 +112,30 @@ pub struct ResponseUsage { pub input_tokens: i32, pub output_tokens: i32, pub total_tokens: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_tokens_details: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct InputTokensDetails { + #[serde(default)] + pub cached_tokens: Option, +} + +impl ResponseUsage { + fn to_usage(&self) -> Usage { + // input_tokens already includes cached tokens + let cached_tokens = self + .input_tokens_details + .as_ref() + .and_then(|d| d.cached_tokens); + Usage::new( + Some(self.input_tokens), + Some(self.output_tokens), + Some(self.total_tokens), + ) + .with_cache_tokens(cached_tokens, None) + } } #[derive(Debug, Serialize, Deserialize)] @@ -241,11 +274,10 @@ fn is_known_responses_stream_event_type(event_type: &str) -> bool { fn parse_responses_stream_event(data_line: &str) -> anyhow::Result> { let raw_event: Value = serde_json::from_str(data_line).map_err(|e| { - anyhow!( + ProviderError::stream_decode_error(format!( "Failed to parse Responses stream event: {}: {:?}", - e, - data_line - ) + e, data_line + )) })?; let Some(event_type) = raw_event.get("type").and_then(Value::as_str) else { @@ -257,11 +289,10 @@ fn parse_responses_stream_event(data_line: &str) -> anyhow::Result, #[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, @@ -565,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 { @@ -659,7 +696,9 @@ pub fn responses_api_to_message(response: &ResponsesApiResponse) -> anyhow::Resu arguments, .. } => { - let request_id = call_id.as_ref().unwrap_or(id).clone(); + 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 { @@ -683,19 +722,16 @@ pub fn responses_api_to_message(response: &ResponsesApiResponse) -> anyhow::Resu } pub fn get_responses_usage(response: &ResponsesApiResponse) -> Usage { - response.usage.as_ref().map_or_else(Usage::default, |u| { - Usage::new( - Some(u.input_tokens), - Some(u.output_tokens), - Some(u.total_tokens), - ) - }) + response + .usage + .as_ref() + .map_or_else(Usage::default, ResponseUsage::to_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 { @@ -743,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 { @@ -758,7 +796,7 @@ fn process_streaming_output_items( } } - content + Ok(content) } pub fn responses_api_to_streaming_message( @@ -851,16 +889,9 @@ where let model = model_name.as_ref().unwrap_or(&response.model); let usage = response.usage.as_ref().map_or_else( Usage::default, - |u| Usage::new( - Some(u.input_tokens), - Some(u.output_tokens), - Some(u.total_tokens), - ), + ResponseUsage::to_usage, ); - final_usage = Some(ProviderUsage { - usage, - model: model.clone(), - }); + final_usage = Some(ProviderUsage::new(model.clone(), usage)); // For complete output, use the response output items if !response.output.is_empty() { @@ -903,11 +934,17 @@ where } ResponsesStreamEvent::ResponseFailed { error, .. } => { - Err(anyhow!("Responses API failed: {:?}", error))?; + Err::<(), ProviderError>(ProviderError::RequestFailed(format!( + "Responses API failed: {:?}", + error + )))?; } ResponsesStreamEvent::Error { error } => { - Err(anyhow!("Responses API error: {:?}", error))?; + Err::<(), ProviderError>(ProviderError::RequestFailed(format!( + "Responses API error: {:?}", + error + )))?; } _ => { @@ -917,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); @@ -947,7 +984,7 @@ mod tests { r#"data: {"type":"keepalive"}"#.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":[],"usage":{"input_tokens":10,"output_tokens":4,"total_tokens":14}}}"#.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":[],"usage":{"input_tokens":10,"output_tokens":4,"total_tokens":14,"input_tokens_details":{"cached_tokens":6}}}}"#.to_string(), "data: [DONE]".to_string(), ]; @@ -978,6 +1015,8 @@ mod tests { assert_eq!(usage.usage.input_tokens, Some(10)); assert_eq!(usage.usage.output_tokens, Some(4)); assert_eq!(usage.usage.total_tokens, Some(14)); + assert_eq!(usage.usage.cache_read_input_tokens, Some(6)); + assert_eq!(usage.usage.cache_write_input_tokens, None); Ok(()) } @@ -1023,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!({ @@ -1171,7 +1288,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1220,8 +1336,8 @@ mod tests { status: "completed".to_string(), model: "gpt-5.3-codex".to_string(), output: vec![ResponseOutputItem::FunctionCall { - id: "fc_123".to_string(), - status: "completed".to_string(), + id: Some("fc_123".to_string()), + status: Some("completed".to_string()), call_id: Some("call_abc".to_string()), name: "test__get_person_zip_code".to_string(), arguments: r#"{"name":"Alice Burns"}"#.to_string(), @@ -1263,7 +1379,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1307,7 +1422,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1329,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(); @@ -1348,7 +1462,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1374,7 +1487,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1388,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; @@ -1403,7 +1536,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1450,7 +1582,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1487,7 +1618,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1519,7 +1649,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1550,7 +1679,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1585,7 +1713,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1622,7 +1749,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1648,7 +1774,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1680,7 +1805,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1712,7 +1836,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1753,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", @@ -1763,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] @@ -1786,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] @@ -1834,7 +1978,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1872,7 +2015,6 @@ mod tests { max_tokens: None, toolshim: false, toolshim_model: None, - fast_model_config: None, request_params: None, reasoning: None, }; @@ -1910,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 34f63af29d..1c2ac06ea4 100644 --- a/crates/goose/src/providers/formats/snowflake.rs +++ b/crates/goose-provider-types/src/formats/snowflake.rs @@ -1,8 +1,8 @@ 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 crate::providers::base::Usage; -use crate::providers::errors::ProviderError; use anyhow::{anyhow, Result}; use rmcp::model::{object, CallToolRequestParams, Role, Tool}; use rmcp::object; @@ -562,8 +562,7 @@ data: {"id":"a9537c2c-2017-4906-9817-2456168d89fa","model":"claude-sonnet-4-2025 use crate::conversation::message::Message; 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?")]; @@ -672,8 +671,7 @@ data: {"id":"a9537c2c-2017-4906-9817-2456168d89fa","model":"claude-sonnet-4-2025 use crate::conversation::message::Message; 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/src/config/goose_mode.rs b/crates/goose-provider-types/src/goose_mode.rs similarity index 100% rename from crates/goose/src/config/goose_mode.rs rename to crates/goose-provider-types/src/goose_mode.rs diff --git a/crates/goose-provider-types/src/images.rs b/crates/goose-provider-types/src/images.rs new file mode 100644 index 0000000000..5c27bd710b --- /dev/null +++ b/crates/goose-provider-types/src/images.rs @@ -0,0 +1,512 @@ +use std::{borrow::Cow, io::Read as _, path::Path}; + +use base64::Engine as _; +use rmcp::model::{AnnotateAble as _, ImageContent, RawImageContent}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::errors::ProviderError; + +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +pub enum ImageFormat { + OpenAi, + Anthropic, +} + +/// Convert an image content into an image json based on format +pub fn convert_image(image: &ImageContent, image_format: &ImageFormat) -> Value { + match image_format { + ImageFormat::OpenAi => json!({ + "type": "image_url", + "image_url": { + "url": format!("data:{};base64,{}", image.mime_type, image.data) + } + }), + ImageFormat::Anthropic => json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": image.mime_type, + "data": image.data, + } + }), + } +} + +pub fn detect_image_path(text: &str) -> Option> { + const EXTENSIONS: [&str; 3] = [".png", ".jpg", ".jpeg"]; + const MAX_PATH_LEN: usize = 4096; + + let mut best: Option<(usize, Cow<'_, str>)> = None; + let mut from = 0; + while from < text.len() { + let Some(end) = EXTENSIONS + .iter() + .filter_map(|ext| find_ascii_ci(text, ext, from).map(|i| i + ext.len())) + .min() + else { + break; + }; + + let terminator = text.get(end..).and_then(|rest| rest.chars().next()); + let terminated = terminator.is_none_or(is_path_terminator); + + if terminated { + let mut floor = end.saturating_sub(MAX_PATH_LEN); + while floor < end && !text.is_char_boundary(floor) { + floor += 1; + } + if let Some(window) = text.get(floor..end) { + for (rel, _) in window.match_indices('/') { + let start = floor + rel; + let preceded_by_boundary = text + .get(..start) + .and_then(|prefix| prefix.chars().next_back()) + .is_none_or(is_path_leading_boundary); + if !preceded_by_boundary { + continue; + } + let Some(candidate) = text.get(start..end) else { + continue; + }; + if let Some(candidate_path) = image_path_candidate(candidate) { + // Keep the first referenced path, but allow a longer + // match anchored at the same start to extend it (a + // whitespace-terminated extension may be a prefix of a + // spaced filename ending in a later extension). + match best { + Some((best_start, _)) if start == best_start => { + best = Some((start, candidate_path)); + } + None => best = Some((start, candidate_path)), + Some(_) => {} + } + break; + } + } + } + } + from = end; + } + best.map(|(_, candidate)| candidate) +} + +fn clean_path(path: &str) -> Cow<'_, str> { + if !path.contains('\\') { + return Cow::Borrowed(path); + } + + let mut cleaned = String::with_capacity(path.len()); + let mut chars = path.chars().peekable(); + let mut changed = false; + + while let Some(c) = chars.next() { + if c == '\\' { + if let Some(&next) = chars.peek() { + if !next.is_alphanumeric() { + cleaned.push(next); + chars.next(); + changed = true; + continue; + } + } + } + cleaned.push(c); + } + + if changed { + Cow::Owned(cleaned) + } else { + Cow::Borrowed(path) + } +} + +fn image_path_candidate(candidate: &str) -> Option> { + if is_existing_image_path(candidate) { + return Some(Cow::Borrowed(candidate)); + } + + let cleaned = clean_path(candidate); + if cleaned.as_ref() != candidate && is_existing_image_path(cleaned.as_ref()) { + return Some(cleaned); + } + + None +} + +fn is_existing_image_path(candidate: &str) -> bool { + let path = Path::new(candidate); + path.is_absolute() && path.is_file() && is_image_file(path) +} + +fn is_path_leading_boundary(c: char) -> bool { + c.is_whitespace() + || matches!( + c, + '"' | '\'' | '\u{00AB}' | '\u{00BB}' | '\u{2018}' + ..='\u{201F}' | '\u{2039}' | '\u{203A}' + ) +} + +fn is_path_terminator(c: char) -> bool { + c == '/' + || c.is_whitespace() + || matches!( + c, + '"' | '\'' | '\u{00AB}' | '\u{00BB}' | '\u{2013}' + ..='\u{201F}' | '\u{2026}' | '\u{2039}' | '\u{203A}' + ) + || ('\u{2300}'..='\u{23FF}').contains(&c) + || ('\u{2600}'..='\u{27BF}').contains(&c) + || ('\u{2B00}'..='\u{2BFF}').contains(&c) + || ('\u{1F1E6}'..='\u{1F1FF}').contains(&c) + || ('\u{1F300}'..='\u{1FAFF}').contains(&c) +} + +/// Case-insensitive ASCII substring search returning a byte index into +/// `haystack` (no allocation, so the index stays valid for slicing). +fn find_ascii_ci(haystack: &str, needle: &str, from: usize) -> Option { + let (hb, nb) = (haystack.as_bytes(), needle.as_bytes()); + if nb.is_empty() || hb.len() < nb.len() || from > hb.len() - nb.len() { + return None; + } + (from..=hb.len() - nb.len()).find(|&i| { + hb[i..i + nb.len()] + .iter() + .zip(nb) + .all(|(a, b)| a.eq_ignore_ascii_case(b)) + }) +} + +/// Check if a file is actually an image by examining its magic bytes +fn is_image_file(path: &Path) -> bool { + if let Ok(mut file) = std::fs::File::open(path) { + let mut buffer = [0u8; 8]; // Large enough for most image magic numbers + if file.read(&mut buffer).is_ok() { + // Check magic numbers for common image formats + return match &buffer[0..4] { + // PNG: 89 50 4E 47 + [0x89, 0x50, 0x4E, 0x47] => true, + // JPEG: FF D8 FF + [0xFF, 0xD8, 0xFF, _] => true, + // GIF: 47 49 46 38 + [0x47, 0x49, 0x46, 0x38] => true, + _ => false, + }; + } + } + false +} + +/// Convert a local image file to base64 encoded ImageContent +pub fn load_image_file(path: &str) -> Result { + let path = Path::new(path); + + // Verify it's an image before proceeding + if !is_image_file(path) { + return Err(ProviderError::RequestFailed( + "File is not a valid image".to_string(), + )); + } + + // Read the file + let bytes = std::fs::read(path) + .map_err(|e| ProviderError::RequestFailed(format!("Failed to read image file: {}", e)))?; + + // Detect mime type from extension + let mime_type = match path.extension().and_then(|e| e.to_str()) { + Some(ext) => match ext.to_lowercase().as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + _ => { + return Err(ProviderError::RequestFailed( + "Unsupported image format".to_string(), + )) + } + }, + None => { + return Err(ProviderError::RequestFailed( + "Unknown image format".to_string(), + )) + } + }; + + // Convert to base64 + let data = base64::prelude::BASE64_STANDARD.encode(&bytes); + + Ok(RawImageContent { + mime_type: mime_type.to_string(), + data, + meta: None, + } + .no_annotation()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile; + + #[test] + fn test_detect_image_path() { + // Create a temporary PNG file with valid PNG magic numbers + let temp_dir = tempfile::tempdir().unwrap(); + let png_path = temp_dir.path().join("test.png"); + let png_data = [ + 0x89, 0x50, 0x4E, 0x47, // PNG magic number + 0x0D, 0x0A, 0x1A, 0x0A, // PNG header + 0x00, 0x00, 0x00, 0x0D, // Rest of fake PNG data + ]; + std::fs::write(&png_path, png_data).unwrap(); + let png_path_str = png_path.to_str().unwrap(); + + // Create a fake PNG (wrong magic numbers) + let fake_png_path = temp_dir.path().join("fake.png"); + std::fs::write(&fake_png_path, b"not a real png").unwrap(); + + // Test with valid PNG file using absolute path + let text = format!("Here is an image {}", png_path_str); + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); + + // Test with non-image file that has .png extension + let text = format!("Here is a fake image {}", fake_png_path.to_str().unwrap()); + assert_eq!(detect_image_path(&text).as_deref(), None); + + // Test with nonexistent file + let text = "Here is a fake.png that doesn't exist"; + assert_eq!(detect_image_path(text).as_deref(), None); + + // Test with non-image file + let text = "Here is a file.txt"; + assert_eq!(detect_image_path(text).as_deref(), None); + + // Test with relative path (should not match) + let text = "Here is a relative/path/image.png"; + assert_eq!(detect_image_path(text).as_deref(), None); + } + + #[test] + fn test_detect_image_path_with_spaces() { + // Absolute path containing spaces (macOS screenshot style). + let temp_dir = tempfile::tempdir().unwrap(); + let png_path = temp_dir.path().join("Screen Shot 2026.png"); + let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + std::fs::write(&png_path, png_data).unwrap(); + let png_path_str = png_path.to_str().unwrap(); + + let text = format!("please describe {} for me", png_path_str); + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); + + // Case-insensitive extension also matches. + let upper = temp_dir.path().join("Another Shot.PNG"); + std::fs::write(&upper, png_data).unwrap(); + let upper_str = upper.to_str().unwrap(); + let text = format!("see {}", upper_str); + assert_eq!(detect_image_path(&text).as_deref(), Some(upper_str)); + + // Quoted path with spaces: the closing quote terminates the candidate. + let text = format!("describe \"{}\" please", png_path_str); + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); + let text = format!("describe '{}'", png_path_str); + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); + let text = format!("describe โ€œ{}โ€ please", png_path_str); + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); + let text = format!("describe ยซ{}ยป please", png_path_str); + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); + + // A stray closing quote in prose must not act as a terminator for an + // unquoted path. + let text = format!("here {}\" trailing", png_path_str); + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); + + // When a spaced filename contains an earlier image extension, prefer + // the longer existing candidate over the embedded prefix. + let edited = temp_dir.path().join("Screen Shot.png edited.jpg"); + std::fs::write(&edited, png_data).unwrap(); + let edited_str = edited.to_str().unwrap(); + let prefix = temp_dir.path().join("Screen Shot.png"); + std::fs::write(&prefix, png_data).unwrap(); + let text = format!("look at {}", edited_str); + assert_eq!(detect_image_path(&text).as_deref(), Some(edited_str)); + + // With multiple distinct images, the first referenced one wins even if + // a later one has a longer path. + let a = temp_dir.path().join("a.png"); + std::fs::write(&a, png_data).unwrap(); + let longer = temp_dir.path().join("much-longer.png"); + std::fs::write(&longer, png_data).unwrap(); + let text = format!( + "compare {} with {}", + a.to_str().unwrap(), + longer.to_str().unwrap() + ); + assert_eq!( + detect_image_path(&text).as_deref(), + Some(a.to_str().unwrap()) + ); + } + + #[test] + fn test_detect_image_path_with_shell_escaped_metacharacters() { + let temp_dir = tempfile::tempdir().unwrap(); + let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + let png_path = temp_dir + .path() + .join("Bob's Project (v2) & $draft [final].png"); + std::fs::write(&png_path, png_data).unwrap(); + let png_path_str = png_path.to_str().unwrap(); + + let escaped_path = png_path_str + .replace(' ', "\\ ") + .replace('(', "\\(") + .replace(')', "\\)") + .replace('&', "\\&") + .replace('$', "\\$") + .replace('\'', "\\'") + .replace('[', "\\[") + .replace(']', "\\]"); + let text = format!("please describe {}", escaped_path); + + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); + } + + #[test] + fn test_detect_image_path_prefers_existing_literal_backslash_path() { + let temp_dir = tempfile::tempdir().unwrap(); + let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + let png_path = temp_dir.path().join("literal\\&name.png"); + std::fs::write(&png_path, png_data).unwrap(); + let png_path_str = png_path.to_str().unwrap(); + let text = format!("please describe {}", png_path_str); + + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); + } + + #[test] + fn test_detect_image_path_with_unicode_separators() { + let temp_dir = tempfile::tempdir().unwrap(); + let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + let png_path = temp_dir.path().join("photo.png"); + std::fs::write(&png_path, png_data).unwrap(); + let png_path_str = png_path.to_str().unwrap(); + + assert_eq!( + detect_image_path(&format!("{png_path_str}๐Ÿ™‚")).as_deref(), + Some(png_path_str) + ); + assert_eq!( + detect_image_path(&format!("{png_path_str}๐Ÿ‡บ๐Ÿ‡ธ")).as_deref(), + Some(png_path_str) + ); + assert_eq!( + detect_image_path(&format!("{png_path_str}โŒš")).as_deref(), + Some(png_path_str) + ); + assert_eq!( + detect_image_path(&format!("{png_path_str}โญ")).as_deref(), + Some(png_path_str) + ); + assert_eq!( + detect_image_path(&format!("{png_path_str}โ€ฆ more text")).as_deref(), + Some(png_path_str) + ); + assert_eq!( + detect_image_path(&format!("{png_path_str}\u{2014}more text")).as_deref(), + Some(png_path_str) + ); + + assert_eq!( + detect_image_path(&format!("{png_path_str}\u{200B}.backup")).as_deref(), + None + ); + assert_eq!( + detect_image_path(&format!("{png_path_str}\u{0301}")).as_deref(), + None + ); + assert_eq!( + detect_image_path(&format!("file:{png_path_str}")).as_deref(), + None + ); + } + + #[test] + fn test_detect_image_path_ignores_urls_and_longer_extensions() { + let temp_dir = tempfile::tempdir().unwrap(); + let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + // A real image whose path is a suffix of a URL must not be extracted + // from that URL via the `://` separator. + let dir = temp_dir.path().to_str().unwrap().trim_start_matches('/'); + let png_path = temp_dir.path().join("photo.png"); + std::fs::write(&png_path, png_data).unwrap(); + let url = format!("https:/{}/photo.png", dir); + assert_eq!(detect_image_path(&url).as_deref(), None); + + // A backup file sharing the image extension prefix must not be + // truncated to the bare image path. + let real = temp_dir.path().join("shot.png"); + std::fs::write(&real, png_data).unwrap(); + let backup = format!("{}.backup", real.to_str().unwrap()); + assert_eq!(detect_image_path(&backup).as_deref(), None); + } + + #[test] + fn test_detect_image_path_ignores_extension_flood() { + // Many extension-like tokens but no real absolute path: must scan + // cheaply (bounded) and find nothing. + let text = "see foo.png and bar.jpg and baz.jpeg ".repeat(500); + assert_eq!(detect_image_path(&text).as_deref(), None); + } + + #[test] + fn test_load_image_file() { + // Create a temporary PNG file with valid PNG magic numbers + let temp_dir = tempfile::tempdir().unwrap(); + let png_path = temp_dir.path().join("test.png"); + let png_data = [ + 0x89, 0x50, 0x4E, 0x47, // PNG magic number + 0x0D, 0x0A, 0x1A, 0x0A, // PNG header + 0x00, 0x00, 0x00, 0x0D, // Rest of fake PNG data + ]; + std::fs::write(&png_path, png_data).unwrap(); + let png_path_str = png_path.to_str().unwrap(); + + // Create a fake PNG (wrong magic numbers) + let fake_png_path = temp_dir.path().join("fake.png"); + std::fs::write(&fake_png_path, b"not a real png").unwrap(); + let fake_png_path_str = fake_png_path.to_str().unwrap(); + + // Test loading valid PNG file + let result = load_image_file(png_path_str); + assert!(result.is_ok()); + let image = result.unwrap(); + assert_eq!(image.mime_type, "image/png"); + + // Test loading fake PNG file + let result = load_image_file(fake_png_path_str); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("not a valid image")); + + // Test nonexistent file + let result = load_image_file("nonexistent.png"); + assert!(result.is_err()); + + // Create a GIF file with valid header bytes + let gif_path = temp_dir.path().join("test.gif"); + // Minimal GIF89a header + let gif_data = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]; + std::fs::write(&gif_path, gif_data).unwrap(); + let gif_path_str = gif_path.to_str().unwrap(); + + // Test loading unsupported GIF format + let result = load_image_file(gif_path_str); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Unsupported image format")); + } +} diff --git a/crates/goose-provider-types/src/json.rs b/crates/goose-provider-types/src/json.rs 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-provider-types/src/mcp_utils.rs b/crates/goose-provider-types/src/mcp_utils.rs new file mode 100644 index 0000000000..ad70764222 --- /dev/null +++ b/crates/goose-provider-types/src/mcp_utils.rs @@ -0,0 +1,105 @@ +use base64::Engine; +use rmcp::model::ResourceContents; + +pub fn extract_text_from_resource(resource: &ResourceContents) -> String { + match resource { + ResourceContents::TextResourceContents { text, .. } => text.clone(), + ResourceContents::BlobResourceContents { + blob, mime_type, .. + } => match base64::engine::general_purpose::STANDARD.decode(blob) { + Ok(bytes) => { + let byte_len = bytes.len(); + match String::from_utf8(bytes) { + Ok(text) => text, + Err(_) => { + let mime = mime_type + .as_ref() + .map(|m| m.as_str()) + .unwrap_or("application/octet-stream"); + format!("[Binary content ({}) - {} bytes]", mime, byte_len) + } + } + } + Err(_) => blob.clone(), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_case::test_case; + + #[test_case("Hello, World!", "Hello, World!" ; "simple text")] + #[test_case("Hello from GitHub!", "Hello from GitHub!" ; "github content")] + #[test_case("", "" ; "empty text")] + fn test_extract_text_from_text_resource(input: &str, expected: &str) { + let resource = ResourceContents::TextResourceContents { + uri: "file:///test.txt".to_string(), + mime_type: Some("text/plain".to_string()), + text: input.to_string(), + meta: None, + }; + assert_eq!(extract_text_from_resource(&resource), expected); + } + + #[test_case("Hello from GitHub!", "Hello from GitHub!" ; "utf8 markdown")] + #[test_case("Simple text", "Simple text" ; "utf8 plain")] + fn test_extract_text_from_blob_utf8(input: &str, expected: &str) { + let blob = base64::engine::general_purpose::STANDARD.encode(input.as_bytes()); + let resource = ResourceContents::BlobResourceContents { + uri: "github://repo/file.md".to_string(), + mime_type: Some("text/markdown".to_string()), + blob, + meta: None, + }; + assert_eq!(extract_text_from_resource(&resource), expected); + } + + #[test] + fn test_extract_text_from_blob_binary() { + let binary_data: Vec = vec![0xFF, 0xFE, 0x00, 0x01, 0x89, 0x50, 0x4E, 0x47]; + let blob = base64::engine::general_purpose::STANDARD.encode(&binary_data); + + let resource = ResourceContents::BlobResourceContents { + uri: "file:///image.png".to_string(), + mime_type: Some("image/png".to_string()), + blob, + meta: None, + }; + + assert_eq!( + extract_text_from_resource(&resource), + "[Binary content (image/png) - 8 bytes]" + ); + } + + #[test] + fn test_extract_text_from_blob_binary_no_mime_type() { + let binary_data: Vec = vec![0xFF, 0xFE]; + let blob = base64::engine::general_purpose::STANDARD.encode(&binary_data); + + let resource = ResourceContents::BlobResourceContents { + uri: "file:///unknown".to_string(), + mime_type: None, + blob, + meta: None, + }; + + assert_eq!( + extract_text_from_resource(&resource), + "[Binary content (application/octet-stream) - 2 bytes]" + ); + } + + #[test] + fn test_extract_text_from_blob_invalid_base64() { + let resource = ResourceContents::BlobResourceContents { + uri: "file:///test.txt".to_string(), + mime_type: Some("text/plain".to_string()), + blob: "not valid base64!!!".to_string(), + meta: None, + }; + assert_eq!(extract_text_from_resource(&resource), "not valid base64!!!"); + } +} diff --git a/crates/goose-provider-types/src/model.rs b/crates/goose-provider-types/src/model.rs new file mode 100644 index 0000000000..341beaea99 --- /dev/null +++ b/crates/goose-provider-types/src/model.rs @@ -0,0 +1,714 @@ +use crate::formats::openai::{extract_reasoning_effort, is_openai_responses_model}; +use crate::thinking::ThinkingEffort; +use serde::de::Deserializer; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use utoipa::ToSchema; + +pub const DEFAULT_CONTEXT_LIMIT: usize = 128_000; + +/// Request param keys that describe model-family-agnostic reasoning behavior and +/// are therefore safe to carry across a model switch or subagent delegation. +/// Provider-specific keys (e.g. `anthropic_beta`) are deliberately excluded so +/// they can't bleed into a request targeting a different model family. +const INHERITED_SESSION_PARAM_KEYS: &[&str] = &[ + "thinking_effort", + "thinking_budget", + "budget_tokens", + "enable_thinking", + "preserve_thinking_context", + "preserve_unsigned_thinking", +]; + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ModelConfig { + pub model_name: String, + pub context_limit: Option, + pub temperature: Option, + pub max_tokens: Option, + pub toolshim: bool, + pub toolshim_model: Option, + /// Provider-specific request parameters (e.g., anthropic_beta headers) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_params: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, +} + +impl<'de> Deserialize<'de> for ModelConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct RawModelConfig { + model_name: String, + context_limit: Option, + temperature: Option, + max_tokens: Option, + toolshim: bool, + toolshim_model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + request_params: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning: Option, + } + + let raw = RawModelConfig::deserialize(deserializer)?; + let mut config = Self { + model_name: raw.model_name, + context_limit: raw.context_limit, + temperature: raw.temperature, + max_tokens: raw.max_tokens, + toolshim: raw.toolshim, + toolshim_model: raw.toolshim_model, + request_params: raw.request_params, + reasoning: raw.reasoning, + }; + config.normalize_effort_suffix(); + Ok(config) + } +} + +impl ModelConfig { + pub fn new(model_name: impl AsRef) -> Self { + let mut config = Self { + model_name: model_name.as_ref().to_string(), + context_limit: None, + temperature: None, + max_tokens: None, + toolshim: false, + toolshim_model: None, + request_params: None, + reasoning: None, + }; + config.normalize_effort_suffix(); + config + } + + pub fn with_canonical_limits(mut self, provider_name: &str) -> Self { + // Try canonical lookup with the full model name first, then fall back + // to the name with reasoning-effort suffixes stripped (e.g. + // "databricks-gpt-5.4-high" โ†’ "databricks-gpt-5.4"). + let canonical = + crate::canonical::maybe_get_canonical_model(provider_name, &self.model_name).or_else( + || { + let (base, _effort) = extract_reasoning_effort(&self.model_name); + if base != self.model_name { + crate::canonical::maybe_get_canonical_model(provider_name, &base) + } else { + None + } + }, + ); + + if let Some(canonical) = canonical { + if self.context_limit.is_none() { + self.context_limit = Some(canonical.limit.context); + } + if self.max_tokens.is_none() { + self.max_tokens = canonical + .limit + .output + .filter(|&output| output < canonical.limit.context) + .map(|output| output as i32); + } + if self.reasoning.is_none() { + self.reasoning = canonical.reasoning; + } + } + + self + } + + pub fn with_context_limit(mut self, limit: Option) -> Self { + if limit.is_some() { + self.context_limit = limit; + } + self + } + + pub fn with_temperature(mut self, temp: Option) -> Self { + self.temperature = temp; + self + } + + pub fn with_max_tokens(mut self, tokens: Option) -> Self { + self.max_tokens = tokens; + self + } + + pub fn with_default_context_limit(mut self, limit: Option) -> Self { + if self.context_limit.is_none() { + self.context_limit = limit; + } + self + } + + pub fn with_default_max_tokens(mut self, tokens: Option) -> Self { + if self.max_tokens.is_none() { + self.max_tokens = tokens; + } + self + } + + pub fn with_toolshim(mut self, toolshim: bool) -> Self { + self.toolshim = toolshim; + self + } + + pub fn with_toolshim_model(mut self, model: Option) -> Self { + self.toolshim_model = model; + self + } + + pub fn with_merged_request_params(mut self, params: HashMap) -> Self { + match self.request_params.as_mut() { + Some(existing) => { + for (k, v) in params { + existing.insert(k, v); + } + } + None => { + self.request_params = Some(params); + } + } + self + } + + pub fn with_thinking_effort(mut self, effort: ThinkingEffort) -> Self { + let params = self.request_params.get_or_insert_with(HashMap::new); + params.insert( + "thinking_effort".to_string(), + serde_json::json!(effort.to_string()), + ); + self + } + + pub fn with_default_thinking_effort(mut self, effort: Option) -> Self { + if self.thinking_effort().is_none() { + if let Some(effort) = effort { + self = self.with_thinking_effort(effort); + } + } + self + } + + pub fn with_inherited_session_settings_from( + mut self, + previous: Option<&ModelConfig>, + request_params: Option>, + ) -> Self { + if let Some(previous_params) = previous.and_then(|p| p.request_params.as_ref()) { + for key in INHERITED_SESSION_PARAM_KEYS { + if let Some(value) = previous_params.get(*key) { + self.request_params + .get_or_insert_with(HashMap::new) + .entry(key.to_string()) + .or_insert_with(|| value.clone()); + } + } + } + + if let Some(request_params) = request_params { + self = self.with_merged_request_params(request_params); + } + + self + } + + pub fn context_limit(&self) -> usize { + self.context_limit.unwrap_or(DEFAULT_CONTEXT_LIMIT) + } + + pub fn is_openai_reasoning_model(&self) -> bool { + is_openai_responses_model(&self.model_name) + } + + pub fn is_reasoning_model(&self) -> bool { + if let Some(reasoning) = self.reasoning { + return reasoning; + } + + self.is_openai_reasoning_model() + || self.model_name.to_lowercase().contains("claude") + || Self::is_gemini3_reasoning_model_name(&self.model_name) + } + + fn is_gemini3_reasoning_model_name(model_name: &str) -> bool { + let lower = model_name.to_lowercase(); + lower.starts_with("gemini-3") || lower.contains("/gemini-3") || lower.contains("-gemini-3") + } + + pub fn max_output_tokens(&self) -> i32 { + if let Some(tokens) = self.max_tokens { + return tokens; + } + + 4_096 + } + + pub fn normalize_effort_suffix(&mut self) { + if !self.is_openai_reasoning_model() { + return; + } + let parts: Vec<&str> = self.model_name.split('-').collect(); + let last = match parts.last() { + Some(l) => *l, + None => return, + }; + let effort = match last { + "none" => ThinkingEffort::Off, + "low" => ThinkingEffort::Low, + "medium" => ThinkingEffort::Medium, + "high" => ThinkingEffort::High, + "xhigh" => ThinkingEffort::Max, + _ => return, + }; + self.model_name = parts[..parts.len() - 1].join("-"); + let has_explicit_effort = self + .request_params + .as_ref() + .and_then(|p| p.get("thinking_effort")) + .is_some(); + if !has_explicit_effort { + let params = self.request_params.get_or_insert_with(HashMap::new); + params.insert( + "thinking_effort".to_string(), + serde_json::json!(effort.to_string()), + ); + } + } + + pub fn thinking_effort(&self) -> Option { + self.request_param::("thinking_effort") + .and_then(|s| s.parse::().ok()) + } + + pub fn request_param serde::Deserialize<'de>>( + &self, + request_key: &str, + ) -> Option { + self.request_params + .as_ref() + .and_then(|params| params.get(request_key)) + .and_then(|v| serde_json::from_value(v.clone()).ok()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod thinking_effort_tests { + use super::*; + + fn config_with_params(model_name: &str, params: HashMap) -> ModelConfig { + ModelConfig::new(model_name).with_merged_request_params(params) + } + + #[test] + fn from_request_params() { + let mut params = HashMap::new(); + params.insert("thinking_effort".to_string(), serde_json::json!("medium")); + let config = config_with_params("test", params); + assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Medium)); + } + + #[test] + fn with_thinking_effort_sets_request_param() { + let config = ModelConfig::new("test").with_thinking_effort(ThinkingEffort::High); + + assert_eq!( + config + .request_params + .as_ref() + .and_then(|params| params.get("thinking_effort")), + Some(&serde_json::json!("high")) + ); + } + + #[test] + fn preserves_explicit_thinking_effort() { + let previous = config_with_params( + "previous", + HashMap::from([("thinking_effort".to_string(), serde_json::json!("high"))]), + ); + let config = ModelConfig::new("next") + .with_inherited_session_settings_from(Some(&previous), None); + + assert_eq!( + config + .request_params + .as_ref() + .and_then(|params| params.get("thinking_effort")), + Some(&serde_json::json!("high")) + ); + } + + #[test] + fn does_not_override_existing_thinking_effort() { + let previous = config_with_params( + "previous", + HashMap::from([("thinking_effort".to_string(), serde_json::json!("high"))]), + ); + let config = config_with_params( + "next", + HashMap::from([("thinking_effort".to_string(), serde_json::json!("low"))]), + ) + .with_inherited_session_settings_from(Some(&previous), None); + + assert_eq!( + config + .request_params + .as_ref() + .and_then(|params| params.get("thinking_effort")), + Some(&serde_json::json!("low")) + ); + } + + #[test] + fn inherits_reasoning_controls_but_not_provider_specific_params() { + let previous = config_with_params( + "previous", + HashMap::from([ + ("budget_tokens".to_string(), serde_json::json!(8192)), + ( + "preserve_thinking_context".to_string(), + serde_json::json!(true), + ), + ("anthropic_beta".to_string(), serde_json::json!("beta")), + ]), + ); + let config = ModelConfig::new("next") + .with_inherited_session_settings_from(Some(&previous), None); + + let params = config.request_params.expect("reasoning controls inherited"); + assert_eq!(params.get("budget_tokens"), Some(&serde_json::json!(8192))); + assert_eq!( + params.get("preserve_thinking_context"), + Some(&serde_json::json!(true)) + ); + assert_eq!(params.get("anthropic_beta"), None); + } + + #[test] + fn explicit_request_params_override_preserved_session_settings() { + let previous = config_with_params( + "previous", + HashMap::from([("thinking_effort".to_string(), serde_json::json!("high"))]), + ); + let config = ModelConfig::new("next").with_inherited_session_settings_from( + Some(&previous), + Some(HashMap::from([( + "thinking_effort".to_string(), + serde_json::json!("low"), + )])), + ); + + assert_eq!( + config + .request_params + .as_ref() + .and_then(|params| params.get("thinking_effort")), + Some(&serde_json::json!("low")) + ); + } + + #[test] + fn effort_suffix_stripped_from_model_name() { + let _guard = env_lock::lock_env([ + ("GOOSE_THINKING_EFFORT", None::<&str>), + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_TEMPERATURE", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ("GOOSE_TOOLSHIM", None::<&str>), + ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), + ]); + let config = ModelConfig::new("o3-mini-high"); + assert_eq!(config.model_name, "o3-mini"); + assert_eq!(config.thinking_effort(), Some(ThinkingEffort::High)); + } + + #[test] + fn none_suffix_stripped_from_model_name() { + let _guard = env_lock::lock_env([ + ("GOOSE_THINKING_EFFORT", Some("high")), + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_TEMPERATURE", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ("GOOSE_TOOLSHIM", None::<&str>), + ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), + ]); + let config = ModelConfig::new("o3-mini-none"); + assert_eq!(config.model_name, "o3-mini"); + assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Off)); + } + + #[test] + fn xhigh_suffix_stripped_from_model_name() { + let _guard = env_lock::lock_env([ + ("GOOSE_THINKING_EFFORT", Some("low")), + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_TEMPERATURE", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ("GOOSE_TOOLSHIM", None::<&str>), + ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), + ]); + let config = ModelConfig::new("gpt-5.4-xhigh"); + assert_eq!(config.model_name, "gpt-5.4"); + assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Max)); + } + + #[test] + fn effort_suffix_not_stripped_when_thinking_effort_set() { + let _guard = env_lock::lock_env([ + ("GOOSE_THINKING_EFFORT", None::<&str>), + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_TEMPERATURE", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ("GOOSE_TOOLSHIM", None::<&str>), + ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), + ]); + let mut params = HashMap::new(); + params.insert("thinking_effort".to_string(), serde_json::json!("low")); + let mut config = ModelConfig::new("o3-mini-high"); + // Suffix was already normalized during new(), but if request_params + // were set before construction, the suffix would not be stripped. + // Verify the normalized state: + assert_eq!(config.model_name, "o3-mini"); + + // Now simulate setting explicit effort after construction + config.request_params = Some(params); + assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Low)); + } + + #[test] + fn no_suffix_no_change() { + let _guard = env_lock::lock_env([ + ("GOOSE_THINKING_EFFORT", None::<&str>), + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_TEMPERATURE", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ("GOOSE_TOOLSHIM", None::<&str>), + ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), + ]); + let config = ModelConfig::new("o3-mini"); + assert_eq!(config.model_name, "o3-mini"); + } + + #[test] + fn non_reasoning_model_suffix_not_stripped() { + let _guard = env_lock::lock_env([ + ("GOOSE_THINKING_EFFORT", None::<&str>), + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_TEMPERATURE", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ("GOOSE_TOOLSHIM", None::<&str>), + ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), + ]); + let config = ModelConfig::new("claude-sonnet-4-high"); + assert_eq!(config.model_name, "claude-sonnet-4-high"); + } + + #[test] + fn parse_aliases() { + assert_eq!("off".parse::(), Ok(ThinkingEffort::Off)); + assert_eq!( + "disabled".parse::(), + Ok(ThinkingEffort::Off) + ); + assert_eq!("med".parse::(), Ok(ThinkingEffort::Medium)); + assert_eq!("max".parse::(), Ok(ThinkingEffort::Max)); + assert_eq!("xhigh".parse::(), Ok(ThinkingEffort::Max)); + assert!("invalid".parse::().is_err()); + } + } + + mod with_canonical_limits { + use super::*; + + #[test] + fn sets_limits_from_canonical_model() { + let _guard = env_lock::lock_env([ + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ]); + let config = ModelConfig::new("gpt-4o").with_canonical_limits("openai"); + + assert_eq!(config.context_limit, Some(128_000)); + assert_eq!(config.max_tokens, Some(16_384)); + assert_eq!(config.reasoning, Some(false)); + } + + #[test] + fn does_not_override_existing_context_limit() { + let _guard = env_lock::lock_env([ + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ]); + let mut config = ModelConfig::new("gpt-4o"); + config.context_limit = Some(64_000); + let config = config.with_canonical_limits("openai"); + + assert_eq!(config.context_limit, Some(64_000)); + } + + #[test] + fn does_not_override_existing_max_tokens() { + let _guard = env_lock::lock_env([ + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ]); + let mut config = ModelConfig::new("gpt-4o"); + config.max_tokens = Some(1_000); + let config = config.with_canonical_limits("openai"); + + assert_eq!(config.max_tokens, Some(1_000)); + } + + #[test] + fn skips_canonical_output_limit_when_it_equals_context_limit() { + let _guard = env_lock::lock_env([ + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ]); + let config = ModelConfig::new("moonshotai/kimi-k2.6").with_canonical_limits("nvidia"); + + assert_eq!(config.context_limit, Some(262_144)); + assert_eq!(config.max_tokens, None); + assert_eq!(config.max_output_tokens(), 4_096); + } + + #[test] + fn resolves_claude_sonnet_5_on_aws_bedrock() { + let _guard = env_lock::lock_env([ + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ]); + let config = ModelConfig::new("global.anthropic.claude-sonnet-5") + .with_canonical_limits("aws_bedrock"); + + assert_eq!(config.context_limit, Some(1_000_000)); + assert_eq!(config.max_tokens, Some(128_000)); + assert_eq!(config.reasoning, Some(true)); + } + + #[test] + fn unknown_model_leaves_fields_none() { + let _guard = env_lock::lock_env([ + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ]); + let config = ModelConfig::new("totally-unknown-model").with_canonical_limits("openai"); + + assert_eq!(config.context_limit, None); + assert_eq!(config.max_tokens, None); + assert_eq!(config.reasoning, None); + } + + #[test] + fn resolves_after_stripping_reasoning_effort_suffix() { + let _guard = env_lock::lock_env([ + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ]); + + // "databricks-gpt-5.4-high" should resolve via "databricks-gpt-5.4" + let config = + ModelConfig::new("databricks-gpt-5.4-high").with_canonical_limits("databricks"); + assert_eq!(config.context_limit, Some(1_050_000)); + + // "gpt-5.4-xhigh" should resolve via "gpt-5.4" + let config = ModelConfig::new("gpt-5.4-xhigh").with_canonical_limits("openai"); + assert_eq!(config.context_limit, Some(1_050_000)); + + // "gpt-5.4-nano-low" should resolve via "gpt-5.4-nano" + let config = ModelConfig::new("gpt-5.4-nano-low").with_canonical_limits("openai"); + assert_eq!(config.context_limit, Some(400_000)); + } + } + + mod is_openai_reasoning_model { + use super::*; + + const ENV_LOCK_KEYS: [(&str, Option<&str>); 5] = [ + ("GOOSE_MAX_TOKENS", None), + ("GOOSE_TEMPERATURE", None), + ("GOOSE_CONTEXT_LIMIT", None), + ("GOOSE_TOOLSHIM", None), + ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None), + ]; + + #[test] + fn bare_reasoning_models() { + let _guard = env_lock::lock_env(ENV_LOCK_KEYS); + assert!(ModelConfig::new("o1").is_openai_reasoning_model()); + assert!(ModelConfig::new("o1-preview").is_openai_reasoning_model()); + assert!(ModelConfig::new("o3").is_openai_reasoning_model()); + assert!(ModelConfig::new("o3-mini").is_openai_reasoning_model()); + assert!(ModelConfig::new("o4-mini").is_openai_reasoning_model()); + assert!(ModelConfig::new("gpt-5").is_openai_reasoning_model()); + assert!(ModelConfig::new("gpt-5-3-codex").is_openai_reasoning_model()); + } + + #[test] + fn goose_prefixed_reasoning_models() { + let _guard = env_lock::lock_env(ENV_LOCK_KEYS); + assert!(ModelConfig::new("goose-o3-mini").is_openai_reasoning_model()); + assert!(ModelConfig::new("goose-o4-mini").is_openai_reasoning_model()); + assert!(ModelConfig::new("goose-gpt-5").is_openai_reasoning_model()); + } + + #[test] + fn databricks_prefixed_reasoning_models() { + let _guard = env_lock::lock_env(ENV_LOCK_KEYS); + assert!(ModelConfig::new("databricks-o3-mini").is_openai_reasoning_model()); + assert!(ModelConfig::new("databricks-o4-mini").is_openai_reasoning_model()); + assert!(ModelConfig::new("databricks-gpt-5").is_openai_reasoning_model()); + } + + #[test] + fn non_reasoning_models() { + let _guard = env_lock::lock_env(ENV_LOCK_KEYS); + assert!(!ModelConfig::new("claude-sonnet-4").is_openai_reasoning_model()); + assert!(!ModelConfig::new("gpt-4o").is_openai_reasoning_model()); + assert!(!ModelConfig::new("databricks-claude-sonnet-4").is_openai_reasoning_model()); + assert!(!ModelConfig::new("goose-claude-sonnet-4").is_openai_reasoning_model()); + assert!(!ModelConfig::new("llama-3-70b").is_openai_reasoning_model()); + } + } + + mod is_reasoning_model { + use super::*; + + const ENV_LOCK_KEYS: [(&str, Option<&str>); 5] = [ + ("GOOSE_MAX_TOKENS", None), + ("GOOSE_TEMPERATURE", None), + ("GOOSE_CONTEXT_LIMIT", None), + ("GOOSE_TOOLSHIM", None), + ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None), + ]; + + #[test] + fn includes_reasoning_model_families() { + let _guard = env_lock::lock_env(ENV_LOCK_KEYS); + assert!(ModelConfig::new("o3-mini").is_reasoning_model()); + assert!(ModelConfig::new("claude-sonnet-4").is_reasoning_model()); + assert!(ModelConfig::new("gemini-3-pro").is_reasoning_model()); + } + + #[test] + fn uses_explicit_metadata_first() { + let _guard = env_lock::lock_env(ENV_LOCK_KEYS); + let mut config = ModelConfig::new("provider-alias"); + config.reasoning = Some(true); + assert!(config.is_reasoning_model()); + + let mut config = ModelConfig::new("claude-sonnet-4"); + config.reasoning = Some(false); + assert!(!config.is_reasoning_model()); + } + } +} diff --git a/crates/goose/src/permission/permission_confirmation.rs b/crates/goose-provider-types/src/permission.rs similarity index 100% rename from crates/goose/src/permission/permission_confirmation.rs rename to crates/goose-provider-types/src/permission.rs diff --git a/crates/goose-provider-types/src/request_log.rs b/crates/goose-provider-types/src/request_log.rs new file mode 100644 index 0000000000..f3b9c53815 --- /dev/null +++ b/crates/goose-provider-types/src/request_log.rs @@ -0,0 +1,134 @@ +use std::{ + error::Error, + fmt::Display, + sync::{Arc, OnceLock}, +}; + +use serde::Serialize; +use serde_json::json; + +use crate::conversation::token_usage::Usage; + +type RequestLogError = Box; + +static LOGGER: OnceLock> = OnceLock::new(); + +#[derive(Debug)] +pub struct LoggerAlreadyInstalled; + +impl Display for LoggerAlreadyInstalled { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "request logger is already installed") + } +} + +impl Error for LoggerAlreadyInstalled {} + +pub fn install_logger(r: R) -> Result<(), LoggerAlreadyInstalled> { + LOGGER.set(Arc::new(r)).map_err(|_| LoggerAlreadyInstalled) +} + +pub trait RequestLogger: Send + Sync { + fn start(&self) -> Result, RequestLogError>; +} + +pub trait RequestLogHandle: Send { + fn write(&mut self, s: &str) -> Result<(), RequestLogError>; +} + +#[derive(Debug)] +pub enum LogError { + LoggerError(String), + SerializeError(serde_json::Error), +} + +impl Display for LogError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LogError::LoggerError(msg) => write!(f, "{}", msg), + LogError::SerializeError(error) => write!(f, "serialize error: {}", error), + } + } +} + +impl Error for LogError {} + +impl From for LogError { + fn from(value: RequestLogError) -> Self { + Self::LoggerError(value.to_string()) + } +} + +fn serialize(v: &serde_json::Value) -> Result { + serde_json::to_string(v).map_err(LogError::SerializeError) +} + +pub fn start_log( + model_config: M, + payload: P, +) -> Result>, LogError> +where + M: Serialize, + P: Serialize, +{ + let logger = if let Some(logger) = LOGGER.get() { + logger + } else { + return Ok(None); + }; + + let mut handle = logger.start()?; + let payload = json!({ + "model_config": model_config, + "input": payload, + }); + + handle.write(serialize(&payload)?.as_str())?; + Ok(Some(handle)) +} + +pub trait LoggerHandleExt { + fn write(&mut self, data: &Payload, usage: Option<&Usage>) -> Result<(), LogError> + where + Payload: Serialize; + fn error(&mut self, error: E) -> Result<(), LogError> + where + E: Display; +} + +impl LoggerHandleExt for Option> { + fn write(&mut self, data: &Payload, usage: Option<&Usage>) -> Result<(), LogError> + where + Payload: Serialize, + { + let log = if let Some(log) = self { + log + } else { + return Ok(()); + }; + + let line = serialize(&json!({ + "data": data, + "usage": usage, + }))?; + + Ok(log.write(line.as_str())?) + } + + fn error(&mut self, error: E) -> Result<(), LogError> + where + E: Display, + { + let log = if let Some(log) = self { + log + } else { + return Ok(()); + }; + + let line = serialize(&json!({ + "error": format!("{}", error), + }))?; + + Ok(log.write(line.as_str())?) + } +} diff --git a/crates/goose/src/providers/retry.rs b/crates/goose-provider-types/src/retry.rs similarity index 82% rename from crates/goose/src/providers/retry.rs rename to crates/goose-provider-types/src/retry.rs index f27f322dc1..49afd9bfe8 100644 --- a/crates/goose/src/providers/retry.rs +++ b/crates/goose-provider-types/src/retry.rs @@ -1,5 +1,5 @@ -use super::errors::ProviderError; -use crate::providers::base::Provider; +use crate::base::Provider; +use crate::errors::ProviderError; use async_trait::async_trait; use std::future::Future; use std::time::Duration; @@ -13,16 +13,16 @@ pub const DEFAULT_MAX_RETRY_INTERVAL_MS: u64 = 30_000; #[derive(Debug, Clone)] pub struct RetryConfig { /// Maximum number of retry attempts - pub(crate) max_retries: usize, + pub max_retries: usize, /// Initial interval between retries in milliseconds - pub(crate) initial_interval_ms: u64, + pub initial_interval_ms: u64, /// Multiplier for backoff (exponential) - pub(crate) backoff_multiplier: f64, + pub backoff_multiplier: f64, /// Maximum interval between retries in milliseconds - pub(crate) max_interval_ms: u64, + pub max_interval_ms: u64, /// When true, only retry on transient errors (ServerError, NetworkError, /// RateLimitExceeded). RequestFailed (4xx client errors) will not be retried. - pub(crate) transient_only: bool, + pub transient_only: bool, } impl Default for RetryConfig { @@ -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-provider-types/src/thinking.rs b/crates/goose-provider-types/src/thinking.rs new file mode 100644 index 0000000000..abfcc904f1 --- /dev/null +++ b/crates/goose-provider-types/src/thinking.rs @@ -0,0 +1,587 @@ +use std::{fmt, str::FromStr, sync::LazyLock}; + +use regex::Regex; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +pub const GEMINI_THOUGHT_SIGNATURE_KEY: &str = "thoughtSignature"; + +pub fn split_think_blocks(text: &str) -> (String, String) { + let mut filter = ThinkFilter::new(); + let mut out = filter.push(text); + let final_out = filter.finish(); + out.content.push_str(&final_out.content); + out.thinking.push_str(&final_out.thinking); + (out.content, out.thinking) +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct FilterOut { + pub content: String, + pub thinking: String, +} + +pub struct ThinkFilter { + buffer: String, + inside_think: bool, + think_depth: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ThinkTag { + Open, + Close, + SelfClosing, +} + +enum BufferEvent { + Tag { + pos: usize, + end: usize, + kind: ThinkTag, + }, + Partial(usize), +} + +impl ThinkFilter { + pub fn new() -> Self { + Self { + buffer: String::new(), + inside_think: false, + think_depth: 0, + } + } + + pub fn push(&mut self, chunk: &str) -> FilterOut { + self.buffer.push_str(chunk); + self.process_buffer() + } + + pub fn finish(mut self) -> FilterOut { + let mut out = self.process_buffer(); + if !self.buffer.is_empty() { + if self.inside_think { + out.thinking.push_str(&self.buffer); + } else { + out.content.push_str(&self.buffer); + } + self.buffer.clear(); + } + out + } + + fn process_buffer(&mut self) -> FilterOut { + let mut out = FilterOut::default(); + + loop { + match next_buffer_event(&self.buffer, self.inside_think) { + Some(BufferEvent::Tag { pos, end, kind }) => { + if pos > 0 { + let prefix = self.buffer.get(..pos).unwrap_or_default().to_string(); + if self.inside_think { + out.thinking.push_str(&prefix); + } else { + out.content.push_str(&prefix); + } + } + + self.buffer.drain(..end); + + match kind { + ThinkTag::Open => { + self.think_depth += 1; + self.inside_think = true; + } + ThinkTag::Close => { + self.think_depth = self.think_depth.saturating_sub(1); + self.inside_think = self.think_depth > 0; + } + ThinkTag::SelfClosing => {} + } + } + Some(BufferEvent::Partial(pos)) => { + if pos > 0 { + let prefix = self.buffer.get(..pos).unwrap_or_default().to_string(); + if self.inside_think { + out.thinking.push_str(&prefix); + } else { + out.content.push_str(&prefix); + } + self.buffer.drain(..pos); + } + break; + } + None => { + if !self.buffer.is_empty() { + if self.inside_think { + out.thinking.push_str(&self.buffer); + } else { + out.content.push_str(&self.buffer); + } + self.buffer.clear(); + } + break; + } + } + } + + out + } +} + +impl Default for ThinkFilter { + fn default() -> Self { + Self::new() + } +} + +fn next_buffer_event(buffer: &str, inside_think: bool) -> Option { + let mut search_from = 0; + + while let Some(rel_pos) = buffer.get(search_from..).and_then(|rest| rest.find('<')) { + let pos = search_from + rel_pos; + let suffix = buffer.get(pos..).unwrap_or_default(); + + if let Some((kind, end)) = parse_think_tag(buffer, pos) { + if inside_think || matches!(kind, ThinkTag::Open | ThinkTag::SelfClosing) { + return Some(BufferEvent::Tag { pos, end, kind }); + } + } else if !contains_unquoted_gt(suffix) && is_possible_partial_think_tag(suffix) { + return Some(BufferEvent::Partial(pos)); + } + + search_from = pos + 1; + } + + None +} + +fn parse_think_tag(buffer: &str, start: usize) -> Option<(ThinkTag, usize)> { + let bytes = buffer.as_bytes(); + if bytes.get(start) != Some(&b'<') { + return None; + } + + let mut idx = start + 1; + let is_close = if bytes.get(idx) == Some(&b'/') { + idx += 1; + true + } else { + false + }; + + let name_start = idx; + while bytes.get(idx).is_some_and(u8::is_ascii_alphabetic) { + idx += 1; + } + + if idx == name_start { + return None; + } + + let name = buffer.get(name_start..idx).unwrap_or_default(); + let is_think = name.eq_ignore_ascii_case("think") || name.eq_ignore_ascii_case("thinking"); + if !is_think { + return None; + } + + if is_close { + while bytes.get(idx).is_some_and(u8::is_ascii_whitespace) { + idx += 1; + } + if bytes.get(idx) == Some(&b'>') { + return Some((ThinkTag::Close, idx + 1)); + } + return None; + } + + // Require a real tag boundary immediately after the name (>, /, or whitespace). + // Without this, `` or `` would be classified as a + // think tag and stripped from normal content. + let valid_open_boundary = match bytes.get(idx) { + Some(&b) => b == b'>' || b == b'/' || b.is_ascii_whitespace(), + None => false, + }; + if !valid_open_boundary { + return None; + } + + let mut quote: Option = None; + let mut last_non_ws: Option = None; + while let Some(&byte) = bytes.get(idx) { + match quote { + Some(quote_byte) => { + if byte == quote_byte { + quote = None; + } + } + None if matches!(byte, b'"' | b'\'') => { + quote = Some(byte); + last_non_ws = Some(byte); + } + None if byte == b'>' => { + let kind = if last_non_ws == Some(b'/') { + ThinkTag::SelfClosing + } else { + ThinkTag::Open + }; + return Some((kind, idx + 1)); + } + None if !byte.is_ascii_whitespace() => { + last_non_ws = Some(byte); + } + None => {} + } + idx += 1; + } + + None +} + +fn is_possible_partial_think_tag(suffix: &str) -> bool { + if contains_unquoted_gt(suffix) { + return false; + } + + // Allow a trailing `/` so a chunk boundary that lands between `` in a self-closing `` (or ``) is still recognised + // as a partial tag and buffered until the `>` arrives in the next chunk. + static OPEN_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?is)^<(?:t(?:h(?:i(?:n(?:k(?:i(?:n(?:g)?)?)?)?)?)?)?)(?:\s.*|/)?$").unwrap() + }); + static CLOSE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?is)^ bool { + let mut quote: Option = None; + for &byte in text.as_bytes() { + match quote { + Some(quote_byte) => { + if byte == quote_byte { + quote = None; + } + } + None if matches!(byte, b'"' | b'\'') => quote = Some(byte), + None if byte == b'>' => return true, + None => {} + } + } + false +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum ThinkingEffort { + Off, + Low, + Medium, + High, + Max, +} + +impl FromStr for ThinkingEffort { + type Err = String; + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "off" | "disabled" | "none" => Ok(Self::Off), + "low" => Ok(Self::Low), + "medium" | "med" => Ok(Self::Medium), + "high" => Ok(Self::High), + "max" | "xhigh" => Ok(Self::Max), + other => Err(format!("unknown thinking effort: '{other}'")), + } + } +} + +impl fmt::Display for ThinkingEffort { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Off => write!(f, "off"), + Self::Low => write!(f, "low"), + Self::Medium => write!(f, "medium"), + Self::High => write!(f, "high"), + Self::Max => write!(f, "max"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_think_blocks_extracts_inline_reasoning() { + assert_eq!( + split_think_blocks("xy"), + ("y".to_string(), "x".to_string()) + ); + } + + #[test] + fn test_split_think_blocks_is_case_insensitive() { + assert_eq!( + split_think_blocks("xy"), + ("y".to_string(), "x".to_string()) + ); + } + + #[test] + fn test_split_think_blocks_handles_multiple_blocks() { + assert_eq!( + split_think_blocks("abcd"), + ("bd".to_string(), "ac".to_string()) + ); + } + + #[test] + fn test_split_think_blocks_without_tags() { + assert_eq!( + split_think_blocks("plain content"), + ("plain content".to_string(), String::new()) + ); + } + + #[test] + fn test_split_think_blocks_handles_attributes() { + assert_eq!( + split_think_blocks(r#"ab"#), + ("b".to_string(), "a".to_string()) + ); + } + + #[test] + fn test_split_think_blocks_handles_quoted_gt_in_self_closing_attributes() { + for input in [ + r#"Visible"#, + "Visible", + ] { + assert_eq!( + split_think_blocks(input), + ("Visible".to_string(), String::new()), + "mismatch for {input:?}" + ); + } + } + + #[test] + fn test_split_think_blocks_handles_quoted_gt_in_open_attributes() { + assert_eq!( + split_think_blocks(r#"HiddenVisible"#), + ("Visible".to_string(), "Hidden".to_string()) + ); + } + + #[test] + fn test_split_think_blocks_handles_thinking_variant() { + assert_eq!( + split_think_blocks("ab"), + ("b".to_string(), "a".to_string()) + ); + } + + #[test] + fn test_think_filter_streaming_across_partial_tags() { + let mut filter = ThinkFilter::new(); + let mut out = FilterOut::default(); + + for chunk in ["xy"] { + let partial = filter.push(chunk); + out.content.push_str(&partial.content); + out.thinking.push_str(&partial.thinking); + } + + let final_out = filter.finish(); + out.content.push_str(&final_out.content); + out.thinking.push_str(&final_out.thinking); + + assert_eq!(out.content, "y"); + assert_eq!(out.thinking, "x"); + } + + #[test] + fn test_think_filter_preserves_non_think_tags() { + let mut filter = ThinkFilter::new(); + let mut out = filter.push(""); + let final_out = filter.finish(); + out.content.push_str(&final_out.content); + out.thinking.push_str(&final_out.thinking); + + assert_eq!(out.content, "
"); + assert!(out.thinking.is_empty()); + } + + #[test] + fn test_think_filter_finish_treats_unterminated_think_as_thinking() { + let mut filter = ThinkFilter::new(); + let mut out = filter.push("unfinished"); + let final_out = filter.finish(); + out.content.push_str(&final_out.content); + out.thinking.push_str(&final_out.thinking); + + assert!(out.content.is_empty()); + assert_eq!(out.thinking, "unfinished"); + } + + #[test] + fn test_think_filter_tracks_generation_prompt_open_block() { + let mut filter = ThinkFilter::new(); + let _ = filter.push("<|assistant|>\n"); + let mut out = filter.push("hidden reasoningvisible answer"); + let final_out = filter.finish(); + out.content.push_str(&final_out.content); + out.thinking.push_str(&final_out.thinking); + + assert_eq!(out.content, "visible answer"); + assert_eq!(out.thinking, "hidden reasoning"); + } + + #[test] + fn test_think_filter_preserves_tags_with_think_prefix() { + for input in [ + "hello", + "payload", + "note", + ] { + let mut filter = ThinkFilter::new(); + let mut out = filter.push(input); + let final_out = filter.finish(); + out.content.push_str(&final_out.content); + out.thinking.push_str(&final_out.thinking); + + assert_eq!(out.content, input, "content mismatch for {input:?}"); + assert!( + out.thinking.is_empty(), + "unexpected thinking for {input:?}: {:?}", + out.thinking + ); + } + } + + #[test] + fn test_think_filter_accepts_think_with_attributes() { + let mut filter = ThinkFilter::new(); + let mut out = filter.push("hiddenvisible"); + let final_out = filter.finish(); + out.content.push_str(&final_out.content); + out.thinking.push_str(&final_out.thinking); + + assert_eq!(out.content, "visible"); + assert_eq!(out.thinking, "hidden"); + } + + #[test] + fn test_think_filter_treats_self_closing_as_noop() { + // `` carries no reasoning payload. It must not flip the filter + // into "inside_think" mode, and the tag itself must not leak into + // visible content. + for input in [ + "before after", + "before after", + "before after", + "before after", + ] { + let mut filter = ThinkFilter::new(); + let mut out = filter.push(input); + let final_out = filter.finish(); + out.content.push_str(&final_out.content); + out.thinking.push_str(&final_out.thinking); + + assert_eq!( + out.content, "before after", + "content mismatch for {input:?}" + ); + assert!( + out.thinking.is_empty(), + "unexpected thinking for {input:?}: {:?}", + out.thinking + ); + } + } + + #[test] + fn test_think_filter_self_closing_does_not_swallow_following_content() { + // Regression: a self-closing `` used to be classified as an + // Open tag, which incremented think_depth and routed everything after + // it into the thinking bucket for the rest of the stream. + let mut filter = ThinkFilter::new(); + let mut out = filter.push("visible chunk 1"); + let final_out = filter.push("visible chunk 2"); + let tail_out = filter.finish(); + out.content.push_str(&final_out.content); + out.thinking.push_str(&final_out.thinking); + out.content.push_str(&tail_out.content); + out.thinking.push_str(&tail_out.thinking); + + assert_eq!(out.content, "visible chunk 1visible chunk 2"); + assert!(out.thinking.is_empty()); + } + + #[test] + fn test_think_filter_streaming_across_self_closing_boundary() { + // Regression: a chunk boundary between `` in a + // self-closing `` used to fall out of the partial-tag regex + // (which only allowed `...`), so the `` arrived. + for (a, b) in [ + ("before after"), + ("before after"), + ("head tail"), + ] { + let mut filter = ThinkFilter::new(); + let mut out = filter.push(a); + let second = filter.push(b); + let final_out = filter.finish(); + out.content.push_str(&second.content); + out.content.push_str(&final_out.content); + out.thinking.push_str(&second.thinking); + out.thinking.push_str(&final_out.thinking); + + assert!( + !out.content.contains('<'), + "partial tag leaked into content for ({a:?}, {b:?}): {:?}", + out.content + ); + assert!( + out.thinking.is_empty(), + "unexpected thinking for ({a:?}, {b:?}): {:?}", + out.thinking + ); + } + } + + #[test] + fn test_think_filter_streaming_across_quoted_attribute_boundary() { + let mut filter = ThinkFilter::new(); + let mut out = filter.push(r#"Visible"#); + let final_out = filter.finish(); + out.content.push_str(&second.content); + out.content.push_str(&final_out.content); + out.thinking.push_str(&second.thinking); + out.thinking.push_str(&final_out.thinking); + + assert_eq!(out.content, "Visible"); + assert!(out.thinking.is_empty()); + } + + #[test] + fn test_think_filter_self_closing_inside_open_block_closes_nothing() { + // `` inside an open `` block is still a no-op: depth + // should stay at 1 until the real `` arrives. + let mut filter = ThinkFilter::new(); + let mut out = filter.push("before hidden1 hidden2visible"); + let final_out = filter.finish(); + out.content.push_str(&final_out.content); + out.thinking.push_str(&final_out.thinking); + + assert_eq!(out.content, "before visible"); + assert_eq!(out.thinking, "hidden1 hidden2"); + } +} diff --git a/crates/goose-provider-types/src/utils.rs b/crates/goose-provider-types/src/utils.rs 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 new file mode 100644 index 0000000000..5dd8c3e34e --- /dev/null +++ b/crates/goose-providers/Cargo.toml @@ -0,0 +1,69 @@ +[package] +name = "goose-providers" +version = "0.1.0-alpha.0" +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description.workspace = true + +[lints] +workspace = true + +[features] +default = [] +local-inference = ["dep:goose-local-inference"] +cuda = ["local-inference", "goose-local-inference/cuda"] +vulkan = ["local-inference", "goose-local-inference/vulkan"] +mlx = ["local-inference", "goose-local-inference/mlx"] +rustls-tls = [ + "reqwest/rustls", + "rmcp/reqwest", +] +native-tls = [ + "dep:pem", + "dep:pkcs1", + "dep:pkcs8", + "dep:sec1", + "reqwest/native-tls", + "rmcp/reqwest-native-tls", +] + +[dependencies] +anyhow = { workspace = true } +async-stream = { workspace = true } +chrono = { workspace = true } +futures = { workspace = true } +goose-provider-types = { version = "0.1.0-alpha.0", path = "../goose-provider-types", default-features = false } +goose-local-inference = { version = "0.1.0-alpha.0", path = "../goose-local-inference", default-features = false, optional = true } +reqwest = { workspace = true } +rmcp = { workspace = true, features = ["server", "macros"] } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +utoipa = { workspace = true, features = ["chrono"] } +async-trait = { workspace = true } +tokio = { workspace = true } +tokio-stream = { workspace = true, features = ["io-util"] } +tokio-util = { workspace = true, features = ["compat"] } +url = { workspace = true } +urlencoding = { workspace = true } +pem = { version = "3.0.2", default-features = false, features = ["std"], optional = true } +pkcs1 = { version = "0.7.5", default-features = false, features = ["pkcs8", "std"], optional = true } +# v0.10 matches the der/const-oid series used by sec1 v0.7 and pkcs1 v0.7; upgrading to v0.11 causes type mismatches across those crates. +pkcs8 = { version = "0.10", default-features = false, features = ["alloc", "std"], optional = true } +sec1 = { version = "0.7", default-features = false, features = ["der", "pkcs8", "std"], optional = true } +include_dir = { workspace = true } + +[dev-dependencies] +test-case = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread"] } +tokio-stream = { workspace = true } +env-lock = { workspace = true } +wiremock.workspace = true + +[[example]] +name = "streaming" +required-features = ["rustls-tls"] diff --git a/crates/goose-providers/examples/declarative.rs b/crates/goose-providers/examples/declarative.rs 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 new file mode 100644 index 0000000000..66ea1da737 --- /dev/null +++ b/crates/goose-providers/examples/streaming.rs @@ -0,0 +1,39 @@ +use std::env; + +use anyhow::Result; +use futures::StreamExt; +use goose_providers::{ + api_client::{ApiClient, AuthMethod}, + base::Provider, + conversation::message::Message, + model::ModelConfig, + openai::OpenAiProvider, +}; + +async fn stream(provider: impl Provider, model: ModelConfig) -> Result<()> { + let system = "You are a knowledgable geography expert"; + let messages = [Message::user().with_text("what is the capital of France?")]; + + let mut stream = provider.stream(&model, system, &messages, &[]).await?; + + while let Some((Some(msg), _)) = stream.next().await.transpose()? { + print!("{}", msg.as_concat_text()); + } + println!(); + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<()> { + let key = env::var("OPENAI_API_KEY").map_err(|_| anyhow::anyhow!("need an OpenAI key"))?; + let api_client = ApiClient::new_with_tls( + "https://api.openai.com".to_string(), + AuthMethod::BearerToken(key), + Some(Default::default()), + )?; + + let provider = OpenAiProvider::new(api_client); + let model = ModelConfig::new("gpt-5.4-mini"); + + stream(provider, model).await +} diff --git a/crates/goose-providers/src/anthropic.rs b/crates/goose-providers/src/anthropic.rs 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/src/providers/api_client.rs b/crates/goose-providers/src/api_client.rs similarity index 79% rename from crates/goose/src/providers/api_client.rs rename to crates/goose-providers/src/api_client.rs index f6bc62d470..34a6d7f021 100644 --- a/crates/goose/src/providers/api_client.rs +++ b/crates/goose-providers/src/api_client.rs @@ -1,5 +1,3 @@ -use crate::providers::base::DEFAULT_PROVIDER_TIMEOUT_SECS; -use crate::session_context::SESSION_ID_HEADER; use anyhow::Result; use async_trait::async_trait; use reqwest::{ @@ -13,8 +11,14 @@ 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; + +pub type RequestBuilderDecorator = + Arc Result + Send + Sync>; + pub struct ApiClient { client: Client, host: String, @@ -23,17 +27,13 @@ pub struct ApiClient { default_query: Vec<(String, String)>, timeout: Duration, tls_config: Option, + request_builder: Option, } pub enum AuthMethod { NoAuth, BearerToken(String), - ApiKey { - header_name: String, - key: String, - }, - #[allow(dead_code)] - OAuth(OAuthConfig), + ApiKey { header_name: String, key: String }, Custom(Box), } @@ -57,48 +57,6 @@ impl TlsConfig { } } - pub fn from_config() -> Result> { - let config = crate::config::Config::global(); - let mut tls_config = TlsConfig::new(); - let mut has_tls_config = false; - - let client_cert_path = config.get_param::("GOOSE_CLIENT_CERT_PATH").ok(); - let client_key_path = config.get_param::("GOOSE_CLIENT_KEY_PATH").ok(); - - // Validate that both cert and key are provided if either is provided - match (client_cert_path, client_key_path) { - (Some(cert_path), Some(key_path)) => { - tls_config = tls_config.with_client_cert_and_key( - std::path::PathBuf::from(cert_path), - std::path::PathBuf::from(key_path), - ); - has_tls_config = true; - } - (Some(_), None) => { - return Err(anyhow::anyhow!( - "Client certificate provided (GOOSE_CLIENT_CERT_PATH) but no private key (GOOSE_CLIENT_KEY_PATH)" - )); - } - (None, Some(_)) => { - return Err(anyhow::anyhow!( - "Client private key provided (GOOSE_CLIENT_KEY_PATH) but no certificate (GOOSE_CLIENT_CERT_PATH)" - )); - } - (None, None) => {} - } - - if let Ok(ca_cert_path) = config.get_param::("GOOSE_CA_CERT_PATH") { - tls_config = tls_config.with_ca_cert(std::path::PathBuf::from(ca_cert_path)); - has_tls_config = true; - } - - if has_tls_config { - Ok(Some(tls_config)) - } else { - Ok(None) - } - } - pub fn with_client_cert_and_key(mut self, cert_path: PathBuf, key_path: PathBuf) -> Self { self.client_identity = Some(TlsCertKeyPair { cert_path, @@ -234,13 +192,6 @@ fn convert_key_to_pkcs8_pem(key_pem_str: &str) -> Result { } } -pub struct OAuthConfig { - pub host: String, - pub client_id: String, - pub redirect_url: String, - pub scopes: Vec, -} - #[async_trait] pub trait AuthProvider: Send + Sync { async fn get_auth_header(&self) -> Result<(String, String)>; @@ -261,7 +212,6 @@ impl fmt::Debug for AuthMethod { .field("header_name", header_name) .field("key", &"[hidden]") .finish(), - AuthMethod::OAuth(_) => f.debug_tuple("OAuth").field(&"[config]").finish(), AuthMethod::Custom(_) => f.debug_tuple("Custom").field(&"[provider]").finish(), } } @@ -279,23 +229,30 @@ pub struct ApiRequestBuilder<'a> { client: &'a ApiClient, path: &'a str, headers: HeaderMap, - session_id: Option<&'a str>, } impl ApiClient { - pub fn new(host: String, auth: AuthMethod) -> Result { - Self::with_timeout( + pub fn new_with_tls( + host: String, + auth: AuthMethod, + tls_config: Option, + ) -> Result { + Self::with_timeout_and_tls( host, auth, Duration::from_secs(DEFAULT_PROVIDER_TIMEOUT_SECS), + tls_config, ) } - pub fn with_timeout(host: String, auth: AuthMethod, timeout: Duration) -> Result { + pub fn with_timeout_and_tls( + host: String, + auth: AuthMethod, + timeout: Duration, + tls_config: Option, + ) -> Result { let mut client_builder = Client::builder().timeout(timeout); - // Configure TLS if needed - let tls_config = TlsConfig::from_config()?; if let Some(ref config) = tls_config { client_builder = Self::configure_tls(client_builder, config)?; } @@ -310,9 +267,14 @@ impl ApiClient { default_query: Vec::new(), timeout, tls_config, + request_builder: None, }) } + pub fn host(&self) -> &str { + &self.host + } + fn rebuild_client(&mut self) -> Result<()> { let mut client_builder = Client::builder() .timeout(self.timeout) @@ -381,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 { @@ -441,16 +392,6 @@ impl ApiClient { Ok(url) } - - async fn get_oauth_token(&self, config: &OAuthConfig) -> Result { - super::oauth::get_oauth_token_async( - &config.host, - &config.client_id, - &config.redirect_url, - &config.scopes, - ) - .await - } } impl<'a> ApiRequestBuilder<'a> { @@ -497,27 +438,20 @@ 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) => { request.header("Authorization", format!("Bearer {}", token)) } AuthMethod::ApiKey { header_name, key } => request.header(header_name.as_str(), key), - AuthMethod::OAuth(config) => { - let token = self.client.get_oauth_token(config).await?; - request.header("Authorization", format!("Bearer {}", token)) - } AuthMethod::Custom(provider) => { let (header_name, header_value) = provider.get_auth_header().await?; request.header(header_name, header_value) @@ -663,40 +597,32 @@ 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( + let client = ApiClient::new_with_tls( "http://localhost:8080".to_string(), 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 67% rename from crates/goose/src/providers/databricks.rs rename to crates/goose-providers/src/databricks.rs index 76b9bc4594..b857b91371 100644 --- a/crates/goose/src/providers/databricks.rs +++ b/crates/goose-providers/src/databricks.rs @@ -1,33 +1,35 @@ +use crate::formats::openai::{ + extract_reasoning_effort, is_openai_responses_model, openai_reasoning_effort_for_thinking, +}; +use crate::images::ImageFormat; use anyhow::Result; use async_trait::async_trait; -use futures::future::BoxFuture; 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 super::databricks_auth::{DatabricksAuth, DatabricksAuthProvider}; -use super::embedding::EmbeddingCapable; -use super::errors::ProviderError; -use super::formats::databricks::create_request; -use super::formats::openai_responses::create_responses_request; -use super::openai_compatible::{ - handle_response_openai_compat, handle_status, map_http_error_to_provider_error, sanitize_url, - stream_openai_compat, stream_responses_compat, -}; -use super::retry::ProviderRetry; -use super::utils::{is_openai_responses_model, ImageFormat, RequestLog}; -use crate::config::ConfigError; +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::instance_id::get_instance_id; +use crate::databricks_auth::{ + DatabricksAuth, DatabricksAuthProvider, DatabricksOauthTokenProvider, DatabricksRefreshHook, + DatabricksSessionIdProvider, DatabricksTokenResolver, +}; +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::providers::retry::{ +use crate::openai_compatible::{ + handle_status, map_http_error_to_provider_error, sanitize_url, 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, }; @@ -40,6 +42,7 @@ struct DatabricksEndpointInfo { upstream_model_name: Option, upstream_model_provider: Option, reasoning: Option, + supports_responses_api: bool, } #[derive(Debug, Clone)] @@ -54,13 +57,12 @@ struct CachedDatabricksEndpointInfo { fetched_at: Instant, } -const DATABRICKS_PROVIDER_NAME: &str = "databricks"; const DATABRICKS_ENDPOINT_METADATA_TTL_SECS: u64 = 60; 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", @@ -70,57 +72,46 @@ 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, #[serde(skip)] - fast_retry_config: RetryConfig, - #[serde(skip)] name: String, #[serde(skip)] 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) -> 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 fast_retry_config = Self::load_fast_retry_config(config); - - let auth = if let Ok(api_key) = config.get_secret("DATABRICKS_TOKEN") { - DatabricksAuth::token(api_key) - } else { - DatabricksAuth::oauth(host.clone()) - }; - + #[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 token_cache = Arc::new(Mutex::new(match &auth { DatabricksAuth::Token(t) => Some(t.clone()), _ => None, @@ -129,54 +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( + let mut api_client = ApiClient::with_timeout_and_tls( host.clone(), 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); + } - let mut provider = Self { + Ok(Self { api_client, host, auth, - model: model.clone(), image_format: ImageFormat::OpenAi, retry_config, - fast_retry_config, name: DATABRICKS_PROVIDER_NAME.to_string(), token_cache, - instance_id: Self::resolve_instance_id(), - }; - provider.model = - model.with_fast(DATABRICKS_DEFAULT_FAST_MODEL, DATABRICKS_PROVIDER_NAME)?; - 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( @@ -187,60 +173,24 @@ impl DatabricksProvider { ) } - fn load_fast_retry_config(_config: &crate::config::Config) -> RetryConfig { - // Fast models are hardcoded to 0 retries for quick failure on Databricks - RetryConfig::new(0, 0, 1.0, 0) - } - - pub fn from_params(host: String, api_key: String, model: ModelConfig) -> Result { - let token_cache = Arc::new(Mutex::new(Some(api_key.clone()))); - let auth = DatabricksAuth::token(api_key); - let auth_method = AuthMethod::Custom(Box::new(DatabricksAuthProvider { - auth: auth.clone(), - token_cache: token_cache.clone(), - })); - - let api_client = ApiClient::with_timeout( - host.clone(), - auth_method, - Duration::from_secs(DEFAULT_PROVIDER_TIMEOUT_SECS), - )?; - - Ok(Self { - api_client, - host, - auth, - model, - image_format: ImageFormat::OpenAi, - retry_config: RetryConfig::default(), - fast_retry_config: RetryConfig::new(0, 0, 1.0, 0), - name: DATABRICKS_PROVIDER_NAME.to_string(), - token_cache, - instance_id: Self::resolve_instance_id(), - }) - } - - 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_responses_model(model_name: &str) -> bool { - is_openai_responses_model(model_name) - } - fn is_claude_model(model_name: &str) -> bool { model_name.to_lowercase().contains("claude") } fn is_reasoning_capable_model_name(model_name: &str) -> bool { - Self::is_claude_model(model_name) || Self::is_responses_model(model_name) + Self::is_claude_model(model_name) || is_openai_responses_model(model_name) + } + + fn uses_responses_api( + endpoint_info: Option<&DatabricksEndpointInfo>, + model_names: &[&str], + ) -> bool { + match endpoint_info { + Some(info) => info.supports_responses_api, + None => model_names + .iter() + .any(|name| is_openai_responses_model(name)), + } } fn endpoint_model_candidates(value: &Value) -> Vec { @@ -301,6 +251,7 @@ impl DatabricksProvider { fn endpoint_info_from_value(endpoint: &Value) -> Option { let name = endpoint.get("name")?.as_str()?.to_string(); + let supports_responses_api = Self::endpoint_supports_responses_api(endpoint); let upstream_model = Self::endpoint_model_candidates(endpoint) .into_iter() .find(|candidate| candidate.name != name); @@ -317,22 +268,55 @@ impl DatabricksProvider { upstream_model_name, upstream_model_provider, reasoning, + supports_responses_api, }) } + fn endpoint_supports_responses_api(endpoint: &Value) -> bool { + fn value_contains_responses_api(value: &Value) -> bool { + match value { + Value::Object(map) => { + map.get("api_types") + .and_then(|api_types| api_types.as_array()) + .is_some_and(|api_types| { + api_types + .iter() + .any(|api_type| api_type.as_str() == Some("openai/v1/responses")) + }) + || map.values().any(value_contains_responses_api) + } + Value::Array(values) => values.iter().any(value_contains_responses_api), + _ => false, + } + } + + let Some(config) = endpoint.get("config") else { + return false; + }; + + for collection_key in ["served_entities", "served_models"] { + let Some(entities) = config.get(collection_key).and_then(|v| v.as_array()) else { + continue; + }; + + if entities.iter().any(value_contains_responses_api) { + return true; + } + } + + false + } + async fn fetch_endpoint_info( &self, endpoint_name: &str, ) -> 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| { @@ -375,6 +359,7 @@ impl DatabricksProvider { let mut current_endpoint_name = endpoint_name.to_string(); let mut visited = HashSet::new(); let mut last_info: Option = None; + let mut first_hop_supports_responses_api: Option = None; for _ in 0..MAX_MODEL_SERVING_HOPS { if !visited.insert(current_endpoint_name.clone()) { @@ -382,6 +367,8 @@ impl DatabricksProvider { } let info = self.fetch_endpoint_info(¤t_endpoint_name).await?; + let supports_responses_api = + *first_hop_supports_responses_api.get_or_insert(info.supports_responses_api); let next_endpoint_name = match ( info.upstream_model_provider.as_deref(), info.upstream_model_name.as_deref(), @@ -400,7 +387,7 @@ impl DatabricksProvider { continue; } - return Ok(if info.name == original_endpoint_name { + let mut resolved_info = if info.name == original_endpoint_name { info } else { let upstream_model_name = info @@ -412,8 +399,11 @@ impl DatabricksProvider { upstream_model_name, upstream_model_provider: info.upstream_model_provider.clone(), reasoning: info.reasoning, + supports_responses_api, } - }); + }; + resolved_info.supports_responses_api = supports_responses_api; + return Ok(resolved_info); } last_info @@ -422,6 +412,7 @@ impl DatabricksProvider { upstream_model_name: info.upstream_model_name, upstream_model_provider: info.upstream_model_provider, reasoning: info.reasoning, + supports_responses_api: first_hop_supports_responses_api.unwrap_or(false), }) .ok_or_else(|| { ProviderError::RequestFailed( @@ -462,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, @@ -481,16 +472,12 @@ impl DatabricksProvider { } } - fn get_endpoint_path(&self, model_name: &str, is_embedding: bool) -> String { - if is_embedding { - "serving-endpoints/text-embedding-3-small/invocations".to_string() + fn get_endpoint_path(&self, model_name: &str, is_responses_model: bool) -> String { + if is_responses_model { + "serving-endpoints/responses".to_string() } else { - let (clean_name, _) = super::utils::extract_reasoning_effort(model_name); - if Self::is_responses_model(&clean_name) { - "serving-endpoints/responses".to_string() - } else { - format!("serving-endpoints/{}/invocations", clean_name) - } + let (clean_name, _) = extract_reasoning_effort(model_name); + format!("serving-endpoints/{}/invocations", clean_name) } } @@ -502,34 +489,9 @@ impl DatabricksProvider { .to_string() }) } - - async fn post( - &self, - session_id: Option<&str>, - mut payload: Value, - model_name: Option<&str>, - ) -> Result { - let is_embedding = payload.get("input").is_some() && payload.get("messages").is_none(); - let model_to_use = model_name.unwrap_or(&self.model.model_name); - let path = self.get_endpoint_path(model_to_use, is_embedding); - - if let Some(session_id) = session_id { - if let Some(client_request_id) = self.build_client_request_id(session_id) { - payload["client_request_id"] = Value::String(client_request_id); - } - } - - let response = self - .api_client - .response_post(session_id, &path, &payload) - .await?; - handle_response_openai_compat(response).await - } } -impl ProviderDef for DatabricksProvider { - type Provider = Self; - +impl crate::base::ProviderDescriptor for DatabricksProvider { fn metadata() -> ProviderMetadata { ProviderMetadata::new( DATABRICKS_PROVIDER_NAME, @@ -543,17 +505,7 @@ impl ProviderDef for DatabricksProvider { ConfigKey::new("DATABRICKS_TOKEN", false, true, None, true), ], ) - } - - fn from_env( - model: ModelConfig, - _extensions: Vec, - ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) - } - - fn supports_inventory_refresh() -> bool { - true + .with_fast_model(DATABRICKS_DEFAULT_FAST_MODEL) } } @@ -568,38 +520,42 @@ 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 (endpoint_name, _) = super::utils::extract_reasoning_effort(&model_config.model_name); + 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 .as_ref() .and_then(|info| info.upstream_model_name.as_deref()) .unwrap_or(&model_config.model_name); - let is_responses_model = Self::is_responses_model(&model_config.model_name) - || Self::is_responses_model(effective_model_name); + let is_responses_model = Self::uses_responses_api( + endpoint_info.as_ref(), + &[&model_config.model_name, effective_model_name], + ); let path = if is_responses_model { "serving-endpoints/responses".to_string() } else { - self.get_endpoint_path(&model_config.model_name, false) + 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; @@ -618,7 +574,7 @@ impl Provider for DatabricksProvider { payload["model"] = Value::String(endpoint_name.clone()); if payload.get("reasoning").is_none() { if let Some(effort) = model_config.thinking_effort().and_then(|effort| { - super::utils::openai_reasoning_effort_for_thinking(effective_model_name, effort) + openai_reasoning_effort_for_thinking(effective_model_name, effort) }) { payload.as_object_mut().unwrap().insert( "reasoning".to_string(), @@ -634,15 +590,12 @@ impl Provider for DatabricksProvider { payload["client_request_id"] = Value::String(client_request_id.clone()); } - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { let payload_clone = payload.clone(); - let resp = self - .api_client - .response_post(Some(session_id), &path, &payload_clone) - .await?; + let resp = self.api_client.response_post(&path, &payload_clone).await?; handle_status(resp).await }) .await @@ -666,7 +619,8 @@ impl Provider for DatabricksProvider { model_config }; - let mut payload = create_request( + let mut payload = create_request_for_provider( + DATABRICKS_PROVIDER_NAME, request_model_config, system, messages, @@ -698,13 +652,10 @@ impl Provider for DatabricksProvider { .insert("stream_options".to_string(), json!({"include_usage": true})); } - let mut log = RequestLog::start(model_config, &payload)?; + 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()); @@ -754,20 +702,6 @@ impl Provider for DatabricksProvider { } } - fn supports_embeddings(&self) -> bool { - true - } - - async fn create_embeddings( - &self, - session_id: &str, - texts: Vec, - ) -> Result>, ProviderError> { - EmbeddingCapable::create_embeddings(self, session_id, texts) - .await - .map_err(|e| ProviderError::ExecutionError(e.to_string())) - } - async fn fetch_supported_models(&self) -> Result, ProviderError> { Ok(self .fetch_supported_model_info() @@ -780,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| { @@ -821,54 +755,16 @@ impl Provider for DatabricksProvider { } async fn fetch_model_info(&self, model_name: &str) -> Result { - let (endpoint_name, _) = super::utils::extract_reasoning_effort(model_name); + let (endpoint_name, _) = extract_reasoning_effort(model_name); let endpoint_info = self.resolve_endpoint_info_cached(&endpoint_name).await?; Ok(Self::model_info_from_endpoint(endpoint_info)) } - async fn fetch_recommended_model_info(&self) -> Result, ProviderError> { - self.fetch_supported_model_info().await - } -} - -#[async_trait] -impl EmbeddingCapable for DatabricksProvider { - async fn create_embeddings( + async fn fetch_recommended_model_info( &self, - session_id: &str, - texts: Vec, - ) -> Result>> { - if texts.is_empty() { - return Ok(vec![]); - } - - let request = json!({ - "input": texts, - }); - - let response = self - .with_retry_config( - || self.post(Some(session_id), request.clone(), None), - self.fast_retry_config.clone(), - ) - .await?; - - let embeddings = response["data"] - .as_array() - .ok_or_else(|| anyhow::anyhow!("Invalid response format: missing data array"))? - .iter() - .map(|item| { - item["embedding"] - .as_array() - .ok_or_else(|| anyhow::anyhow!("Invalid embedding format"))? - .iter() - .map(|v| v.as_f64().map(|f| f as f32)) - .collect::>>() - .ok_or_else(|| anyhow::anyhow!("Invalid embedding values")) - }) - .collect::>>>()?; - - Ok(embeddings) + _toolshim: bool, + ) -> Result, ProviderError> { + self.fetch_supported_model_info().await } } @@ -876,47 +772,6 @@ impl EmbeddingCapable for DatabricksProvider { mod tests { use super::*; - fn test_provider() -> DatabricksProvider { - DatabricksProvider { - api_client: super::super::api_client::ApiClient::new( - "https://example.com".to_string(), - super::super::api_client::AuthMethod::NoAuth, - ) - .unwrap(), - host: "https://example.com".to_string(), - auth: DatabricksAuth::Token("fake".into()), - model: ModelConfig::new_or_fail("databricks-gpt-5.4"), - image_format: ImageFormat::OpenAi, - retry_config: RetryConfig::default(), - fast_retry_config: RetryConfig::new(0, 0, 1.0, 0), - name: "databricks".into(), - token_cache: std::sync::Arc::new(std::sync::Mutex::new(None)), - instance_id: None, - } - } - - #[test] - fn responses_models_route_to_responses_endpoint() { - let provider = test_provider(); - - for (model_name, expected_path) in [ - ("gpt-5.4", "serving-endpoints/responses"), - ("databricks-gpt-5.4-high", "serving-endpoints/responses"), - ("databricks-gpt-5-4-xhigh", "serving-endpoints/responses"), - ("o3-mini", "serving-endpoints/responses"), - ( - "databricks-claude-sonnet-4", - "serving-endpoints/databricks-claude-sonnet-4/invocations", - ), - ] { - assert_eq!( - provider.get_endpoint_path(model_name, false), - expected_path, - "unexpected endpoint for {model_name}" - ); - } - } - #[test] fn endpoint_metadata_marks_reasoning_alias_from_external_model() { let endpoint = json!({ @@ -938,6 +793,7 @@ mod tests { assert_eq!(info.name, "goose"); assert_eq!(info.upstream_model_name.as_deref(), Some("claude-opus-4.6")); assert_eq!(info.reasoning, Some(true)); + assert!(!info.supports_responses_api); let model_info = DatabricksProvider::model_info_from_endpoint(info); assert_eq!(model_info.name, "goose"); @@ -1010,5 +866,118 @@ mod tests { assert_eq!(info.name, "goose-gpt-5-5"); assert_eq!(info.upstream_model_name, None); assert_eq!(info.reasoning, Some(true)); + assert!(!info.supports_responses_api); + } + + #[test] + fn endpoint_metadata_detects_responses_api_from_foundation_model_api_types() { + let endpoint = json!({ + "name": "databricks-gpt-5-4", + "config": { + "served_entities": [{ + "name": "databricks-gpt-5-4", + "entity_name": "system.ai.databricks-gpt-5-4", + "type": "FOUNDATION_MODEL", + "foundation_model": { + "name": "system.ai.databricks-gpt-5-4", + "display_name": "GPT-5.4", + "api_types": [ + "mlflow/v1/chat/completions", + "openai/v1/responses", + "cursor/v1/chat/completions" + ] + } + }] + } + }); + + let info = DatabricksProvider::endpoint_info_from_value(&endpoint).unwrap(); + + assert_eq!(info.name, "databricks-gpt-5-4"); + assert_eq!( + info.upstream_model_name.as_deref(), + Some("system.ai.databricks-gpt-5-4") + ); + assert!(info.supports_responses_api); + } + + #[test] + fn endpoint_metadata_detects_responses_api_from_served_models() { + let endpoint = json!({ + "name": "databricks-gpt-5-4", + "config": { + "served_models": [{ + "foundation_model": { + "api_types": ["openai/v1/responses"] + } + }] + } + }); + + let info = DatabricksProvider::endpoint_info_from_value(&endpoint).unwrap(); + + assert!(info.supports_responses_api); + } + + #[test] + fn endpoint_metadata_ignores_pending_config_for_responses_routing() { + let endpoint = json!({ + "name": "databricks-gpt-5-4", + "config": { + "served_entities": [{ + "foundation_model": { + "api_types": ["mlflow/v1/chat/completions"] + } + }] + }, + "pending_config": { + "served_entities": [{ + "foundation_model": { + "api_types": ["openai/v1/responses"] + } + }] + } + }); + + let info = DatabricksProvider::endpoint_info_from_value(&endpoint).unwrap(); + + assert!(!info.supports_responses_api); + } + + #[test] + fn responses_routing_prefers_metadata_over_model_name() { + let responses_info = DatabricksEndpointInfo { + name: "custom".into(), + upstream_model_name: None, + upstream_model_provider: None, + reasoning: None, + supports_responses_api: true, + }; + assert!(DatabricksProvider::uses_responses_api( + Some(&responses_info), + &["databricks-claude-sonnet-4"] + )); + + let chat_info = DatabricksEndpointInfo { + supports_responses_api: false, + ..responses_info + }; + assert!(!DatabricksProvider::uses_responses_api( + Some(&chat_info), + &["gpt-5.4"] + )); + } + + #[test] + fn responses_routing_falls_back_to_model_name_without_metadata() { + assert!(DatabricksProvider::uses_responses_api(None, &["gpt-5.4"])); + assert!(DatabricksProvider::uses_responses_api( + None, + &["databricks-claude-sonnet-4", "gpt-5.4"] + )); + assert!(!DatabricksProvider::uses_responses_api( + None, + &["databricks-claude-sonnet-4"] + )); } } 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 64% rename from crates/goose/src/providers/databricks_v2.rs rename to crates/goose-providers/src/databricks_v2.rs index 2385ae9bde..af30140590 100644 --- a/crates/goose/src/providers/databricks_v2.rs +++ b/crates/goose-providers/src/databricks_v2.rs @@ -1,7 +1,9 @@ +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 serde::Serialize; use serde_json::Value; @@ -11,21 +13,22 @@ 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::errors::ProviderError; -use super::formats::{anthropic, openai, openai_responses}; -use super::openai_compatible::{handle_status, stream_openai_compat, stream_responses_compat}; -use super::retry::ProviderRetry; -use super::utils::{extract_reasoning_effort, is_openai_responses_model, ImageFormat, RequestLog}; -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::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::providers::retry::{ +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, }; @@ -33,6 +36,7 @@ use rmcp::model::Tool; const DATABRICKS_V2_PROVIDER_NAME: &str = "databricks_v2"; const DATABRICKS_V2_LIST_ENDPOINTS_PATH: &str = "api/ai-gateway/v2/endpoints"; +const DATABRICKS_V2_LIST_ENDPOINTS_PAGE_SIZE: usize = 100; pub const DATABRICKS_V2_DEFAULT_MODEL: &str = "databricks-gpt-5-5"; pub const DATABRICKS_V2_KNOWN_MODELS: &[&str] = &["databricks-gpt-5-5", "databricks-claude-opus-4-7"]; @@ -46,65 +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) -> 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) - } - - pub fn from_params(host: String, api_key: String, model: ModelConfig) -> Result { - Self::new( - host, - DatabricksAuth::token(api_key), - model, - RetryConfig::default(), - ) - } - - fn new( + #[allow(clippy::too_many_arguments)] + pub fn new( host: String, auth: DatabricksAuth, - model: ModelConfig, retry_config: RetryConfig, + 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()), @@ -114,46 +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( + 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( @@ -185,7 +157,9 @@ impl DatabricksV2Provider { model_name.contains("claude") } - fn parse_list_endpoints_response(json: &Value) -> Result, ProviderError> { + fn parse_list_endpoints_response( + json: &Value, + ) -> Result<(Vec, Option), ProviderError> { let endpoints = json .get("endpoints") .and_then(|v| v.as_array()) @@ -196,7 +170,7 @@ impl DatabricksV2Provider { ) })?; - let mut models: Vec = endpoints + let models: Vec = endpoints .iter() .filter_map(|endpoint| { endpoint @@ -205,14 +179,19 @@ impl DatabricksV2Provider { .map(str::to_string) }) .collect(); - models.sort(); - Ok(models) + + let next_page_token = json + .get("next_page_token") + .and_then(|v| v.as_str()) + .filter(|token| !token.is_empty()) + .map(str::to_string); + + Ok((models, next_page_token)) } async fn stream_openai_responses( &self, model_config: &ModelConfig, - session_id: &str, system: &str, messages: &[Message], tools: &[Tool], @@ -220,13 +199,13 @@ impl DatabricksV2Provider { let mut payload = openai_responses::create_responses_request(model_config, system, messages, tools)?; payload["stream"] = Value::Bool(true); - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .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 }) @@ -241,7 +220,6 @@ impl DatabricksV2Provider { async fn stream_mlflow_chat_completions( &self, model_config: &ModelConfig, - session_id: &str, system: &str, messages: &[Message], tools: &[Tool], @@ -257,17 +235,13 @@ impl DatabricksV2Provider { if payload.get("max_tokens").is_none() { payload["max_tokens"] = Value::from(model_config.max_output_tokens()); } - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .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 }) @@ -282,24 +256,26 @@ 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 = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .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 }) @@ -318,7 +294,7 @@ impl DatabricksV2Provider { let message_stream = anthropic::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(|e| ProviderError::RequestFailed(format!("Stream decode error: {e}")))?; + 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); } @@ -326,9 +302,7 @@ impl DatabricksV2Provider { } } -impl ProviderDef for DatabricksV2Provider { - type Provider = Self; - +impl crate::base::ProviderDescriptor for DatabricksV2Provider { fn metadata() -> ProviderMetadata { ProviderMetadata::new( DATABRICKS_V2_PROVIDER_NAME, @@ -343,17 +317,6 @@ impl ProviderDef for DatabricksV2Provider { ], ) } - - fn from_env( - model: ModelConfig, - _extensions: Vec, - ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) - } - - fn supports_inventory_refresh() -> bool { - true - } } #[async_trait] @@ -367,71 +330,80 @@ 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 } } } async fn fetch_supported_models(&self) -> Result, ProviderError> { - let response = self - .api_client - .response_get(None, DATABRICKS_V2_LIST_ENDPOINTS_PATH) - .await - .map_err(|e| { + let mut models = Vec::new(); + let mut page_token: Option = None; + + loop { + let mut path = format!( + "{}?page_size={}", + DATABRICKS_V2_LIST_ENDPOINTS_PATH, DATABRICKS_V2_LIST_ENDPOINTS_PAGE_SIZE + ); + if let Some(token) = &page_token { + path.push_str(&format!("&page_token={}", urlencoding::encode(token))); + } + + 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(); - let detail = response.text().await.unwrap_or_default(); - return Err(ProviderError::RequestFailed(format!( - "Failed to fetch Databricks AI Gateway endpoints: {status} {detail}" - ))); + if !response.status().is_success() { + let status = response.status(); + let detail = response.text().await.unwrap_or_default(); + return Err(ProviderError::RequestFailed(format!( + "Failed to fetch Databricks AI Gateway endpoints: {status} {detail}" + ))); + } + + let json: Value = response.json().await.map_err(|e| { + ProviderError::RequestFailed(format!( + "Failed to parse Databricks AI Gateway endpoints response: {e}" + )) + })?; + + let (page_models, next_page_token) = Self::parse_list_endpoints_response(&json)?; + models.extend(page_models); + + if next_page_token.is_none() || next_page_token == page_token { + break; + } + page_token = next_page_token; } - let json: Value = response.json().await.map_err(|e| { - ProviderError::RequestFailed(format!( - "Failed to parse Databricks AI Gateway endpoints response: {e}" - )) - })?; - - Self::parse_list_endpoints_response(&json) + models.sort(); + Ok(models) } } @@ -470,19 +442,22 @@ mod tests { {"name": "databricks-claude-opus-4-7"}, {"name": "databricks-gpt-5-5"}, {"name": "custom-model"} - ] + ], + "next_page_token": "tok" }); - let models = DatabricksV2Provider::parse_list_endpoints_response(&json).unwrap(); + let (models, next_page_token) = + DatabricksV2Provider::parse_list_endpoints_response(&json).unwrap(); assert_eq!( models, vec![ - "custom-model".to_string(), "databricks-claude-opus-4-7".to_string(), "databricks-gpt-5-5".to_string(), + "custom-model".to_string(), ] ); + assert_eq!(next_page_token.as_deref(), Some("tok")); } #[test] 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-providers/src/declarative/definitions/empiriolabs.json b/crates/goose-providers/src/declarative/definitions/empiriolabs.json new file mode 100644 index 0000000000..f0d6f536df --- /dev/null +++ b/crates/goose-providers/src/declarative/definitions/empiriolabs.json @@ -0,0 +1,53 @@ +{ + "name": "empiriolabs", + "engine": "openai", + "display_name": "EmpirioLabs AI", + "description": "Frontier open and proprietary chat models through one OpenAI-compatible API with streaming support", + "api_key_env": "EMPIRIOLABS_API_KEY", + "base_url": "https://api.empiriolabs.ai/v1/chat/completions", + "models": [ + { + "name": "qwen3-7-plus", + "context_limit": 1000000, + "max_tokens": 65536 + }, + { + "name": "qwen3-7-max", + "context_limit": 1000000, + "max_tokens": 65536 + }, + { + "name": "deepseek-v4-pro", + "context_limit": 1000000, + "max_tokens": 65536 + }, + { + "name": "deepseek-v4-flash", + "context_limit": 1000000, + "max_tokens": 65536 + }, + { + "name": "glm-5-1", + "context_limit": 202000, + "max_tokens": 131072 + }, + { + "name": "kimi-k2-7-code", + "context_limit": 256000, + "max_tokens": 131072 + }, + { + "name": "minimax-m3", + "context_limit": 524000, + "max_tokens": 131072 + } + ], + "supports_streaming": true, + "dynamic_models": false, + "model_doc_link": "https://docs.empiriolabs.ai", + "setup_steps": [ + "Go to https://platform.empiriolabs.ai/dashboard/api-keys", + "Create or copy an existing API key", + "Paste the key above as EMPIRIOLABS_API_KEY" + ] +} diff --git a/crates/goose-providers/src/declarative/definitions/fireworks.json b/crates/goose-providers/src/declarative/definitions/fireworks.json 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 83% rename from crates/goose/src/providers/declarative/groq.json rename to crates/goose-providers/src/declarative/definitions/groq.json index f2a7000abd..6adef5395d 100644 --- a/crates/goose/src/providers/declarative/groq.json +++ b/crates/goose-providers/src/declarative/definitions/groq.json @@ -9,17 +9,17 @@ { "name": "moonshotai/kimi-k2-instruct-0905", "context_limit": 262144, - "max_tokens": 16384 + "max_tokens": 8192 }, { "name": "openai/gpt-oss-120b", "context_limit": 131072, - "max_tokens": 65536 + "max_tokens": 8192 }, { "name": "openai/gpt-oss-20b", "context_limit": 131072, - "max_tokens": 65536 + "max_tokens": 8192 }, { "name": "meta-llama/llama-4-maverick-17b-128e-instruct", @@ -34,22 +34,22 @@ { "name": "qwen/qwen3-32b", "context_limit": 131072, - "max_tokens": 40960 + "max_tokens": 8192 }, { "name": "llama-3.3-70b-versatile", "context_limit": 131072, - "max_tokens": 32768 + "max_tokens": 8192 }, { "name": "llama-3.1-8b-instant", "context_limit": 131072, - "max_tokens": 131072 + "max_tokens": 8192 }, { "name": "openai/gpt-oss-safeguard-20b", "context_limit": 131072, - "max_tokens": 65536 + "max_tokens": 8192 }, { "name": "meta-llama/llama-guard-4-12b", @@ -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 51% rename from crates/goose/src/providers/declarative/moonshot.json rename to crates/goose-providers/src/declarative/definitions/moonshot.json index d21c6faee0..489e8e47a9 100644 --- a/crates/goose/src/providers/declarative/moonshot.json +++ b/crates/goose-providers/src/declarative/definitions/moonshot.json @@ -2,9 +2,18 @@ "name": "moonshot", "engine": "openai", "display_name": "Moonshot", - "description": "Moonshot AI (Kimi) models", + "description": "Moonshot AI (Kimi) models. Set MOONSHOT_BASE_URL to override the default endpoint (e.g. https://api.moonshot.ai/v1 for international users).", "api_key_env": "MOONSHOT_API_KEY", - "base_url": "https://api.moonshot.cn/v1/chat/completions", + "base_url": "${MOONSHOT_BASE_URL}", + "env_vars": [ + { + "name": "MOONSHOT_BASE_URL", + "required": false, + "secret": false, + "default": "https://api.moonshot.cn/v1/chat/completions", + "description": "Moonshot API base URL. Use https://api.moonshot.ai/v1 for international access outside China Mainland." + } + ], "models": [ {"name": "kimi-latest", "context_limit": 131072}, {"name": "kimi-thinking-preview", "context_limit": 131072}, 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-providers/src/declarative/definitions/orcarouter.json b/crates/goose-providers/src/declarative/definitions/orcarouter.json new file mode 100644 index 0000000000..fa830284b0 --- /dev/null +++ b/crates/goose-providers/src/declarative/definitions/orcarouter.json @@ -0,0 +1,31 @@ +{ + "name": "orcarouter", + "engine": "openai", + "display_name": "OrcaRouter", + "description": "Adaptive router for OpenAI, Anthropic, Google, xAI, DeepSeek, and 150+ other frontier models through a single OpenAI-compatible API.", + "api_key_env": "ORCAROUTER_API_KEY", + "base_url": "https://api.orcarouter.ai/v1", + "catalog_provider_id": "orcarouter", + "headers": { + "http-referer": "https://goose-docs.ai", + "x-title": "goose" + }, + "models": [ + { "name": "anthropic/claude-sonnet-4.6", "context_limit": 200000 }, + { "name": "openai/gpt-5.5", "context_limit": 400000 }, + { "name": "google/gemini-3-flash-preview", "context_limit": 1000000 }, + { "name": "anthropic/claude-opus-4.7", "context_limit": 200000 }, + { "name": "grok/grok-4.3", "context_limit": 256000 }, + { "name": "deepseek/deepseek-v4-pro", "context_limit": 163840 }, + { "name": "orcarouter/auto", "context_limit": 128000 } + ], + "dynamic_models": true, + "supports_streaming": true, + "preserves_thinking": true, + "model_doc_link": "https://www.orcarouter.ai/models", + "setup_steps": [ + "Sign up at https://www.orcarouter.ai", + "Create an API key in the console", + "Paste the key above as ORCAROUTER_API_KEY" + ] +} diff --git a/crates/goose/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-providers/src/declarative/definitions/together.json b/crates/goose-providers/src/declarative/definitions/together.json new file mode 100644 index 0000000000..203fe96717 --- /dev/null +++ b/crates/goose-providers/src/declarative/definitions/together.json @@ -0,0 +1,49 @@ +{ + "name": "together", + "engine": "openai", + "display_name": "Together AI", + "description": "Open-source and custom AI models via Together AI's OpenAI-compatible API.", + "api_key_env": "TOGETHER_API_KEY", + "base_url": "https://api.together.xyz/v1", + "catalog_provider_id": "togetherai", + "dynamic_models": true, + "models": [ + { + "name": "meta-llama/Llama-3.3-70B-Instruct-Turbo", + "context_limit": 131072 + }, + { + "name": "deepseek-ai/DeepSeek-V3", + "context_limit": 131072 + }, + { + "name": "deepseek-ai/DeepSeek-R1", + "context_limit": 163839 + }, + { + "name": "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8", + "context_limit": 262144 + }, + { + "name": "Qwen/Qwen3-235B-A22B-Instruct-2507-tput", + "context_limit": 262144 + }, + { + "name": "moonshotai/Kimi-K2.5", + "context_limit": 262144 + }, + { + "name": "openai/gpt-oss-120b", + "context_limit": 131072 + } + ], + "preserves_thinking": true, + "supports_streaming": true, + "model_doc_link": "https://docs.together.ai/docs/inference-models", + "setup_steps": [ + "Sign in to https://api.together.xyz", + "Navigate to API Keys in your account settings", + "Create a new API key", + "Copy the key and paste it above" + ] +} diff --git a/crates/goose/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/src/providers/google.rs b/crates/goose-providers/src/google.rs similarity index 65% rename from crates/goose/src/providers/google.rs rename to crates/goose-providers/src/google.rs index ba272dd9fa..934a488ccd 100644 --- a/crates/goose/src/providers/google.rs +++ b/crates/goose-providers/src/google.rs @@ -1,19 +1,17 @@ -use super::api_client::{ApiClient, AuthMethod}; -use super::base::MessageStream; -use super::errors::ProviderError; -use super::openai_compatible::{handle_status, map_http_error_to_provider_error, sanitize_url}; -use super::retry::ProviderRetry; -use super::utils::RequestLog; +use crate::api_client::{ApiClient, AuthMethod}; +use crate::base::MessageStream; use crate::conversation::message::Message; +use crate::errors::ProviderError; +use crate::openai_compatible::{handle_status, map_http_error_to_provider_error, sanitize_url}; +use crate::retry::ProviderRetry; +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::providers::base::{ConfigKey, Provider, ProviderDef, ProviderMetadata}; -use crate::providers::formats::google::{create_request, response_to_streaming_message}; -use crate::providers::inventory::{config_secret_value, InventoryIdentityInput}; +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 rmcp::model::Tool; use serde_json::Value; @@ -23,7 +21,7 @@ use tokio_stream::StreamExt; use tokio_util::codec::{FramedRead, LinesCodec}; use tokio_util::io::StreamReader; -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"; @@ -61,54 +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) -> Result { - let model = model.with_fast(GOOGLE_DEFAULT_FAST_MODEL, GOOGLE_PROVIDER_NAME)?; - - 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()); - + pub fn new( + host: String, + api_key: String, + tls_config: Option, + request_builder: Option, + thinking_budget: Option, + ) -> Result { let auth = AuthMethod::ApiKey { header_name: "x-goog-api-key".to_string(), key: api_key, }; - let api_client = - ApiClient::new(host, auth)?.with_header("Content-Type", "application/json")?; + 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 ProviderDef for GoogleProvider { - type Provider = Self; - +impl crate::base::ProviderDescriptor for GoogleProvider { fn metadata() -> ProviderMetadata { ProviderMetadata::new( GOOGLE_PROVIDER_NAME, @@ -122,6 +116,7 @@ impl ProviderDef 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", @@ -129,34 +124,6 @@ impl ProviderDef for GoogleProvider { "Copy the key and paste it above", ]) } - - fn from_env( - model: ModelConfig, - _extensions: Vec, - ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) - } - - fn supports_inventory_refresh() -> bool { - true - } - - fn inventory_identity() -> Result { - let config = crate::config::Config::global(); - let mut identity = InventoryIdentityInput::new(GOOGLE_PROVIDER_NAME, GOOGLE_PROVIDER_NAME) - .with_public( - "host", - config - .get_param::("GOOGLE_HOST") - .unwrap_or_else(|_| GOOGLE_API_HOST.to_string()), - ); - - if let Some(api_key) = config_secret_value(config, "GOOGLE_API_KEY") { - identity = identity.with_secret("api_key", api_key); - } - - Ok(identity) - } } #[async_trait] @@ -165,14 +132,10 @@ impl Provider for GoogleProvider { &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(); @@ -204,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 mut log = RequestLog::start(model_config, &payload)?; + 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); @@ -232,9 +197,10 @@ impl Provider for GoogleProvider { let message_stream = response_to_streaming_message(framed); pin!(message_stream); while let Some(message) = message_stream.next().await { - let (message, usage) = message.map_err(|e| - ProviderError::RequestFailed(format!("Stream decode error: {}", e)) - )?; + let (message, usage) = message.map_err(|e| { + e.downcast::() + .unwrap_or_else(ProviderError::stream_decode_error) + })?; if message.is_some() || usage.is_some() { log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?; } diff --git a/crates/goose/src/providers/http_status.rs b/crates/goose-providers/src/http_status.rs similarity index 81% rename from crates/goose/src/providers/http_status.rs rename to crates/goose-providers/src/http_status.rs index 279b5bc0e9..13147ccadd 100644 --- a/crates/goose/src/providers/http_status.rs +++ b/crates/goose-providers/src/http_status.rs @@ -6,13 +6,12 @@ use std::time::{Duration, SystemTime}; +use crate::errors::ProviderError; use chrono::{DateTime, NaiveDateTime, TimeZone, Utc}; use reqwest::header::{HeaderMap, RETRY_AFTER}; use reqwest::{Response, StatusCode}; use serde_json::Value; -use super::errors::ProviderError; - /// Strip credentials and sensitive query parameters from a URL for safe /// inclusion in error messages and logs. Drops userinfo (`user:pass@`) and /// all query parameters (which may contain API keys like `?key=...`). @@ -108,25 +107,75 @@ fn parse_http_date(value: &str) -> Option { None } -fn check_context_length_exceeded(text: &str) -> bool { - let check_phrases = [ - "too long", +pub fn is_context_length_exceeded_message(text: &str) -> bool { + let text_lower = text.to_lowercase(); + + let direct_context_phrases = [ "context length", "context_length_exceeded", - "reduce the length", - "token count", - "exceeds", - "exceed context limit", - "input length", - "max_tokens", - "decrease input length", + "context window", + "context_window_exceeded", "context limit", + "maximum context", + "max context", "maximum prompt length", + "max prompt length", ]; - let text_lower = text.to_lowercase(); - check_phrases + if direct_context_phrases .iter() .any(|phrase| text_lower.contains(phrase)) + { + return true; + } + + if text_lower.contains("reduce the length") + && ["message", "messages", "input", "prompt"] + .iter() + .any(|word| text_lower.contains(word)) + { + return true; + } + + if [ + "input is too long", + "input too long", + "prompt is too long", + "prompt too long", + ] + .iter() + .any(|phrase| text_lower.contains(phrase)) + { + return true; + } + + let mentions_prompt_input_tokens = [ + "input token", + "input length", + "prompt token", + "prompt length", + "message token", + "messages token", + "request token", + "total token", + ] + .iter() + .any(|phrase| text_lower.contains(phrase)); + let mentions_limit = [ + "model limit", + "model's limit", + "maximum allowed", + "max allowed", + "maximum number of tokens", + "token limit", + "tokens limit", + ] + .iter() + .any(|phrase| text_lower.contains(phrase)); + let mentions_overflow = ["exceed", "too long", "too large", "over the limit"] + .iter() + .any(|phrase| text_lower.contains(phrase)); + + mentions_prompt_input_tokens && mentions_limit && mentions_overflow } pub fn map_http_error_to_provider_error( @@ -165,7 +214,7 @@ pub fn map_http_error_to_provider_error( StatusCode::PAYLOAD_TOO_LARGE => ProviderError::ContextLengthExceeded(extract_message()), StatusCode::BAD_REQUEST => { let payload_str = extract_message(); - if check_context_length_exceeded(&payload_str) { + if is_context_length_exceeded_message(&payload_str) { ProviderError::ContextLengthExceeded(payload_str) } else { ProviderError::RequestFailed(format!("Bad request (400): {}", payload_str)) @@ -343,4 +392,42 @@ mod tests { let payload = json!({ "error": { "metadata": { "retry_after_seconds": f64::INFINITY } } }); assert!(extract_retry_after(&empty_headers(), Some(&payload)).is_none()); } + + #[test] + fn context_length_classifier_accepts_context_window_errors() { + let messages = [ + "This request exceeds the maximum context length", + "context_length_exceeded", + "context window exceeded", + "Input token count exceeds the maximum number of tokens allowed", + "Please reduce the length of the messages", + "prompt is too long for this model", + ]; + + for message in messages { + assert!( + is_context_length_exceeded_message(message), + "expected context-length match for: {message}" + ); + } + } + + #[test] + fn context_length_classifier_rejects_generic_bad_request_errors() { + let messages = [ + "max_tokens must be less than or equal to 4096", + "Requested max_tokens exceeds the model output limit", + "Current token count exceeds your organization quota", + "temperature exceeds maximum allowed value", + "schema is too long", + "metadata length exceeds maximum allowed", + ]; + + for message in messages { + assert!( + !is_context_length_exceeded_message(message), + "expected generic bad request for: {message}" + ); + } + } } diff --git a/crates/goose-providers/src/lib.rs b/crates/goose-providers/src/lib.rs new file mode 100644 index 0000000000..77e011b176 --- /dev/null +++ b/crates/goose-providers/src/lib.rs @@ -0,0 +1,21 @@ +pub mod anthropic; +pub mod api_client; +pub mod databricks; +pub mod databricks_auth; +pub mod databricks_v2; +pub mod google; +pub use goose_provider_types::{ + base, canonical, conversation, errors, formats, goose_mode, images, json, model, permission, + request_log, retry, thinking, utils, +}; +pub mod declarative; +pub mod http_status; +#[cfg(feature = "local-inference")] +pub mod local_inference; +pub mod ollama; +pub mod openai; +pub mod openai_compatible; + +pub use declarative::declarative_providers::*; + +pub mod snowflake; diff --git a/crates/goose-providers/src/local_inference.rs b/crates/goose-providers/src/local_inference.rs 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 new file mode 100644 index 0000000000..3c920ca144 --- /dev/null +++ b/crates/goose-providers/src/openai.rs @@ -0,0 +1,1220 @@ +use super::api_client::ApiClient; +use super::base::{ConfigKey, ModelInfo, Provider, ProviderMetadata}; +use super::retry::ProviderRetry; +use crate::api_client::{AuthMethod, TlsConfig}; +use crate::conversation::message::Message; +use crate::conversation::token_usage::{CostSource, ProviderUsage}; +use crate::declarative::{DeclarativeProviderConfig, KeyResolver}; +use crate::errors::ProviderError; +use crate::formats::openai::is_openai_responses_model; +use crate::formats::openai::{ + create_request_with_options, get_cost, get_usage, response_to_message, OpenAiFormatOptions, +}; +use crate::formats::openai_responses::{ + create_responses_request, get_responses_usage, responses_api_to_message, ResponsesApiResponse, +}; +use crate::images::ImageFormat; +use crate::openai_compatible::{ + handle_response_openai_compat, handle_status, stream_openai_compat, stream_responses_compat, +}; +use crate::request_log::{start_log, LoggerHandleExt}; +use anyhow::Result; +use async_trait::async_trait; +use reqwest::StatusCode; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use crate::base::{MessageStream, ProviderDescriptor}; +use crate::model::ModelConfig; +use rmcp::model::Tool; + +pub const OPEN_AI_PROVIDER_NAME: &str = "openai"; +pub const OPEN_AI_DEFAULT_BASE_PATH: &str = "v1/chat/completions"; +pub const OPEN_AI_VERSIONLESS_BASE_PATH: &str = "chat/completions"; +const OPEN_AI_DEFAULT_RESPONSES_PATH: &str = "v1/responses"; +const OPEN_AI_DEFAULT_MODELS_PATH: &str = "v1/models"; +pub const OPEN_AI_DEFAULT_MODEL: &str = "gpt-4o"; +pub const OPEN_AI_DEFAULT_FAST_MODEL: &str = "gpt-4o-mini"; +pub const OPEN_AI_KNOWN_MODELS: &[(&str, usize)] = &[ + ("gpt-4o", 128_000), + ("gpt-4o-mini", 128_000), + ("gpt-4.1", 128_000), + ("gpt-4.1-mini", 128_000), + ("o1", 200_000), + ("o3", 200_000), + ("gpt-3.5-turbo", 16_385), + ("gpt-4-turbo", 128_000), + ("o4-mini", 128_000), + ("gpt-5", 400_000), + ("gpt-5-mini", 400_000), + ("gpt-5-nano", 400_000), + ("gpt-5-pro", 400_000), + ("gpt-5-codex", 400_000), + ("gpt-5.1", 400_000), + ("gpt-5.1-codex", 400_000), + ("gpt-5.2", 400_000), + ("gpt-5.2-codex", 400_000), + ("gpt-5.2-pro", 400_000), + ("gpt-5.3-codex", 400_000), + ("gpt-5.4", 1_050_000), + ("gpt-5.4-mini", 400_000), + ("gpt-5.4-nano", 400_000), + ("gpt-5.4-pro", 1_050_000), + ("gpt-5.5", 1_050_000), + ("gpt-5.5-pro", 1_050_000), + ("gpt-5.6-luna", 1_050_000), + ("gpt-5.6-sol", 1_050_000), + ("gpt-5.6-terra", 1_050_000), +]; + +pub const OPEN_AI_DOC_URL: &str = "https://platform.openai.com/docs/models"; +const DEFAULT_TIMEOUT_SECONDS: u64 = 600; + +type OpenAiBaseUrlParts = (String, Vec<(String, String)>, bool); + +/// Ensure a base URL has an explicit scheme. +/// +/// Users frequently enter hosts like `localhost:1234` without a scheme. The +/// `url` crate parses such input as `scheme="localhost"`, `path="1234"`, +/// silently dropping both the host and the port. When no `://` is present we +/// prepend a sensible scheme (`http://` for local hosts, `https://` +/// otherwise) so the host and port survive parsing. +pub fn ensure_url_scheme(raw_url: &str) -> String { + let trimmed = raw_url.trim(); + if trimmed.contains("://") { + return trimmed.to_string(); + } + + let host_part = trimmed.split(['/', '?']).next().unwrap_or(trimmed); + let bare_host = if let Some(rest) = host_part.strip_prefix('[') { + rest.split(']').next().unwrap_or(rest) + } else { + host_part.split(':').next().unwrap_or(host_part) + }; + let is_local = bare_host == "localhost" + || bare_host == "127.0.0.1" + || bare_host == "0.0.0.0" + || bare_host == "::1"; + + let scheme = if is_local { "http" } else { "https" }; + format!("{}://{}", scheme, trimmed) +} + +pub fn parse_openai_base_url(raw_url: &str) -> Result { + let raw_url = ensure_url_scheme(raw_url); + let raw_url = raw_url.as_str(); + let parsed = url::Url::parse(raw_url) + .map_err(|e| anyhow::anyhow!("Invalid OPENAI_BASE_URL '{}': {}", raw_url, e))?; + + let authority = parsed[..url::Position::BeforePath].to_string(); + let query_params: Vec<(String, String)> = parsed + .query_pairs() + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect(); + + let path = parsed.path().trim_end_matches('/'); + if path.is_empty() || path == "/" { + return Ok((authority, query_params, true)); + } + + if path == "/v1" { + return Ok((authority, query_params, true)); + } + if let Some(prefix) = path.strip_suffix("/v1") { + return Ok((format!("{}{}", authority, prefix), query_params, true)); + } + + Ok((format!("{}{}", authority, path), query_params, false)) +} + +#[derive(Debug, serde::Serialize)] +pub struct OpenAiProvider { + #[serde(skip)] + api_client: ApiClient, + base_path: String, + organization: Option, + project: Option, + custom_headers: Option>, + supports_streaming: bool, + name: String, + custom_models: Option>, + dynamic_models: Option, + skip_canonical_filtering: bool, + preserve_thinking_context: bool, + #[serde(skip)] + n_ctx_cache: Arc>>>, +} + +/// Builder for [`OpenAiProvider`]. +/// +/// Exposes every field of the provider so that constructors living outside +/// `openai.rs` (e.g. in `openai_def.rs`) can assemble a provider without +/// needing direct access to the struct's private fields. +pub struct OpenAiProviderBuilder { + api_client: ApiClient, + base_path: String, + organization: Option, + project: Option, + custom_headers: Option>, + supports_streaming: bool, + name: String, + custom_models: Option>, + dynamic_models: Option, + skip_canonical_filtering: bool, + preserve_thinking_context: bool, +} + +impl OpenAiProviderBuilder { + pub fn new(api_client: ApiClient) -> Self { + Self { + api_client, + base_path: OPEN_AI_DEFAULT_BASE_PATH.to_string(), + organization: None, + project: None, + custom_headers: None, + supports_streaming: true, + name: OPEN_AI_PROVIDER_NAME.to_string(), + custom_models: None, + dynamic_models: None, + skip_canonical_filtering: false, + preserve_thinking_context: false, + } + } + + pub fn api_client(mut self, api_client: ApiClient) -> Self { + self.api_client = api_client; + self + } + + pub fn map_api_client(mut self, f: impl FnOnce(ApiClient) -> ApiClient) -> Self { + self.api_client = f(self.api_client); + self + } + + pub fn try_map_api_client( + mut self, + f: impl FnOnce(ApiClient) -> Result, + ) -> Result { + self.api_client = f(self.api_client)?; + Ok(self) + } + + pub fn base_path(mut self, base_path: impl Into) -> Self { + self.base_path = base_path.into(); + self + } + + pub fn organization(mut self, organization: Option) -> Self { + self.organization = organization; + self + } + + pub fn project(mut self, project: Option) -> Self { + self.project = project; + self + } + + pub fn custom_headers(mut self, custom_headers: Option>) -> Self { + self.custom_headers = custom_headers; + self + } + + pub fn supports_streaming(mut self, supports_streaming: bool) -> Self { + self.supports_streaming = supports_streaming; + self + } + + pub fn name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + + pub fn custom_models(mut self, custom_models: Option>) -> Self { + self.custom_models = custom_models; + self + } + + pub fn dynamic_models(mut self, dynamic_models: Option) -> Self { + self.dynamic_models = dynamic_models; + self + } + + pub fn skip_canonical_filtering(mut self, skip_canonical_filtering: bool) -> Self { + self.skip_canonical_filtering = skip_canonical_filtering; + self + } + + pub fn preserve_thinking_context(mut self, preserve_thinking_context: bool) -> Self { + self.preserve_thinking_context = preserve_thinking_context; + self + } + + pub fn build(self) -> OpenAiProvider { + OpenAiProvider { + api_client: self.api_client, + base_path: self.base_path, + organization: self.organization, + project: self.project, + custom_headers: self.custom_headers, + supports_streaming: self.supports_streaming, + name: self.name, + custom_models: self.custom_models, + dynamic_models: self.dynamic_models, + skip_canonical_filtering: self.skip_canonical_filtering, + preserve_thinking_context: self.preserve_thinking_context, + n_ctx_cache: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +impl OpenAiProvider { + #[doc(hidden)] + pub fn new(api_client: ApiClient) -> Self { + Self { + api_client, + base_path: OPEN_AI_DEFAULT_BASE_PATH.to_string(), + organization: None, + project: None, + custom_headers: None, + supports_streaming: true, + name: OPEN_AI_PROVIDER_NAME.to_string(), + custom_models: None, + dynamic_models: None, + skip_canonical_filtering: false, + preserve_thinking_context: false, + n_ctx_cache: Arc::new(Mutex::new(HashMap::new())), + } + } + + fn normalize_base_path(base_path: &str) -> String { + if let Some(path) = base_path.strip_prefix('/') { + format!("/{}", path.trim_end_matches('/')) + } else { + base_path.trim_end_matches('/').to_string() + } + } + + fn is_chat_completions_path(base_path: &str) -> bool { + let normalized = Self::normalize_base_path(base_path).to_ascii_lowercase(); + normalized.contains("chat/completions") + } + + fn is_responses_path(base_path: &str) -> bool { + let normalized = Self::normalize_base_path(base_path).to_ascii_lowercase(); + normalized.ends_with("responses") || normalized.contains("/responses") + } + + fn is_responses_model(model_name: &str) -> bool { + is_openai_responses_model(model_name) + } + + fn should_use_responses_api(model_name: &str, base_path: &str) -> bool { + let normalized_base_path = Self::normalize_base_path(base_path); + // Only the standard "v1/chat/completions" is treated as a default + // path that defers to model-based routing. The versionless + // "chat/completions" (derived from an OPENAI_BASE_URL without /v1) + // is treated as custom because versionless gateways typically do not + // support the Responses API. + let has_custom_base_path = normalized_base_path != OPEN_AI_DEFAULT_BASE_PATH; + + if has_custom_base_path { + if Self::is_responses_path(&normalized_base_path) { + return true; + } + if Self::is_chat_completions_path(&normalized_base_path) { + return false; + } + } + + Self::is_responses_model(model_name) + } + + /// Providers known to reject `max_completion_tokens` and require + /// the legacy `max_tokens` field instead. + const PROVIDERS_NEEDING_MAX_TOKENS_REMAP: &[&str] = &[ + "cerebras", + "custom_deepseek", + "groq", + "inception", + "kimi", + "lmstudio", + "mistral", + "moonshot", + "nearai", + "ovhcloud", + ]; + + const PROVIDERS_NEEDING_STANDARD_CHAT_PARAMS: &[&str] = &["nearai"]; + + fn sanitize_request_for_compat(&self, mut payload: serde_json::Value) -> serde_json::Value { + if let Some(obj) = payload.as_object_mut() { + if Self::PROVIDERS_NEEDING_MAX_TOKENS_REMAP.contains(&self.name.as_str()) { + if let Some(value) = obj.remove("max_completion_tokens") { + obj.entry("max_tokens").or_insert(value); + } + } + + if Self::PROVIDERS_NEEDING_STANDARD_CHAT_PARAMS.contains(&self.name.as_str()) { + let model_name = obj.get("model").and_then(|model| model.as_str()); + if !model_name.is_some_and(Self::is_responses_model) { + obj.remove("reasoning_effort"); + } + + if let Some(messages) = obj.get_mut("messages").and_then(|m| m.as_array_mut()) { + for message in messages { + if message + .get("role") + .and_then(|role| role.as_str()) + .is_some_and(|role| role == "developer") + { + message["role"] = serde_json::Value::String("system".to_string()); + } + } + } + } + } + + payload + } + + fn should_use_responses_api_for_provider(&self, model_name: &str) -> bool { + if Self::PROVIDERS_NEEDING_STANDARD_CHAT_PARAMS.contains(&self.name.as_str()) { + return false; + } + + Self::should_use_responses_api(model_name, &self.base_path) + } + + fn map_base_path(base_path: &str, target: &str, fallback: &str) -> String { + let normalized = Self::normalize_base_path(base_path); + if normalized.ends_with(target) || normalized.contains(&format!("/{target}")) { + return normalized; + } + + if Self::is_chat_completions_path(&normalized) { + return normalized.replacen("chat/completions", target, 1); + } + + if Self::is_responses_path(&normalized) { + return normalized.replacen("responses", target, 1); + } + + if normalized.starts_with('/') { + format!("/{}", fallback.trim_start_matches('/')) + } else { + fallback.to_string() + } + } + + async fn fetch_models_from_api(&self) -> Result, ProviderError> { + let models_path = + Self::map_base_path(&self.base_path, "models", OPEN_AI_DEFAULT_MODELS_PATH); + let response = self.api_client.request(&models_path).response_get().await?; + + if response.status() == StatusCode::NOT_FOUND { + let body = response.text().await.unwrap_or_default(); + return Err(ProviderError::EndpointNotFound(body)); + } + + let json = handle_response_openai_compat(response).await?; + if let Some(err_obj) = json.get("error") { + let msg = err_obj + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + return Err(ProviderError::Authentication(msg.to_string())); + } + + let data = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| { + ProviderError::UsageError("Missing data field in JSON response".into()) + })?; + let mut models: Vec = data + .iter() + .filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(str::to_string)) + .collect(); + models.sort(); + Ok(models) + } + + /// llama.cpp and Ollama expose the actual allocated context window in the + /// non-standard `meta.n_ctx` field of `/v1/models`. Returns `None` when absent + /// (e.g. real OpenAI). + async fn fetch_n_ctx_from_api(&self, model_name: &str) -> Option { + let models_path = + Self::map_base_path(&self.base_path, "models", OPEN_AI_DEFAULT_MODELS_PATH); + let response = self + .api_client + .request(&models_path) + .response_get() + .await + .ok()?; + let json = handle_response_openai_compat(response).await.ok()?; + parse_n_ctx_from_models(&json, model_name) + } +} + +/// Extract `meta.n_ctx` for `model_name` from a `/v1/models` response body. +fn parse_n_ctx_from_models(json: &serde_json::Value, model_name: &str) -> Option { + let data = json.get("data")?.as_array()?; + + let n_ctx = |entry: &serde_json::Value| -> Option { + entry + .get("meta")? + .get("n_ctx")? + .as_u64() + .map(|v| v as usize) + }; + + if let Some(entry) = data + .iter() + .find(|e| e.get("id").and_then(|v| v.as_str()) == Some(model_name)) + { + return n_ctx(entry); + } + + // For single-model servers without --alias, llama.cpp reports the loaded model + // file path as id rather than the client's alias, so no entry matches above. + // Fall back to the sole entry's n_ctx. + match data.as_slice() { + [only] => n_ctx(only), + _ => None, + } +} + +impl ProviderDescriptor for OpenAiProvider { + fn metadata() -> ProviderMetadata { + let models = OPEN_AI_KNOWN_MODELS + .iter() + .map(|(name, limit)| ModelInfo::new(*name, *limit)) + .collect(); + ProviderMetadata::with_models( + OPEN_AI_PROVIDER_NAME, + "OpenAI", + "GPT-4 and other OpenAI models, including OpenAI compatible ones", + OPEN_AI_DEFAULT_MODEL, + models, + OPEN_AI_DOC_URL, + vec![ + ConfigKey::new("OPENAI_API_KEY", false, true, None, true), + ConfigKey::new("OPENAI_BASE_URL", false, false, None, false), + ConfigKey::new( + "OPENAI_HOST", + true, + false, + Some("https://api.openai.com"), + false, + ), + ConfigKey::new( + "OPENAI_BASE_PATH", + true, + false, + Some("v1/chat/completions"), + false, + ), + ConfigKey::new("OPENAI_ORGANIZATION", false, false, None, false), + ConfigKey::new("OPENAI_PROJECT", false, false, None, false), + ConfigKey::new("OPENAI_CUSTOM_HEADERS", false, true, None, false), + ConfigKey::new("OPENAI_TIMEOUT", false, false, Some("600"), false), + ], + ) + .with_setup_steps(vec![ + "Go to https://platform.openai.com and sign up or log in", + "Navigate to API Keys in the left sidebar", + "Click 'Create new secret key'", + "Copy the key and paste it above", + ]) + } +} + +#[async_trait] +impl Provider for OpenAiProvider { + fn get_name(&self) -> &str { + &self.name + } + + fn skip_canonical_filtering(&self) -> bool { + self.skip_canonical_filtering + } + + /// Resolve the effective context limit. When the config carries an explicit + /// limit (GOOSE_CONTEXT_LIMIT, a session override, or a known/canonical + /// value) it is used as-is. Otherwise probe `/v1/models`: llama.cpp and + /// Ollama report the real allocated window via the non-standard + /// `meta.n_ctx` field, which fixes auto-compaction for local servers that + /// would otherwise fall back to DEFAULT_CONTEXT_LIMIT. The probe is bounded + /// by a short timeout so a hung endpoint can't stall the caller. + async fn get_context_limit(&self, model_config: &ModelConfig) -> Result { + if let Some(limit) = model_config.context_limit { + return Ok(limit); + } + + if let Some(cached) = self + .n_ctx_cache + .lock() + .ok() + .and_then(|cache| cache.get(&model_config.model_name).copied()) + { + return Ok(cached.unwrap_or_else(|| model_config.context_limit())); + } + + const N_CTX_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + let probed = tokio::time::timeout( + N_CTX_PROBE_TIMEOUT, + self.fetch_n_ctx_from_api(&model_config.model_name), + ) + .await + .ok() + .flatten(); + + if let Ok(mut cache) = self.n_ctx_cache.lock() { + cache.insert(model_config.model_name.clone(), probed); + } + + Ok(probed.unwrap_or_else(|| model_config.context_limit())) + } + + async fn fetch_supported_models(&self) -> Result, ProviderError> { + if let Some(custom_models) = &self.custom_models { + if self.dynamic_models == Some(false) { + return Ok(custom_models.clone()); + } + match self.fetch_models_from_api().await { + Ok(models) => return Ok(models), + Err(e) if e.is_endpoint_not_found() => { + tracing::debug!( + "Models endpoint not implemented for provider '{}' ({}), using predefined list", + self.name, + e + ); + return Ok(custom_models.clone()); + } + Err(e) => return Err(e), + } + } + + self.fetch_models_from_api().await + } + + async fn stream( + &self, + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result { + if self.should_use_responses_api_for_provider(&model_config.model_name) { + let mut payload = create_responses_request(model_config, system, messages, tools)?; + payload["stream"] = serde_json::Value::Bool(self.supports_streaming); + + let mut log = start_log(model_config, &payload)?; + + let response = self + .with_retry(|| async { + let payload_clone = payload.clone(); + let resp = self + .api_client + .response_post( + &Self::map_base_path( + &self.base_path, + "responses", + OPEN_AI_DEFAULT_RESPONSES_PATH, + ), + &payload_clone, + ) + .await?; + handle_status(resp).await + }) + .await + .inspect_err(|e| { + let _ = log.error(e); + })?; + + if self.supports_streaming { + stream_responses_compat(response, log) + } else { + let json: serde_json::Value = response.json().await.map_err(|e| { + ProviderError::RequestFailed(format!("Failed to parse JSON: {}", e)) + })?; + + let responses_api_response: ResponsesApiResponse = + serde_json::from_value(json.clone()).map_err(|e| { + ProviderError::ExecutionError(format!( + "Failed to parse responses API response: {}", + e + )) + })?; + + let message = responses_api_to_message(&responses_api_response)?; + let usage_data = get_responses_usage(&responses_api_response); + let usage_json = json.get("usage").unwrap_or(&serde_json::Value::Null); + let mut usage = ProviderUsage::new(model_config.model_name.clone(), usage_data); + if let Some(cost) = get_cost(usage_json) { + usage = usage.with_cost(cost, CostSource::ProviderReported); + } + + log.write( + &serde_json::to_value(&message).unwrap_or_default(), + Some(&usage_data), + )?; + + Ok(super::base::stream_from_single_message(message, usage)) + } + } else { + let payload = create_request_with_options( + model_config, + system, + messages, + tools, + &ImageFormat::OpenAi, + self.supports_streaming, + OpenAiFormatOptions { + preserve_thinking_context: self.preserve_thinking_context, + }, + )?; + let payload = self.sanitize_request_for_compat(payload); + let mut log = start_log(model_config, &payload)?; + + let response = self + .with_retry(|| async { + let resp = self + .api_client + .response_post(&self.base_path, &payload) + .await?; + handle_status(resp).await + }) + .await + .inspect_err(|e| { + let _ = log.error(e); + })?; + + if self.supports_streaming { + stream_openai_compat(response, log) + } else { + let json: serde_json::Value = response.json().await.map_err(|e| { + ProviderError::RequestFailed(format!("Failed to parse JSON: {}", e)) + })?; + + let message = response_to_message(&json).map_err(|e| { + ProviderError::RequestFailed(format!("Failed to parse message: {}", e)) + })?; + + let usage_json = json.get("usage").unwrap_or(&serde_json::Value::Null); + let usage_data = get_usage(usage_json); + let mut usage = ProviderUsage::new(model_config.model_name.clone(), usage_data); + if let Some(cost) = get_cost(usage_json) { + usage = usage.with_cost(cost, CostSource::ProviderReported); + } + + log.write( + &serde_json::to_value(&message).unwrap_or_default(), + Some(&usage_data), + )?; + + Ok(super::base::stream_from_single_message(message, usage)) + } + } + } +} + +pub fn from_declarative_config( + config: DeclarativeProviderConfig, + tls_config: Option, + key_resolver: impl KeyResolver, +) -> Result { + let custom_models = if !config.models.is_empty() { + Some( + config + .models + .iter() + .map(|m| m.name.clone()) + .collect::>(), + ) + } else { + None + }; + + if config.dynamic_models == Some(false) && custom_models.is_none() { + return Err(anyhow::anyhow!( + "Provider '{}' has dynamic_models: false but no static models listed; \ + at least one entry in `models` is required.", + config.name + )); + } + + let api_key = if config.api_key_env.is_empty() { + None + } else { + match key_resolver.resolve_key(config.api_key_env.as_str()) { + Ok(key) => Some(key), + Err(err) => { + if config.requires_auth { + anyhow::bail!("missing required key {}: {}", config.api_key_env, err); + } + None + } + } + }; + + let normalized_base_url = ensure_url_scheme(&config.base_url); + let url = url::Url::parse(&normalized_base_url) + .map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?; + + let host = url[..url::Position::BeforePath].to_string(); + let base_path = if let Some(ref explicit_path) = config.base_path { + explicit_path.trim_start_matches('/').to_string() + } else { + derive_base_path(url.path()) + }; + + let timeout_secs = config.timeout_seconds.unwrap_or(DEFAULT_TIMEOUT_SECONDS); + + let auth = match api_key { + Some(key) if !key.is_empty() => AuthMethod::BearerToken(key), + _ => AuthMethod::NoAuth, + }; + let mut api_client = ApiClient::with_timeout_and_tls( + host, + auth, + std::time::Duration::from_secs(timeout_secs), + tls_config, + )?; + + if let Some(query) = url.query() { + let query_params = url::form_urlencoded::parse(query.as_bytes()) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect(); + api_client = api_client.with_query(query_params); + } + + if let Some(headers) = &config.headers { + let mut header_map = reqwest::header::HeaderMap::new(); + for (key, value) in headers { + let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?; + let header_value = reqwest::header::HeaderValue::from_str(value)?; + header_map.insert(header_name, header_value); + } + api_client = api_client.with_headers(header_map)?; + } + + Ok(OpenAiProviderBuilder::new(api_client) + .base_path(base_path) + .custom_headers(config.headers) + .supports_streaming(config.supports_streaming.unwrap_or(true)) + .name(config.name.clone()) + .custom_models(custom_models) + .dynamic_models(config.dynamic_models) + .skip_canonical_filtering(config.skip_canonical_filtering) + .preserve_thinking_context(config.preserves_thinking)) +} + +pub fn parse_custom_headers(s: String) -> HashMap { + s.split(',') + .filter_map(|header| { + let mut parts = header.splitn(2, '='); + let key = parts.next().map(|s| s.trim().to_string())?; + let value = parts.next().map(|s| s.trim().to_string())?; + Some((key, value)) + }) + .collect() +} + +pub fn derive_base_path(url_path: &str) -> String { + let stripped = url_path.trim_start_matches('/'); + let normalized = stripped.trim_end_matches('/'); + if normalized.is_empty() { + "v1/chat/completions".to_string() + } else if normalized.ends_with("chat/completions") { + stripped.to_string() + } else if ends_with_version_segment(normalized) { + format!("{}/chat/completions", normalized) + } else { + format!("{}/v1/chat/completions", normalized) + } +} + +fn ends_with_version_segment(path: &str) -> bool { + let last = path.rsplit('/').next().unwrap_or(path); + last.strip_prefix('v') + .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api_client::AuthMethod; + use serde_json::json; + + #[test] + fn gpt_5_5_and_5_6_models_have_expected_context_limits() { + for model in [ + "gpt-5.5", + "gpt-5.5-pro", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", + ] { + assert_eq!( + OPEN_AI_KNOWN_MODELS + .iter() + .find(|(name, _)| *name == model) + .map(|(_, limit)| *limit), + Some(1_050_000), + "unexpected context limit for {model}" + ); + } + } + + fn make_provider(name: &str) -> OpenAiProvider { + OpenAiProvider { + api_client: ApiClient::new_with_tls( + "http://localhost".to_string(), + AuthMethod::NoAuth, + None, + ) + .unwrap(), + base_path: "v1/chat/completions".to_string(), + organization: None, + project: None, + custom_headers: None, + supports_streaming: true, + name: name.to_string(), + custom_models: None, + dynamic_models: None, + skip_canonical_filtering: false, + preserve_thinking_context: false, + n_ctx_cache: Arc::new(Mutex::new(HashMap::new())), + } + } + + #[test] + fn sanitize_remaps_max_completion_tokens_for_compat_provider() { + let provider = make_provider("mistral"); + let payload = json!({ + "model": "mistral-medium-latest", + "messages": [], + "max_completion_tokens": 16384 + }); + + let result = provider.sanitize_request_for_compat(payload); + let obj = result.as_object().unwrap(); + + assert!(!obj.contains_key("max_completion_tokens")); + assert_eq!(obj.get("max_tokens").unwrap(), &json!(16384)); + } + + #[test] + fn sanitize_preserves_existing_max_tokens_for_compat_provider() { + let provider = make_provider("mistral"); + let payload = json!({ + "model": "mistral-medium-latest", + "messages": [], + "max_tokens": 4096, + "max_completion_tokens": 16384 + }); + + let result = provider.sanitize_request_for_compat(payload); + let obj = result.as_object().unwrap(); + + assert!(!obj.contains_key("max_completion_tokens")); + assert_eq!(obj.get("max_tokens").unwrap(), &json!(4096)); + } + + #[test] + fn sanitize_noop_for_native_openai_provider() { + let provider = make_provider("openai"); + let payload = json!({ + "model": "o3", + "messages": [], + "max_completion_tokens": 16384 + }); + + let result = provider.sanitize_request_for_compat(payload); + let obj = result.as_object().unwrap(); + + assert!(obj.contains_key("max_completion_tokens")); + assert!(!obj.contains_key("max_tokens")); + } + + #[test] + fn sanitize_noop_for_unknown_provider() { + let provider = make_provider("some_future_provider"); + let payload = json!({ + "model": "future-model", + "messages": [], + "max_completion_tokens": 16384 + }); + + let result = provider.sanitize_request_for_compat(payload); + let obj = result.as_object().unwrap(); + + assert!(obj.contains_key("max_completion_tokens")); + assert!(!obj.contains_key("max_tokens")); + } + + #[test] + fn sanitize_no_token_params() { + let provider = make_provider("groq"); + let payload = json!({ + "model": "llama-3.3-70b-versatile", + "messages": [] + }); + + let result = provider.sanitize_request_for_compat(payload.clone()); + assert_eq!(result, payload); + } + + #[test] + fn sanitize_nearai_reasoning_chat_params() { + let provider = make_provider("nearai"); + let payload = json!({ + "model": "Qwen/Qwen3.6-35B-A3B-FP8", + "messages": [ + { + "role": "developer", + "content": "system instructions" + }, + { + "role": "user", + "content": "hello" + } + ], + "reasoning_effort": "medium", + "max_completion_tokens": 16384 + }); + + let result = provider.sanitize_request_for_compat(payload); + let obj = result.as_object().unwrap(); + + assert!(!obj.contains_key("reasoning_effort")); + assert!(!obj.contains_key("max_completion_tokens")); + assert_eq!(obj.get("max_tokens").unwrap(), &json!(16384)); + assert_eq!(obj["messages"][0]["role"], "system"); + assert_eq!(obj["messages"][1]["role"], "user"); + } + + #[test] + fn sanitize_nearai_preserves_openai_reasoning_effort() { + let provider = make_provider("nearai"); + let payload = json!({ + "model": "openai/gpt-5", + "messages": [], + "reasoning_effort": "medium", + "max_completion_tokens": 16384 + }); + + let result = provider.sanitize_request_for_compat(payload); + let obj = result.as_object().unwrap(); + + assert_eq!(obj.get("reasoning_effort"), Some(&json!("medium"))); + assert!(!obj.contains_key("max_completion_tokens")); + assert_eq!(obj.get("max_tokens").unwrap(), &json!(16384)); + } + + #[test] + fn nearai_uses_chat_completions_for_openai_reasoning_models() { + let provider = make_provider("nearai"); + + assert!(!provider.should_use_responses_api_for_provider("openai/gpt-5")); + assert!(!provider.should_use_responses_api_for_provider("openai/o3")); + } + + #[test] + fn responses_api_routing_uses_model_family_unless_path_forces_chat() { + for (model_name, base_path, expected) in [ + ("gpt-5.4", "v1/chat/completions", true), + ("gpt-5.4-xhigh", "v1/chat/completions", true), + ("gpt-5.2-pro-2025-12-11", "v1/chat/completions", true), + ("gpt-4o", "v1/chat/completions", false), + ("gpt-5.2-codex", "openai/v1/chat/completions", false), + ] { + assert_eq!( + OpenAiProvider::should_use_responses_api(model_name, base_path), + expected, + "unexpected routing for {model_name} via {base_path}" + ); + } + } + + #[test] + fn custom_chat_path_maps_to_responses_path() { + let responses_path = OpenAiProvider::map_base_path( + "openai/v1/chat/completions", + "responses", + "v1/responses", + ); + assert_eq!(responses_path, "openai/v1/responses"); + } + + #[test] + fn responses_path_maps_to_models_path() { + let models_path = + OpenAiProvider::map_base_path("openai/v1/responses", "models", "v1/models"); + assert_eq!(models_path, "openai/v1/models"); + } + + #[test] + fn unknown_path_falls_back_to_default_models_path() { + let models_path = OpenAiProvider::map_base_path("custom/path", "models", "v1/models"); + assert_eq!(models_path, "v1/models"); + } + + #[test] + fn absolute_chat_path_maps_to_absolute_responses_path() { + let responses_path = + OpenAiProvider::map_base_path("/v1/chat/completions", "responses", "v1/responses"); + assert_eq!(responses_path, "/v1/responses"); + } + + #[test] + fn unknown_absolute_path_falls_back_to_absolute_models_path() { + let models_path = OpenAiProvider::map_base_path("/custom/path", "models", "v1/models"); + assert_eq!(models_path, "/v1/models"); + } + #[test] + fn versionless_base_path_opts_out_of_responses_for_codex_models() { + assert!(!OpenAiProvider::should_use_responses_api( + "gpt-5-codex", + "chat/completions" + )); + } + + #[test] + fn ensure_url_scheme_adds_http_for_local_hosts() { + assert_eq!(ensure_url_scheme("localhost:1234"), "http://localhost:1234"); + assert_eq!( + ensure_url_scheme("127.0.0.1:8080/v1"), + "http://127.0.0.1:8080/v1" + ); + assert_eq!(ensure_url_scheme("0.0.0.0:3000"), "http://0.0.0.0:3000"); + assert_eq!(ensure_url_scheme("[::1]:1234"), "http://[::1]:1234"); + } + + #[test] + fn ensure_url_scheme_adds_https_for_remote_hosts() { + assert_eq!( + ensure_url_scheme("api.example.com:8443/v1"), + "https://api.example.com:8443/v1" + ); + assert_eq!(ensure_url_scheme("example.com"), "https://example.com"); + } + + #[test] + fn ensure_url_scheme_preserves_existing_scheme() { + assert_eq!( + ensure_url_scheme("http://localhost:1234"), + "http://localhost:1234" + ); + assert_eq!( + ensure_url_scheme("https://api.openai.com/v1"), + "https://api.openai.com/v1" + ); + } + + fn custom_config(base_url: &str) -> DeclarativeProviderConfig { + DeclarativeProviderConfig { + name: "test-openai".to_string(), + engine: crate::declarative::ProviderEngine::OpenAI, + display_name: "Test OpenAI".to_string(), + description: None, + api_key_env: String::new(), + base_url: base_url.to_string(), + models: vec![crate::base::ModelInfo::new("test-model", 4096)], + headers: None, + timeout_seconds: None, + supports_streaming: None, + requires_auth: false, + catalog_provider_id: None, + base_path: None, + env_vars: None, + dynamic_models: Some(false), + skip_canonical_filtering: false, + model_doc_link: None, + setup_steps: vec![], + fast_model: None, + preserves_thinking: false, + } + } + + #[test] + fn from_custom_config_preserves_ipv6_authority() { + let provider = from_declarative_config( + custom_config("http://[::1]:1234/v1"), + None, + crate::declarative::EnvKeyResolver, + ) + .unwrap() + .build(); + + assert_eq!(provider.api_client.host(), "http://[::1]:1234"); + } + + #[test] + fn from_custom_config_preserves_userinfo_authority() { + let provider = from_declarative_config( + custom_config("https://user:pass@gateway.example/v1"), + None, + crate::declarative::EnvKeyResolver, + ) + .unwrap() + .build(); + + assert_eq!( + provider.api_client.host(), + "https://user:pass@gateway.example" + ); + } + + #[test] + fn parse_n_ctx_falls_back_to_sole_entry_when_id_differs() { + let body = json!({ + "data": [ + { "id": "/models/qwen3.gguf", "meta": { "n_ctx": 32768 } } + ] + }); + assert_eq!(parse_n_ctx_from_models(&body, "qwen3"), Some(32768)); + } + + #[test] + fn parse_n_ctx_no_fallback_with_multiple_unmatched_entries() { + let body = json!({ + "data": [ + { "id": "model-a", "meta": { "n_ctx": 4096 } }, + { "id": "model-b", "meta": { "n_ctx": 8192 } } + ] + }); + assert_eq!(parse_n_ctx_from_models(&body, "model-c"), None); + } + + #[test] + fn derive_base_path_not_removing_api_path() { + let r = derive_base_path("https://opencode.ai/zen/go"); + assert_eq!(r, "https://opencode.ai/zen/go/v1/chat/completions"); + } + + #[test] + fn derive_base_path_should_support_v1() { + let r = derive_base_path("https://opencode.ai/zen/go/v1"); + assert_eq!(r, "https://opencode.ai/zen/go/v1/chat/completions"); + } + + #[test] + fn derive_base_path_should_support_no_base_path() { + let r = derive_base_path("https://opencode.ai/"); + assert_eq!(r, "https://opencode.ai/v1/chat/completions"); + } + + #[test] + fn derive_base_path_preserves_non_v1_version_prefix() { + // Zhipu's default base_url is https://open.bigmodel.cn/api/paas/v4 and + // from_custom_config passes url.path() ("/api/paas/v4") here. The + // existing /api/paas/v4 version must not gain an extra /v1 segment. + let r = derive_base_path("/api/paas/v4"); + assert_eq!(r, "api/paas/v4/chat/completions"); + } + + #[test] + fn derive_base_path_does_not_treat_v_word_as_version() { + let r = derive_base_path("/api/voice"); + assert_eq!(r, "api/voice/v1/chat/completions"); + } +} diff --git a/crates/goose/src/providers/openai_compatible.rs b/crates/goose-providers/src/openai_compatible.rs similarity index 69% rename from crates/goose/src/providers/openai_compatible.rs rename to crates/goose-providers/src/openai_compatible.rs index d3580bdcb1..78f96aed92 100644 --- a/crates/goose/src/providers/openai_compatible.rs +++ b/crates/goose-providers/src/openai_compatible.rs @@ -1,3 +1,5 @@ +use crate::conversation::token_usage::{CostSource, ProviderUsage}; +use crate::images::ImageFormat; use anyhow::Error; use async_stream::try_stream; use futures::TryStreamExt; @@ -11,40 +13,42 @@ use tokio_util::codec::{FramedRead, LinesCodec}; use tokio_util::io::StreamReader; use super::api_client::ApiClient; -use super::base::{MessageStream, Provider}; -use super::errors::ProviderError; +use super::base::{stream_from_single_message, MessageStream, Provider}; use super::retry::ProviderRetry; -use super::utils::{ImageFormat, RequestLog}; use crate::conversation::message::Message; +use crate::errors::ProviderError; +use crate::formats::openai::{ + 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; -use crate::providers::formats::openai::{create_request, response_to_streaming_message}; -use crate::providers::formats::openai_responses::responses_api_to_streaming_message; +use crate::request_log::{start_log, LoggerHandleExt, RequestLogHandle}; use rmcp::model::Tool; 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, } } + pub fn with_supports_streaming(mut self, supports_streaming: bool) -> Self { + self.supports_streaming = supports_streaming; + self + } + fn build_request( &self, model_config: &ModelConfig, @@ -71,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?; @@ -105,20 +105,25 @@ impl Provider for OpenAiCompatibleProvider { async fn stream( &self, model_config: &ModelConfig, - session_id: &str, system: &str, messages: &[Message], tools: &[Tool], ) -> Result { - let payload = self.build_request(model_config, system, messages, tools, true)?; - let mut log = RequestLog::start(model_config, &payload)?; + let payload = self.build_request( + model_config, + system, + messages, + tools, + self.supports_streaming, + )?; + let mut log = start_log(model_config, &payload)?; let completions_path = format!("{}chat/completions", self.completions_prefix); let response = self .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 }) @@ -127,7 +132,31 @@ impl Provider for OpenAiCompatibleProvider { let _ = log.error(e); })?; - stream_openai_compat(response, log) + if self.supports_streaming { + stream_openai_compat(response, log) + } else { + let json: serde_json::Value = response.json().await.map_err(|e| { + ProviderError::RequestFailed(format!("Failed to parse JSON: {}", e)) + })?; + + let message = response_to_message(&json).map_err(|e| { + ProviderError::RequestFailed(format!("Failed to parse message: {}", e)) + })?; + + let usage_json = json.get("usage").unwrap_or(&serde_json::Value::Null); + let usage_data = get_usage(usage_json); + let mut usage = ProviderUsage::new(model_config.model_name.clone(), usage_data); + if let Some(cost) = get_cost(usage_json) { + usage = usage.with_cost(cost, CostSource::ProviderReported); + } + + log.write( + &serde_json::to_value(&message).unwrap_or_default(), + Some(&usage.usage), + )?; + + Ok(stream_from_single_message(message, usage)) + } } } @@ -142,7 +171,7 @@ pub use super::http_status::handle_response as handle_response_openai_compat; pub fn stream_openai_compat( response: Response, - mut log: RequestLog, + mut log: Option>, ) -> Result { let stream = response.bytes_stream().map_err(std::io::Error::other); @@ -156,7 +185,7 @@ pub fn stream_openai_compat( while let Some(message) = message_stream.next().await { let (message, usage) = message.map_err(|e| e.downcast::() - .unwrap_or_else(|e| ProviderError::RequestFailed(format!("Stream decode error: {e}"))) + .unwrap_or_else(ProviderError::stream_decode_error) )?; log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?; yield (message, usage); @@ -166,7 +195,7 @@ pub fn stream_openai_compat( pub fn stream_responses_compat( response: Response, - mut log: RequestLog, + mut log: Option>, ) -> Result { let stream = response.bytes_stream().map_err(std::io::Error::other); @@ -179,7 +208,8 @@ pub fn stream_responses_compat( pin!(message_stream); while let Some(message) = message_stream.next().await { let (message, usage) = message.map_err(|e| - ProviderError::RequestFailed(format!("Stream decode error: {e}")) + e.downcast::() + .unwrap_or_else(ProviderError::stream_decode_error) )?; log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?; yield (message, usage); @@ -190,6 +220,7 @@ pub fn stream_responses_compat( #[cfg(test)] mod tests { use super::*; + use crate::model::ModelConfig; use serde_json::json; use test_case::test_case; @@ -262,4 +293,27 @@ mod tests { "Expected {expected_variant}, got error: {err:?}" ); } + + #[test] + fn build_request_respects_non_streaming_mode() { + let provider = OpenAiCompatibleProvider::new( + "test".to_string(), + ApiClient::new_with_tls( + "http://localhost".to_string(), + super::super::api_client::AuthMethod::NoAuth, + None, + ) + .unwrap(), + String::new(), + ) + .with_supports_streaming(false); + + let model = ModelConfig::new("test-model"); + let payload = provider + .build_request(&model, "", &[], &[], provider.supports_streaming) + .unwrap(); + + assert_eq!(payload.get("stream"), None); + assert_eq!(payload.get("stream_options"), None); + } } diff --git a/crates/goose/src/providers/snowflake.rs b/crates/goose-providers/src/snowflake.rs similarity index 80% rename from crates/goose/src/providers/snowflake.rs rename to crates/goose-providers/src/snowflake.rs index de5c262bf1..1ae0537d29 100644 --- a/crates/goose/src/providers/snowflake.rs +++ b/crates/goose-providers/src/snowflake.rs @@ -1,22 +1,21 @@ +use crate::conversation::token_usage::ProviderUsage; +use crate::images::ImageFormat; use anyhow::Result; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use super::api_client::{ApiClient, AuthMethod}; -use super::base::{ - ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata, ProviderUsage, -}; -use super::errors::ProviderError; -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, ImageFormat, RequestLog}; -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 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 crate::model::ModelConfig; -use futures::future::BoxFuture; +use crate::request_log::{start_log, LoggerHandleExt}; use rmcp::model::Tool; const SNOWFLAKE_PROVIDER_NAME: &str = "snowflake"; @@ -51,28 +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) -> 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?; - + pub fn new( + mut host: String, + token: String, + tls_config: Option, + request_builder: Option, + ) -> Result { // Convert host to lowercase host = host.to_lowercase(); @@ -81,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) @@ -101,25 +77,24 @@ impl SnowflakeProvider { host }; - let auth = AuthMethod::BearerToken(token?); - let api_client = ApiClient::new(base_url, auth)?.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(); @@ -298,9 +273,7 @@ impl SnowflakeProvider { } } -impl ProviderDef for SnowflakeProvider { - type Provider = Self; - +impl crate::base::ProviderDescriptor for SnowflakeProvider { fn metadata() -> ProviderMetadata { ProviderMetadata::new( SNOWFLAKE_PROVIDER_NAME, @@ -315,13 +288,6 @@ impl ProviderDef for SnowflakeProvider { ], ) } - - fn from_env( - model: ModelConfig, - _extensions: Vec, - ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) - } } #[async_trait] @@ -330,10 +296,6 @@ impl Provider for SnowflakeProvider { &self.name } - fn get_model_config(&self) -> ModelConfig { - self.model.clone() - } - async fn fetch_supported_models(&self) -> Result, ProviderError> { Ok(SNOWFLAKE_KNOWN_MODELS .iter() @@ -344,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 = RequestLog::start(&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?; @@ -372,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-sdk-types/Cargo.toml b/crates/goose-sdk-types/Cargo.toml new file mode 100644 index 0000000000..afda65dd86 --- /dev/null +++ b/crates/goose-sdk-types/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "goose-sdk-types" +version = "0.1.0-alpha.0" +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Shared types for the Goose SDK" + +[dependencies] +agent-client-protocol = { workspace = true, features = ["unstable"] } +agent-client-protocol-schema = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +schemars = { workspace = true, features = ["derive"] } + +[package.metadata.cargo-machete] +# Used to provide extras imports for agent-client-protocol +ignored = ["agent-client-protocol-schema"] diff --git a/crates/goose-sdk-types/src/custom_notifications.rs b/crates/goose-sdk-types/src/custom_notifications.rs new file mode 100644 index 0000000000..868e3185de --- /dev/null +++ b/crates/goose-sdk-types/src/custom_notifications.rs @@ -0,0 +1,226 @@ +use crate::custom_requests::CustomMethodSchema; +use agent_client_protocol::{JsonRpcMessage, JsonRpcNotification}; +use schemars::{JsonSchema, SchemaGenerator}; +use serde::{Deserialize, Serialize}; + +/// Goose-custom session update notification โ€” a parallel to ACP's +/// `session/update` carrying goose-specific update variants. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcNotification)] +#[notification(method = "_goose/unstable/session/update")] +#[serde(rename_all = "camelCase")] +pub struct GooseSessionNotification { + pub session_id: String, + pub update: GooseSessionUpdate, +} + +/// Discriminated union of goose-specific session update payloads. +/// Variant tag matches ACP's convention (`sessionUpdate: ""`). +/// +/// `discriminator.mapping` is what makes TS codegen (`@hey-api/openapi-ts`) +/// emit the correct snake_case tag value even when this enum has a single +/// variant. Add a mapping entry per variant. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "sessionUpdate", rename_all = "snake_case")] +#[schemars(extend("discriminator" = { + "propertyName": "sessionUpdate", + "mapping": { + "usage_update": "#/$defs/SessionUsageUpdate", + "status_message": "#/$defs/StatusMessageUpdate", + "message_usage": "#/$defs/MessageUsageUpdate" + } +}))] +pub enum GooseSessionUpdate { + UsageUpdate(SessionUsageUpdate), + StatusMessage(StatusMessageUpdate), + MessageUsage(MessageUsageUpdate), +} + +impl Default for GooseSessionUpdate { + fn default() -> Self { + GooseSessionUpdate::UsageUpdate(SessionUsageUpdate::default()) + } +} + +/// Streaming context-window usage update for a session. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SessionUsageUpdate { + pub used: u64, + pub context_limit: u64, + pub accumulated_input_tokens: u64, + pub accumulated_output_tokens: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub accumulated_cost: Option, +} + +/// Per-message token usage/cost/timing, keyed by the message id used for +/// chunk matching. Sent live after a turn's messages and on replay. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MessageUsageUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub message_id: Option, + pub usage: MessageUsageData, +} + +/// Wire mirror of the conversation `MessageUsage` (this crate cannot depend on +/// goose-provider-types); field names and serde casing MUST stay in parity. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MessageUsageData { + #[serde(skip_serializing_if = "Option::is_none")] + pub input_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub total_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_read_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_write_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cost: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cost_source: Option, + /// Wall-clock generation time, used by the client for a tokens/sec readout. + #[serde(skip_serializing_if = "Option::is_none")] + pub elapsed_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub time_to_first_token_ms: Option, + /// Usage from a compaction/summarization call rather than a normal turn. + #[serde(default)] + pub is_compaction: bool, +} + +/// Wire mirror of the conversation `CostSource`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CostSourceData { + /// Cost returned directly by the provider (e.g. OpenRouter `usage.cost`). + ProviderReported, + /// Cost computed from the canonical pricing table. + Estimated, +} + +/// Live UI/session status. This is not conversation transcript content, and +/// should not be persisted or replayed as history. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct StatusMessageUpdate { + pub status: StatusMessage, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum StatusMessage { + #[serde(rename_all = "camelCase")] + Notice { message: String }, + #[serde(rename_all = "camelCase")] + Progress { message: String }, +} + +fn notification_schema(generator: &mut SchemaGenerator) -> CustomMethodSchema +where + T: Default + JsonRpcMessage + JsonSchema, +{ + let dummy = T::default(); + let type_name = std::any::type_name::() + .rsplit("::") + .next() + .unwrap_or(std::any::type_name::()) + .to_string(); + CustomMethodSchema { + method: dummy.method().to_string(), + params_schema: Some(generator.subschema_for::()), + params_type_name: Some(type_name), + response_schema: None, + response_type_name: None, + } +} + +/// Schemas for every goose-custom outbound notification. To register a new +/// notification, define the struct above (with `JsonRpcNotification` + +/// `Default`) and add one line below. +pub fn custom_notification_schemas(generator: &mut SchemaGenerator) -> Vec { + vec![notification_schema::(generator)] +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn status_message_serializes_to_expected_wire_shape() { + let notification = GooseSessionNotification { + session_id: "s1".to_string(), + update: GooseSessionUpdate::StatusMessage(StatusMessageUpdate { + status: StatusMessage::Notice { + message: "Compaction complete".to_string(), + }, + }), + }; + + let value = serde_json::to_value(notification).unwrap(); + + assert_eq!( + value, + json!({ + "sessionId": "s1", + "update": { + "sessionUpdate": "status_message", + "status": { + "type": "notice", + "message": "Compaction complete" + } + } + }) + ); + } + + #[test] + fn message_usage_serializes_to_expected_wire_shape() { + let notification = GooseSessionNotification { + session_id: "s1".to_string(), + update: GooseSessionUpdate::MessageUsage(MessageUsageUpdate { + message_id: Some("m1".to_string()), + usage: MessageUsageData { + input_tokens: Some(1200), + output_tokens: Some(340), + total_tokens: Some(1540), + cache_read_tokens: Some(1000), + cache_write_tokens: None, + cost: Some(0.0123), + cost_source: Some(CostSourceData::Estimated), + elapsed_ms: Some(4200), + time_to_first_token_ms: Some(840), + is_compaction: false, + }, + }), + }; + + let value = serde_json::to_value(notification).unwrap(); + + assert_eq!( + value, + json!({ + "sessionId": "s1", + "update": { + "sessionUpdate": "message_usage", + "messageId": "m1", + "usage": { + "inputTokens": 1200, + "outputTokens": 340, + "totalTokens": 1540, + "cacheReadTokens": 1000, + "cost": 0.0123, + "costSource": "estimated", + "elapsedMs": 4200, + "timeToFirstTokenMs": 840, + "isCompaction": false + } + } + }) + ); + } +} diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk-types/src/custom_requests.rs similarity index 59% rename from crates/goose-sdk/src/custom_requests.rs rename to crates/goose-sdk-types/src/custom_requests.rs index 5b007574bb..a4502ca255 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk-types/src/custom_requests.rs @@ -1,8 +1,14 @@ +use agent_client_protocol::schema::v1::{AvailableCommand, ContentBlock, McpServer, SessionInfo}; use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +mod recipe; +pub use recipe::*; +mod schedule; +pub use schedule::*; + /// Schema descriptor for a single custom method, produced by the /// `#[custom_methods]` macro's generated `custom_method_schemas()` function. /// @@ -25,18 +31,16 @@ pub struct CustomMethodSchema { #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/unstable/session/extensions/add", response = EmptyResponse)] #[serde(rename_all = "camelCase")] -pub struct AddExtensionRequest { +pub struct AddSessionExtensionRequest { pub session_id: String, - /// Extension configuration (see ExtensionConfig variants: Stdio, StreamableHttp, Builtin, Platform). - #[serde(default)] - pub config: serde_json::Value, + pub extension: GooseExtension, } /// Remove an extension from an active session. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/unstable/session/extensions/remove", response = EmptyResponse)] #[serde(rename_all = "camelCase")] -pub struct RemoveExtensionRequest { +pub struct RemoveSessionExtensionRequest { pub session_id: String, pub name: String, } @@ -47,13 +51,28 @@ pub struct RemoveExtensionRequest { #[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. @@ -99,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)] @@ -139,6 +210,119 @@ pub struct SetSessionSystemPromptRequest { pub text: String, } +/// Add user input to the currently active prompt without starting a new prompt. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/session/steer", + response = SteerSessionResponse +)] +#[serde(rename_all = "camelCase")] +pub struct SteerSessionRequest { + pub session_id: String, + #[serde(default)] + pub prompt: Vec, + pub expected_run_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct SteerSessionResponse { + pub run_id: String, + /// Stable id of the queued steer message. The same id later appears as + /// `messageId` on the streamed `UserMessageChunk` (with `_meta.goose.steer`), + /// letting clients correlate a queued steer with its pickup. + pub message_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/diagnostics/get", + response = DiagnosticsGetResponse +)] +#[serde(rename_all = "camelCase")] +pub struct DiagnosticsGetRequest { + pub session_id: String, + #[serde(default)] + pub level: DiagnosticsReportLevel, +} + +#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum DiagnosticsReportLevel { + #[default] + Summary, + Full, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct DiagnosticsGetResponse { + pub report: serde_json::Value, +} + +/// Information about a prompt template, including its default content and customization status. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PromptTemplateEntry { + pub name: String, + pub description: String, + pub default_content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_content: Option, + pub is_customized: bool, +} + +/// List all available Goose prompt templates. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/config/prompts/list", response = ListPromptsResponse)] +#[serde(rename_all = "camelCase")] +pub struct ListPromptsRequest {} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct ListPromptsResponse { + pub prompts: Vec, +} + +/// Read a Goose prompt template. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/config/prompts/get", response = GetPromptResponse)] +#[serde(rename_all = "camelCase")] +pub struct GetPromptRequest { + pub name: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct GetPromptResponse { + pub name: String, + pub content: String, + pub default_content: String, + pub is_customized: bool, +} + +/// Save a custom Goose prompt template. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/config/prompts/save", response = PromptOperationResponse)] +#[serde(rename_all = "camelCase")] +pub struct SavePromptRequest { + pub name: String, + pub content: String, +} + +/// Reset a Goose prompt template to its default content. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/config/prompts/reset", response = PromptOperationResponse)] +#[serde(rename_all = "camelCase")] +pub struct ResetPromptRequest { + pub name: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct PromptOperationResponse { + pub message: String, +} + /// Delete a session. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "session/delete", response = EmptyResponse)] @@ -147,30 +331,114 @@ pub struct DeleteSessionRequest { pub session_id: String, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum GooseExtension { + Builtin { + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + bundled: Option, + /// Tool allowlist for this extension. Omit this field to allow all tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + available_tools: Option>, + }, + Platform { + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + bundled: Option, + /// Tool allowlist for this extension. Omit this field to allow all tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + available_tools: Option>, + }, + Mcp { + server: McpServer, + #[serde(default, rename = "envKeys", skip_serializing_if = "Vec::is_empty")] + env_keys: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + socket: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + bundled: Option, + /// Tool allowlist for this extension. Omit this field to allow all tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + available_tools: Option>, + }, +} + +impl Default for GooseExtension { + fn default() -> Self { + Self::Builtin { + name: String::new(), + description: None, + display_name: None, + timeout: None, + bundled: None, + available_tools: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct GooseExtensionEntry { + pub extension: GooseExtension, + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_key: Option, +} + +/// List Goose-owned extension definitions available to configure or enable. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/extensions/available", + response = GetAvailableExtensionsResponse +)] +pub struct GetAvailableExtensionsRequest {} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct GetAvailableExtensionsResponse { + pub extensions: Vec, +} + /// List configured extensions and any warnings. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/config/extensions/list", response = GetExtensionsResponse)] -pub struct GetExtensionsRequest {} +#[request( + method = "_goose/unstable/config/extensions/list", + response = GetConfigExtensionsResponse +)] +pub struct GetConfigExtensionsRequest {} /// List configured extensions and any warnings. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct GetExtensionsResponse { - /// Array of ExtensionEntry objects with `enabled` flag, `configKey`, and flattened config details. - pub extensions: Vec, +pub struct GetConfigExtensionsResponse { + pub extensions: Vec, + #[serde(default)] pub warnings: Vec, } +pub type GetExtensionsRequest = GetConfigExtensionsRequest; +pub type GetExtensionsResponse = GetConfigExtensionsResponse; + /// Persist a new extension to the user's global goose config. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/unstable/config/extensions/add", response = EmptyResponse)] #[serde(rename_all = "camelCase")] pub struct AddConfigExtensionRequest { - pub name: String, - /// Extension configuration. Must be a JSON object matching one of the - /// `ExtensionConfig` variants (e.g. `stdio`, `streamable_http`, `builtin`). - /// `name` and `enabled` are injected server-side. - #[serde(default)] - pub extension_config: serde_json::Value, + pub extension: GooseExtension, #[serde(default)] pub enabled: bool, } @@ -183,11 +451,14 @@ pub struct RemoveConfigExtensionRequest { pub config_key: String, } -/// Toggle the `enabled` flag for a persisted extension in the user's global goose config. +/// Set the `enabled` flag for a persisted extension in the user's global goose config. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/config/extensions/toggle", response = EmptyResponse)] +#[request( + method = "_goose/unstable/config/extensions/set-enabled", + response = EmptyResponse +)] #[serde(rename_all = "camelCase")] -pub struct ToggleConfigExtensionRequest { +pub struct SetConfigExtensionEnabledRequest { pub config_key: String, pub enabled: bool, } @@ -201,7 +472,7 @@ pub struct GetSessionExtensionsRequest { #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] pub struct GetSessionExtensionsResponse { - pub extensions: Vec, + pub extensions: Vec, } /// Read allowlisted user preferences. Empty `keys` means all supported preferences. @@ -231,11 +502,58 @@ pub struct PreferencesRemoveRequest { pub keys: Vec, } +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/config/read", response = ConfigReadResponse)] +#[serde(rename_all = "camelCase")] +pub struct ConfigReadRequest { + pub key: String, + #[serde(default)] + pub is_secret: bool, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct ConfigReadResponse { + #[serde(default)] + pub value: serde_json::Value, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/config/upsert", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct ConfigUpsertRequest { + pub key: String, + pub value: serde_json::Value, + #[serde(default)] + pub is_secret: bool, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/config/remove", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct ConfigRemoveRequest { + pub key: String, + #[serde(default)] + pub is_secret: bool, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/config/read-all", response = ConfigReadAllResponse)] +#[serde(rename_all = "camelCase")] +pub struct ConfigReadAllRequest {} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct ConfigReadAllResponse { + pub config: std::collections::HashMap, +} + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub enum PreferenceKey { #[default] AutoCompactThreshold, + GooseThinkingEffort, VoiceAutoSubmitPhrases, VoiceDictationProvider, VoiceDictationPreferredMic, @@ -278,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")] @@ -371,6 +695,35 @@ pub struct DictationSecretDeleteRequest { pub provider: String, } +/// Return list-style metadata for a single session without loading the conversation. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/session/info", + response = GetSessionInfoResponse +)] +#[serde(rename_all = "camelCase")] +pub struct GetSessionInfoRequest { + pub session_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct GetSessionInfoResponse { + pub session: SessionInfo, +} + +/// Truncate a session conversation from the given message timestamp onward. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/session/conversation/truncate", + response = EmptyResponse +)] +#[serde(rename_all = "camelCase")] +pub struct TruncateSessionConversationRequest { + pub session_id: String, + pub truncate_from: i64, +} + /// Update the project association for a session. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/unstable/session/project/update", response = EmptyResponse)] @@ -419,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. @@ -553,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 { @@ -976,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)] @@ -1247,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 {} @@ -1275,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. @@ -1338,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 new file mode 100644 index 0000000000..101fa230df --- /dev/null +++ b/crates/goose-sdk-types/src/custom_requests/recipe.rs @@ -0,0 +1,394 @@ +use std::collections::HashMap; + +use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::EmptyResponse; + +fn default_recipe_version() -> String { + "1.0.0".to_string() +} + +pub const REQUEST_RECIPE_PARAMS_METHOD: &str = "_goose/unstable/session/recipe/request-params"; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RecipeDto { + #[serde(default = "default_recipe_version")] + pub version: String, + pub title: String, + pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instructions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extensions: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub settings: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub activities: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub author: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parameters: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sub_recipes: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retry: Option, +} + +impl Default for RecipeDto { + fn default() -> Self { + Self { + version: default_recipe_version(), + title: String::new(), + description: String::new(), + instructions: None, + prompt: None, + extensions: None, + settings: None, + activities: None, + author: None, + parameters: None, + response: None, + sub_recipes: None, + retry: None, + } + } +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RecipeAuthorDto { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub contact: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RecipeSettingsDto { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub goose_provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub goose_model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_turns: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RecipeResponseDto { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub json_schema: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SubRecipeDto { + pub name: String, + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub values: Option>, + #[serde(default)] + pub sequential_when_repeated: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RecipeParameterDto { + pub key: String, + pub input_type: RecipeParameterInputTypeDto, + pub requirement: RecipeParameterRequirementDto, + pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option>, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RecipeParameterInputTypeDto { + #[default] + String, + Number, + Boolean, + Date, + File, + Select, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RecipeParameterRequirementDto { + #[default] + Required, + Optional, + UserPrompt, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RecipeRetryConfigDto { + pub max_retries: u32, + #[serde(default)] + pub checks: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_failure: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_failure_timeout_seconds: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RecipeSuccessCheckDto { + Shell { command: String }, +} + +impl Default for RecipeSuccessCheckDto { + fn default() -> Self { + Self::Shell { + command: String::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RecipeExtensionDto { + Builtin { + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + bundled: Option, + /// Tool allowlist for this extension. Omit this field to allow all tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + available_tools: Option>, + }, + Platform { + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + bundled: Option, + /// Tool allowlist for this extension. Omit this field to allow all tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + available_tools: Option>, + }, + Stdio { + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + cmd: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + args: Vec, + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + envs: HashMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + env_keys: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + bundled: Option, + /// Tool allowlist for this extension. Omit this field to allow all tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + available_tools: Option>, + }, + StreamableHttp { + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + uri: String, + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + envs: HashMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + env_keys: Vec, + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + headers: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + socket: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + bundled: Option, + /// Tool allowlist for this extension. Omit this field to allow all tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + available_tools: Option>, + }, +} + +impl Default for RecipeExtensionDto { + fn default() -> Self { + Self::Builtin { + name: String::new(), + description: None, + display_name: None, + timeout: None, + bundled: None, + available_tools: None, + } + } +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RecipeListEntryDto { + pub id: String, + pub recipe: RecipeDto, + pub file_path: String, + pub last_modified: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schedule_cron: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slash_command: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct RequestRecipeParams { + pub session_id: String, + pub parameters: Vec, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum RecipeParamsAction { + #[default] + Submit, + Cancel, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct RecipeParamsResponse { + #[serde(default)] + pub action: RecipeParamsAction, + #[serde(default)] + pub values: HashMap, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/recipes/encode", + response = EncodeRecipeResponse +)] +pub struct EncodeRecipeRequest { + pub recipe: RecipeDto, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct EncodeRecipeResponse { + pub deeplink: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/recipes/decode", + response = DecodeRecipeResponse +)] +pub struct DecodeRecipeRequest { + pub deeplink: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct DecodeRecipeResponse { + pub recipe: RecipeDto, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/recipes/scan", response = ScanRecipeResponse)] +pub struct ScanRecipeRequest { + pub recipe: RecipeDto, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct ScanRecipeResponse { + pub has_security_warnings: bool, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/recipes/save", response = SaveRecipeResponse)] +pub struct SaveRecipeRequest { + pub recipe: RecipeDto, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct SaveRecipeResponse { + pub id: String, + pub file_name: String, + pub file_path: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/recipes/parse", response = ParseRecipeResponse)] +pub struct ParseRecipeRequest { + pub content: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct ParseRecipeResponse { + pub recipe: RecipeDto, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/recipes/delete", response = EmptyResponse)] +pub struct DeleteRecipeRequest { + pub id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/recipes/list", response = ListRecipesResponse)] +pub struct ListRecipesRequest {} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct ListRecipesResponse { + pub recipes: Vec, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/recipes/schedule", response = EmptyResponse)] +pub struct ScheduleRecipeRequest { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cron_schedule: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/recipes/slash-command", + response = EmptyResponse +)] +pub struct SetRecipeSlashCommandRequest { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slash_command: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/recipes/to-yaml", + response = RecipeToYamlResponse +)] +pub struct RecipeToYamlRequest { + pub recipe: RecipeDto, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct RecipeToYamlResponse { + pub yaml: String, +} diff --git a/crates/goose-sdk-types/src/custom_requests/schedule.rs b/crates/goose-sdk-types/src/custom_requests/schedule.rs 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 new file mode 100644 index 0000000000..0358354605 --- /dev/null +++ b/crates/goose-sdk-types/src/lib.rs @@ -0,0 +1,7 @@ +//! Shared Goose ACP wire types. +//! +//! These wire types keep a single source of truth for Goose's custom +//! `_goose/*` JSON-RPC methods. + +pub mod custom_notifications; +pub mod custom_requests; diff --git a/crates/goose-sdk/.gitignore b/crates/goose-sdk/.gitignore new file mode 100644 index 0000000000..0916a2813c --- /dev/null +++ b/crates/goose-sdk/.gitignore @@ -0,0 +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 5cdfc6bb07..48cdfb7cab 100644 --- a/crates/goose-sdk/Cargo.toml +++ b/crates/goose-sdk/Cargo.toml @@ -1,22 +1,49 @@ [package] name = "goose-sdk" -version.workspace = true +version = "0.1.0-alpha.0" edition.workspace = true rust-version.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -description = "Rust SDK for talking to Goose over the Agent Client Protocol (ACP)" +description = "Rust SDK for Goose with optional uniffi bindings for Python/Kotlin" + +[lib] +name = "goose_sdk" +crate-type = ["cdylib", "staticlib", "rlib"] + +[[bin]] +name = "goose-uniffi-bindgen" +path = "src/bin/uniffi-bindgen.rs" +required-features = ["uniffi"] + +[features] +default = [] +uniffi = [ + "dep:uniffi", + "dep:thiserror", + "dep:anyhow", + "dep:goose-providers", + "dep:futures", + "dep:serde_json", + "dep:tokio", +] [dependencies] +goose-sdk-types = { version = "0.1.0-alpha.0", path = "../goose-sdk-types" } agent-client-protocol = { workspace = true, features = ["unstable"] } agent-client-protocol-schema = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -schemars = { workspace = true, features = ["derive"] } + +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 new file mode 100644 index 0000000000..e10329d358 --- /dev/null +++ b/crates/goose-sdk/README.md @@ -0,0 +1,26 @@ +# goose-sdk + +The bindings layer for Goose. It houses the shared types used for both ACP and +SDK access, and exposes a cross-language version of the Goose API. + +With `--features uniffi` the crate compiles to native bindings for Python and +Kotlin (namespace `goose` / `io.aaif.goose`). The UniFFI surface currently lets +callers construct declarative providers from JSON and stream provider +completions. + +```bash +just python # build bindings + run examples/uniffi/provider.py +just kotlin # build bindings + run examples/uniffi/Provider.kt +``` + +## Python package + +The PyPI package is published as `goose-sdk` and imports as `goose`. +Build a local wheel from the repository root with: + +```bash +just --justfile crates/goose-sdk/justfile python-wheel +``` + +This regenerates the UniFFI Python bindings, copies the release native library +into the package, and writes the wheel to `crates/goose-sdk/python/dist/`. diff --git a/crates/goose-sdk/examples/acp_client.rs b/crates/goose-sdk/examples/acp_client.rs index 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/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/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 new file mode 100644 index 0000000000..407b48ef00 --- /dev/null +++ b/crates/goose-sdk/justfile @@ -0,0 +1,157 @@ +set shell := ["bash", "-cu"] +set working-directory := '../..' + +lib_ext := if os() == "macos" { "dylib" } else if os() == "windows" { "dll" } else { "so" } +lib_prefix := if os() == "windows" { "" } else { "lib" } +lib_name := lib_prefix + "goose_sdk." + lib_ext +debug_lib_dir := "./target/debug" +release_lib_dir := "./target/release" +debug_lib_path := debug_lib_dir / lib_name +release_lib_path := release_lib_dir / lib_name +debug_bindgen := "./target/debug/goose-uniffi-bindgen" +release_bindgen := "./target/release/goose-uniffi-bindgen" +config := "./crates/goose-sdk/uniffi.toml" +gen_dir := "./crates/goose-sdk/generated" +examples_dir := "./crates/goose-sdk/examples/uniffi" +python_dir := "./crates/goose-sdk/python" +python_package_dir := python_dir / "src/goose" + +_default: + @just --list --justfile {{justfile()}} + +_build: + cargo build -p goose-sdk --features uniffi -q + +_build-release: + cargo build -p goose-sdk --features uniffi --release -q + +_generate lang: _build + {{debug_bindgen}} generate --library {{debug_lib_path}} --config {{config}} --language {{lang}} --no-format --out-dir {{gen_dir}} 2>/dev/null + cp {{debug_lib_path}} {{gen_dir}}/ + touch {{gen_dir}}/__init__.py + +python: (_generate "python") + DYLD_LIBRARY_PATH={{debug_lib_dir}} LD_LIBRARY_PATH={{debug_lib_dir}} \ + python3 {{examples_dir}}/provider.py + +kotlin: (_generate "kotlin") + @if [ ! -f {{examples_dir}}/jna.jar ]; then \ + curl -sSL -o {{examples_dir}}/jna.jar \ + https://repo1.maven.org/maven2/net/java/dev/jna/jna/5.14.0/jna-5.14.0.jar; \ + fi + kotlinc -cp {{examples_dir}}/jna.jar -nowarn \ + {{gen_dir}}/io/aaif/goose/goose.kt \ + {{examples_dir}}/Provider.kt \ + -include-runtime -d {{examples_dir}}/provider.jar 2>/dev/null + java -Djna.library.path={{debug_lib_dir}} \ + --enable-native-access=ALL-UNNAMED \ + -cp {{examples_dir}}/provider.jar:{{examples_dir}}/jna.jar aaif.example.ProviderKt + +python-bindings profile="debug": + @case "{{profile}}" in \ + debug) cargo build -p goose-sdk --features uniffi -q; bindgen={{debug_bindgen}}; lib_path={{debug_lib_path}} ;; \ + release) cargo build -p goose-sdk --features uniffi --release -q; bindgen={{release_bindgen}}; lib_path={{release_lib_path}} ;; \ + *) echo 'profile must be debug or release' >&2; exit 1 ;; \ + esac; \ + rm -rf {{python_package_dir}}; \ + mkdir -p {{python_package_dir}}; \ + "$bindgen" generate --library "$lib_path" --config {{config}} --language python --no-format --out-dir {{python_package_dir}} 2>/dev/null; \ + mv {{python_package_dir}}/goose.py {{python_package_dir}}/__init__.py; \ + cp "$lib_path" {{python_package_dir}}/; \ + touch {{python_package_dir}}/py.typed + +python-wheel: (python-bindings "release") + rm -rf {{python_dir}}/build {{python_dir}}/dist {{python_dir}}/*.egg-info {{python_dir}}/src/*.egg-info + cd {{python_dir}} && UV_NO_CONFIG=1 PIP_CONFIG_FILE=/dev/null PIP_INDEX_URL=https://pypi.org/simple uvx --default-index https://pypi.org/simple --from build pyproject-build --wheel + +python-check: python-wheel + python3 -m pip install --force-reinstall {{python_dir}}/dist/*.whl + python3 -c 'import goose; print(goose.__name__)' + +python-publish repository="pypi": python-wheel + UV_NO_CONFIG=1 PIP_CONFIG_FILE=/dev/null PIP_INDEX_URL=https://pypi.org/simple uvx --default-index https://pypi.org/simple twine check {{python_dir}}/dist/*.whl + @if [ "{{repository}}" = "pypi" ]; then \ + UV_NO_CONFIG=1 PIP_CONFIG_FILE=/dev/null PIP_INDEX_URL=https://pypi.org/simple uvx --default-index https://pypi.org/simple twine upload {{python_dir}}/dist/*.whl; \ + else \ + UV_NO_CONFIG=1 PIP_CONFIG_FILE=/dev/null PIP_INDEX_URL=https://pypi.org/simple uvx --default-index https://pypi.org/simple twine upload --repository {{repository}} {{python_dir}}/dist/*.whl; \ + fi + +crates-publish dry_run="true": + @set -euo pipefail; \ + dry_run_flag=""; \ + if [ "{{dry_run}}" = "true" ]; then \ + dry_run_flag="--dry-run"; \ + elif [ "{{dry_run}}" != "false" ]; then \ + echo 'dry_run must be true or false' >&2; \ + exit 1; \ + fi; \ + for crate in \ + goose-provider-types \ + goose-sdk-types \ + goose-download-manager \ + goose-local-inference \ + goose-providers \ + goose-sdk; do \ + cargo publish -p "$crate" $dry_run_flag --allow-dirty; \ + done + +bump-version rust_version: + #!/usr/bin/env bash + set -euo pipefail + python3 - "{{rust_version}}" <<'PY' + import re + import sys + from pathlib import Path + + rust_version = sys.argv[1] + match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:-alpha\.(\d+))?", rust_version) + if not match: + raise SystemExit("rust_version must look like 0.1.0 or 0.1.0-alpha.0") + + major, minor, patch, alpha = match.groups() + python_version = f"{major}.{minor}.{patch}" if alpha is None else f"{major}.{minor}.{patch}a{alpha}" + + crate_names = [ + "goose-provider-types", + "goose-sdk-types", + "goose-download-manager", + "goose-local-inference", + "goose-providers", + "goose-sdk", + ] + crate_paths = {name: Path("crates") / name / "Cargo.toml" for name in crate_names} + + def replace_unique(path: Path, pattern: str, replacement: str) -> None: + text = path.read_text() + new_text, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) + if count != 1: + raise SystemExit(f"expected one match for {pattern!r} in {path}") + path.write_text(new_text) + + for path in crate_paths.values(): + replace_unique(path, r'^version\s*=\s*"[^"]+"', f'version = "{rust_version}"') + + dependency_names = [ + "goose-provider-types", + "goose-sdk-types", + "goose-download-manager", + "goose-local-inference", + "goose-providers", + ] + for path in crate_paths.values(): + text = path.read_text() + for dependency in dependency_names: + text = re.sub( + rf'({re.escape(dependency)}\s*=\s*[^\n]*version\s*=\s*)"[^"]+"', + rf'\1"{rust_version}"', + text, + ) + path.write_text(text) + + pyproject = Path("crates/goose-sdk/python/pyproject.toml") + replace_unique(pyproject, r'^version\s*=\s*"[^"]+"', f'version = "{python_version}"') + + print(f"Rust crates: {rust_version}") + print(f"Python package: {python_version}") + PY + cargo fmt --all diff --git a/crates/goose-sdk/python/README.md b/crates/goose-sdk/python/README.md 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/bin/uniffi-bindgen.rs b/crates/goose-sdk/src/bin/uniffi-bindgen.rs new file mode 100644 index 0000000000..f6cff6cf1d --- /dev/null +++ b/crates/goose-sdk/src/bin/uniffi-bindgen.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/crates/goose-sdk/src/bindings.rs b/crates/goose-sdk/src/bindings.rs new file mode 100644 index 0000000000..b44dd6601c --- /dev/null +++ b/crates/goose-sdk/src/bindings.rs @@ -0,0 +1,231 @@ +//! In-process uniffi bindings for the Goose SDK. +//! +//! This is the API surface exposed to Python and Kotlin. It currently focuses +//! on declarative providers: consumers can construct a provider from JSON and +//! stream completions from it. + +use std::sync::{Arc, Mutex}; + +use futures::StreamExt; +use goose_providers::{ + base::{MessageStream, Provider}, + conversation::message::Message, + declarative::EnvKeyResolver, + model::ModelConfig, +}; + +/// Errors surfaced across the uniffi boundary. +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum GooseError { + #[error("{0}")] + Generic(String), +} + +impl From for GooseError { + fn from(error: anyhow::Error) -> Self { + Self::Generic(error.to_string()) + } +} + +impl From for GooseError { + fn from(error: goose_providers::errors::ProviderError) -> Self { + Self::Generic(error.to_string()) + } +} + +impl From for GooseError { + fn from(error: serde_json::Error) -> Self { + Self::Generic(error.to_string()) + } +} + +/// A text message passed to a provider. +#[derive(Debug, Clone, uniffi::Record)] +pub struct ProviderMessage { + pub role: MessageRole, + pub text: String, +} + +/// Supported message roles for provider requests and streamed responses. +#[derive(Debug, Clone, uniffi::Enum)] +pub enum MessageRole { + User, + Assistant, +} + +impl ProviderMessage { + fn to_goose_message(&self) -> Message { + match self.role { + MessageRole::User => Message::user().with_text(&self.text), + MessageRole::Assistant => Message::assistant().with_text(&self.text), + } + } +} + +/// Model selection and optional generation settings for a provider request. +#[derive(Debug, Clone, uniffi::Record)] +pub struct ProviderModelConfig { + pub model_name: String, + #[uniffi(default = None)] + pub context_limit: Option, + #[uniffi(default = None)] + pub temperature: Option, + #[uniffi(default = None)] + pub max_tokens: Option, + #[uniffi(default = false)] + pub toolshim: bool, + #[uniffi(default = None)] + pub toolshim_model: Option, + /// Provider-specific request parameters as a JSON object string. + #[uniffi(default = None)] + pub request_params_json: Option, + #[uniffi(default = None)] + pub reasoning: Option, +} + +impl ProviderModelConfig { + fn to_goose_model_config(&self) -> Result { + let mut config = ModelConfig::new(&self.model_name) + .with_context_limit(self.context_limit.map(|limit| limit as usize)) + .with_temperature(self.temperature) + .with_max_tokens(self.max_tokens) + .with_toolshim(self.toolshim) + .with_toolshim_model(self.toolshim_model.clone()); + + if let Some(request_params_json) = &self.request_params_json { + let request_params = serde_json::from_str(request_params_json)?; + config = config.with_merged_request_params(request_params); + } + + config.reasoning = self.reasoning; + Ok(config) + } +} + +/// One item yielded by a provider stream. +#[derive(Debug, Clone, uniffi::Record)] +pub struct ProviderStreamChunk { + /// The concatenated text content in this message chunk, if one was emitted. + pub text: Option, + /// Full Goose message JSON for callers that need non-text content such as tool requests. + pub message_json: Option, + /// Provider usage JSON when the provider emits usage metadata. + pub usage_json: Option, +} + +/// A declarative Goose provider constructed from provider JSON. +#[derive(uniffi::Object)] +pub struct DeclarativeProvider { + provider: Box, + runtime: Arc, +} + +#[uniffi::export] +impl DeclarativeProvider { + /// Construct a declarative provider using the process environment to resolve + /// configured API key environment variables. + #[uniffi::constructor] + pub fn from_json(json: String) -> Result, GooseError> { + let provider = goose_providers::declarative::from_json(&json, None, EnvKeyResolver {})?; + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| GooseError::Generic(error.to_string()))?; + + Ok(Arc::new(Self { + provider, + runtime: Arc::new(runtime), + })) + } + + pub fn name(&self) -> String { + self.provider.get_name().to_string() + } + + /// Start a streaming completion request. Tools are not yet exposed over the + /// uniffi boundary, so this calls providers with an empty tool list. + pub fn stream( + &self, + model: ProviderModelConfig, + system: String, + messages: Vec, + ) -> Result, GooseError> { + let model = model.to_goose_model_config()?; + let messages = messages + .iter() + .map(ProviderMessage::to_goose_message) + .collect::>(); + let stream = + self.runtime + .block_on(self.provider.stream(&model, &system, &messages, &[]))?; + + Ok(Arc::new(DeclarativeProviderStream { + stream: Mutex::new(stream), + runtime: Arc::clone(&self.runtime), + })) + } +} + +/// A blocking iterator over provider stream chunks. +#[derive(uniffi::Object)] +pub struct DeclarativeProviderStream { + stream: Mutex, + runtime: Arc, +} + +#[uniffi::export] +impl DeclarativeProviderStream { + /// Return the next stream chunk, or `None` when the stream is exhausted. + pub fn next(&self) -> Result, GooseError> { + let mut stream = self + .stream + .lock() + .map_err(|_| GooseError::Generic("provider stream lock poisoned".to_string()))?; + + let Some((message, usage)) = self.runtime.block_on(stream.next()).transpose()? else { + return Ok(None); + }; + + let text = message.as_ref().map(Message::as_concat_text); + let message_json = message.as_ref().map(serde_json::to_string).transpose()?; + let usage_json = usage.as_ref().map(serde_json::to_string).transpose()?; + + Ok(Some(ProviderStreamChunk { + text, + message_json, + usage_json, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn model_config_rejects_invalid_request_params_json() { + let config = ProviderModelConfig { + model_name: "test".to_string(), + context_limit: None, + temperature: None, + max_tokens: None, + toolshim: false, + toolshim_model: None, + request_params_json: Some("not json".to_string()), + reasoning: None, + }; + + assert!(config.to_goose_model_config().is_err()); + } + + #[test] + fn provider_message_converts_user_text() { + let message = ProviderMessage { + role: MessageRole::User, + text: "what is the capital of France?".to_string(), + } + .to_goose_message(); + + assert_eq!(message.as_concat_text(), "what is the capital of France?"); + } +} diff --git a/crates/goose-sdk/src/lib.rs b/crates/goose-sdk/src/lib.rs index 6c1c1bf50c..4742ea2de8 100644 --- a/crates/goose-sdk/src/lib.rs +++ b/crates/goose-sdk/src/lib.rs @@ -1 +1,19 @@ -pub mod custom_requests; +//! Goose SDK. +//! +//! With default features this crate re-exports the shared SDK wire types from +//! `goose-sdk-types` so you can build an Agent Client Protocol (ACP) client +//! that talks to `goose acp` over stdio. +//! +//! With `--features uniffi` the crate additionally compiles as a +//! `cdylib`/`staticlib` and exposes an in-process API to Python and Kotlin via +//! [uniffi-rs](https://github.com/mozilla/uniffi-rs). The current uniffi surface +//! lets callers construct declarative providers from JSON and stream provider +//! completions. + +pub use goose_sdk_types::{custom_notifications, custom_requests}; + +#[cfg(feature = "uniffi")] +uniffi::setup_scaffolding!("goose"); + +#[cfg(feature = "uniffi")] +pub mod bindings; diff --git a/crates/goose-sdk/uniffi.toml b/crates/goose-sdk/uniffi.toml new file mode 100644 index 0000000000..aef1f592c9 --- /dev/null +++ b/crates/goose-sdk/uniffi.toml @@ -0,0 +1,2 @@ +[bindings.kotlin] +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 c1f04fc6dd..0000000000 --- a/crates/goose-server/Cargo.toml +++ /dev/null @@ -1,112 +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"] -telemetry = ["goose/telemetry"] -nostr = ["goose/nostr"] -otel = ["goose/otel"] -system-keyring = ["goose/system-keyring"] -portable-default = ["rustls-tls", "aws-providers", "telemetry", "otel"] -rustls-tls = [ - "reqwest/rustls", - "tokio-tungstenite/rustls-tls-native-roots", - "axum-server/tls-rustls", - "dep:rustls", - "dep:aws-lc-rs", - "goose/rustls-tls", - "goose-mcp/rustls-tls", -] -native-tls = [ - "reqwest/native-tls", - "tokio-tungstenite/native-tls", - "axum-server/tls-openssl", - "dep:openssl", - "goose/native-tls", - "goose-mcp/native-tls", -] - -[dependencies] -goose = { path = "../goose", default-features = false } -goose-mcp = { path = "../goose-mcp", default-features = false } -rmcp = { workspace = true } -axum = { workspace = true, features = ["ws", "macros"] } -tokio = { workspace = true } -chrono = { workspace = true } -tower-http = { workspace = true, features = ["cors"] } -serde = { workspace = true } -serde_json = { workspace = true, features = ["preserve_order"] } -futures = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json", "time"] } -tokio-stream = { workspace = true } -anyhow = { workspace = true } -bytes = { workspace = true } -http = { workspace = true } -base64 = { workspace = true } -config = { version = "0.15", default-features = false, features = ["toml"] } -thiserror = { workspace = true } -clap = { workspace = true } -serde_yaml = { workspace = true } -utoipa = { workspace = true, features = ["axum_extras", "chrono"] } -reqwest = { workspace = true, features = ["json", "blocking", "multipart", "system-proxy"] } -tokio-util = { workspace = true } -serde_path_to_error = { version = "0.1.8", default-features = false } -tokio-tungstenite = { version = "0.29", default-features = false, features = ["connect"] } -url = { workspace = true } -rand = { workspace = true } -hex = { version = "0.4.3", default-features = false, features = ["std"] } -subtle = { version = "2.5", default-features = false, features = ["std"] } -socket2 = { version = "0.6", default-features = false } -fs2 = { workspace = true } -rustls = { workspace = true, optional = true } -uuid = { workspace = true } -rcgen = { version = "0.14", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] } -axum-server = { version = "0.8", default-features = false } -aws-lc-rs = { version = "1.17", default-features = false, optional = true } -openssl = { version = "0.10.66", default-features = false, optional = true } -pem = { version = "3.0.2", default-features = false, features = ["std"] } - -[target.'cfg(windows)'.dependencies] -winreg = { version = "0.56", default-features = false } - -[[bin]] -name = "goosed" -path = "src/main.rs" - -[[bin]] -name = "generate_schema" -path = "src/bin/generate_schema.rs" - -[dev-dependencies] -tower = { version = "0.5.2", default-features = false } - -[package.metadata.cargo-machete] -ignored = [ - # Used only in windows - "winreg", -] diff --git a/crates/goose-server/build.rs b/crates/goose-server/build.rs 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/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 ef9be3ac70..0000000000 --- a/crates/goose-server/src/commands/agent.rs +++ /dev/null @@ -1,166 +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"))?; - - goose::security::set_security_defaults(); - - let settings = configuration::Settings::new()?; - - let secret_key = std::env::var("GOOSE_SERVER__SECRET_KEY") - .unwrap_or_else(|_| hex::encode(rand::random::<[u8; 32]>())); - - boot_marker("appstate init start"); - let app_state = state::AppState::new(settings.tls).await?; - - // Share the server secret with the tunnel manager so it uses the same - // key for forwarded requests, without mutating the process environment. - app_state - .tunnel_manager - .set_server_secret(secret_key.clone()) - .await; - - let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any); - - // TODO(acp-migration): When ui/desktop launches `goose serve` directly, - // move any goosed-only ACP setup into the goose serve path before deleting - // this bridge. In particular, verify everything ACP currently gets from - // goosed startup/AppState initialization, including builtin extension - // registration and the desktop platform identity. - let acp_server = Arc::new(AcpServer::new(AcpServerFactoryConfig { - builtins: vec!["developer".to_string()], - data_dir: Paths::data_dir(), - config_dir: Paths::config_dir(), - goose_platform: GoosePlatform::GooseDesktop, - additional_source_roots: Vec::new(), - })); - - let rest_router = crate::routes::configure(app_state.clone(), secret_key.clone()).layer( - middleware::from_fn_with_state(secret_key.clone(), check_token), - ); - let acp_router = create_acp_router(acp_server).layer(middleware::from_fn_with_state( - secret_key.clone(), - check_acp_token, - )); - - let app = rest_router.merge(acp_router).layer(cors); - - let addr = settings.socket_addr(); - - let tunnel_manager = app_state.tunnel_manager.clone(); - tokio::spawn(async move { - tunnel_manager.check_auto_start().await; - }); - - let gateway_manager = app_state.gateway_manager.clone(); - tokio::spawn(async move { - gateway_manager.check_auto_start().await; - }); - - if settings.tls { - #[cfg(any(feature = "rustls-tls", feature = "native-tls"))] - { - boot_marker("tls setup start"); - let tls_setup = setup_tls( - settings.tls_cert_path.as_deref(), - settings.tls_key_path.as_deref(), - ) - .await?; - - let handle = Handle::new(); - let shutdown_handle = handle.clone(); - tokio::spawn(async move { - shutdown_signal().await; - shutdown_handle.graceful_shutdown(None); - }); - - info!("listening on https://{}", addr); - boot_marker("listening"); - - #[cfg(feature = "rustls-tls")] - axum_server::bind_rustls(addr, tls_setup.config) - .handle(handle) - .serve(app.into_make_service()) - .await?; - - #[cfg(feature = "native-tls")] - axum_server::bind_openssl(addr, tls_setup.config) - .handle(handle) - .serve(app.into_make_service()) - .await?; - } - - #[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))] - { - anyhow::bail!( - "TLS was requested but no TLS backend is enabled. \ - Enable the `rustls-tls` or `native-tls` feature." - ); - } - } else { - boot_marker("tcp bind start"); - let listener = tokio::net::TcpListener::bind(addr).await?; - - info!("listening on http://{}", addr); - boot_marker("listening"); - - axum::serve(listener, app) - .with_graceful_shutdown(async { shutdown_signal().await }) - .await?; - } - - #[cfg(feature = "otel")] - if goose::otel::otlp::is_otlp_initialized() { - goose::otel::otlp::shutdown_otlp(); - } - - info!("server shutdown complete"); - Ok(()) -} diff --git a/crates/goose-server/src/commands/mod.rs b/crates/goose-server/src/commands/mod.rs 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 88af03127c..0000000000 --- a/crates/goose-server/src/logging.rs +++ /dev/null @@ -1,17 +0,0 @@ -use anyhow::Result; -use tracing_subscriber::util::SubscriberInitExt; - -/// Sets up the logging infrastructure for the server. -/// Logs go to a JSON file and a pretty console layer on stderr. -pub fn setup_logging(name: Option<&str>) -> Result<()> { - let config = goose::logging::LoggingConfig { - component: "server", - name, - extra_directives: &["goose_server=info", "tower_http=info"], - console: true, - json: false, - }; - let subscriber = goose::logging::build_logging_subscriber(&config)?; - subscriber.try_init()?; - Ok(()) -} diff --git a/crates/goose-server/src/main.rs b/crates/goose-server/src/main.rs 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 5749b6440a..0000000000 --- a/crates/goose-server/src/openapi.rs +++ /dev/null @@ -1,721 +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::Conversation; -use goose::download_manager::{DownloadProgress, DownloadStatus}; -use goose::model::{ModelConfig, ThinkingEffort}; -use goose::permission::permission_confirmation::{Permission, PrincipalType}; -use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata, ProviderType}; -use goose::session::{Session, SessionInsights, SessionType, SystemInfo}; -use rmcp::model::{ - Annotations, Content, EmbeddedResource, Icon, IconTheme, ImageContent, JsonObject, - RawAudioContent, RawContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, - ResourceContents, Role, TaskSupport, TextContent, Tool, ToolAnnotations, ToolExecution, -}; -use utoipa::{OpenApi, ToSchema}; - -use goose::config::declarative_providers::{ - DeclarativeProviderConfig, EnvVarConfig, LoadedProvider, ProviderEngine, -}; -use goose::conversation::message::{ - ActionRequired, ActionRequiredData, FrontendToolRequest, InferenceMetadata, Message, - MessageContent, MessageMetadata, RedactedThinkingContent, SystemNotificationContent, - SystemNotificationType, ThinkingContent, TokenState, ToolConfirmationRequest, ToolRequest, - ToolResponse, -}; - -use crate::routes::recipe_utils::RecipeManifest; -use crate::routes::reply::MessageEvent; -use utoipa::openapi::schema::{ - AdditionalProperties, AnyOfBuilder, ArrayBuilder, ObjectBuilder, OneOfBuilder, Schema, - SchemaFormat, SchemaType, -}; -use utoipa::openapi::{AllOfBuilder, Ref, RefOr}; - -macro_rules! derive_utoipa { - ($inner_type:ident as $schema_name:ident) => { - pub struct $schema_name {} - - impl<'__s> ToSchema<'__s> for $schema_name { - fn schema() -> (&'__s str, utoipa::openapi::RefOr) { - let settings = rmcp::schemars::generate::SchemaSettings::openapi3(); - let generator = settings.into_generator(); - let schema = generator.into_root_schema_for::<$inner_type>(); - let schema = convert_schemars_to_utoipa(schema); - (stringify!($inner_type), schema) - } - - fn aliases() -> Vec<(&'__s str, utoipa::openapi::schema::Schema)> { - Vec::new() - } - } - }; - ($inner_type:ident as $schema_name:ident => $output_name:expr) => { - pub struct $schema_name {} - - impl<'__s> ToSchema<'__s> for $schema_name { - fn schema() -> (&'__s str, utoipa::openapi::RefOr) { - let settings = rmcp::schemars::generate::SchemaSettings::openapi3(); - let generator = settings.into_generator(); - let schema = generator.into_root_schema_for::<$inner_type>(); - let schema = convert_schemars_to_utoipa(schema); - ($output_name, schema) - } - - fn aliases() -> Vec<(&'__s str, utoipa::openapi::schema::Schema)> { - Vec::new() - } - } - }; -} - -fn convert_schemars_to_utoipa(schema: rmcp::schemars::Schema) -> RefOr { - if let Some(true) = schema.as_bool() { - return RefOr::T(Schema::Object(ObjectBuilder::new().build())); - } - - if let Some(false) = schema.as_bool() { - return RefOr::T(Schema::Object(ObjectBuilder::new().build())); - } - - if let Some(obj) = schema.as_object() { - return convert_json_object_to_utoipa(obj); - } - - RefOr::T(Schema::Object(ObjectBuilder::new().build())) -} - -fn convert_json_object_to_utoipa( - obj: &serde_json::Map, -) -> RefOr { - use serde_json::Value; - - if let Some(Value::String(reference)) = obj.get("$ref") { - return RefOr::Ref(Ref::new(reference.clone())); - } - - if let Some(Value::Array(one_of)) = obj.get("oneOf") { - let mut builder = OneOfBuilder::new(); - for item in one_of { - if let Ok(schema) = rmcp::schemars::Schema::try_from(item.clone()) { - builder = builder.item(convert_schemars_to_utoipa(schema)); - } - } - return RefOr::T(Schema::OneOf(builder.build())); - } - - // Handle the discriminated union pattern from schemars: an object with - // `type`, `properties`, `required` AND `allOf` (e.g. each variant of a - // `#[serde(tag = "type")]` enum). We merge the inline object (which carries - // the discriminator property) with the `allOf` refs into a single `allOf`. - if let Some(Value::Array(all_of)) = obj.get("allOf") { - let has_inline_properties = obj.contains_key("properties") || obj.contains_key("type"); - if has_inline_properties { - let mut builder = AllOfBuilder::new(); - // Build an object schema from the inline properties/required - let mut obj_without_allof = obj.clone(); - obj_without_allof.remove("allOf"); - builder = builder.item(convert_json_object_to_utoipa(&obj_without_allof)); - for item in all_of { - if let Ok(schema) = rmcp::schemars::Schema::try_from(item.clone()) { - builder = builder.item(convert_schemars_to_utoipa(schema)); - } - } - return RefOr::T(Schema::AllOf(builder.build())); - } - - let mut builder = AllOfBuilder::new(); - for item in all_of { - if let Ok(schema) = rmcp::schemars::Schema::try_from(item.clone()) { - builder = builder.item(convert_schemars_to_utoipa(schema)); - } - } - return RefOr::T(Schema::AllOf(builder.build())); - } - - if let Some(Value::Array(any_of)) = obj.get("anyOf") { - let mut builder = AnyOfBuilder::new(); - for item in any_of { - if let Ok(schema) = rmcp::schemars::Schema::try_from(item.clone()) { - builder = builder.item(convert_schemars_to_utoipa(schema)); - } - } - return RefOr::T(Schema::AnyOf(builder.build())); - } - - match obj.get("type") { - Some(Value::String(type_str)) => convert_typed_schema(type_str, obj), - Some(Value::Array(types)) => { - let mut builder = AnyOfBuilder::new(); - for type_val in types { - if let Value::String(type_str) = type_val { - builder = builder.item(convert_typed_schema(type_str, obj)); - } - } - RefOr::T(Schema::AnyOf(builder.build())) - } - None => RefOr::T(Schema::Object(ObjectBuilder::new().build())), - _ => RefOr::T(Schema::Object(ObjectBuilder::new().build())), - } -} - -fn convert_typed_schema( - type_str: &str, - obj: &serde_json::Map, -) -> RefOr { - use serde_json::Value; - - match type_str { - "object" => { - let mut object_builder = ObjectBuilder::new(); - - if let Some(Value::Object(properties)) = obj.get("properties") { - for (name, prop_value) in properties { - if let Ok(prop_schema) = rmcp::schemars::Schema::try_from(prop_value.clone()) { - let prop = convert_schemars_to_utoipa(prop_schema); - object_builder = object_builder.property(name, prop); - } - } - } - - if let Some(Value::Array(required)) = obj.get("required") { - for req in required { - if let Value::String(field_name) = req { - object_builder = object_builder.required(field_name); - } - } - } - - if let Some(additional) = obj.get("additionalProperties") { - match additional { - Value::Bool(false) => { - object_builder = object_builder - .additional_properties(Some(AdditionalProperties::FreeForm(false))); - } - Value::Bool(true) => { - object_builder = object_builder - .additional_properties(Some(AdditionalProperties::FreeForm(true))); - } - _ => { - if let Ok(schema) = rmcp::schemars::Schema::try_from(additional.clone()) { - let schema = convert_schemars_to_utoipa(schema); - object_builder = object_builder - .additional_properties(Some(AdditionalProperties::RefOr(schema))); - } - } - } - } - - RefOr::T(Schema::Object(object_builder.build())) - } - "array" => { - let mut array_builder = ArrayBuilder::new(); - - if let Some(items) = obj.get("items") { - match items { - Value::Object(_) | Value::Bool(_) => { - if let Ok(item_schema) = rmcp::schemars::Schema::try_from(items.clone()) { - let item_schema = convert_schemars_to_utoipa(item_schema); - array_builder = array_builder.items(item_schema); - } - } - Value::Array(item_schemas) => { - let mut any_of = AnyOfBuilder::new(); - for item in item_schemas { - if let Ok(schema) = rmcp::schemars::Schema::try_from(item.clone()) { - any_of = any_of.item(convert_schemars_to_utoipa(schema)); - } - } - let any_of_schema = RefOr::T(Schema::AnyOf(any_of.build())); - array_builder = array_builder.items(any_of_schema); - } - _ => {} - } - } - - if let Some(Value::Number(min_items)) = obj.get("minItems") { - if let Some(min) = min_items.as_u64() { - array_builder = array_builder.min_items(Some(min as usize)); - } - } - if let Some(Value::Number(max_items)) = obj.get("maxItems") { - if let Some(max) = max_items.as_u64() { - array_builder = array_builder.max_items(Some(max as usize)); - } - } - - RefOr::T(Schema::Array(array_builder.build())) - } - "string" => { - let mut object_builder = ObjectBuilder::new().schema_type(SchemaType::String); - - if let Some(Value::Array(enum_values)) = obj.get("enum") { - let values: Vec = enum_values - .iter() - .filter_map(|v| { - if let Value::String(s) = v { - Some(Value::String(s.clone())) - } else { - None - } - }) - .collect(); - if !values.is_empty() { - object_builder = object_builder.enum_values(Some(values)); - } - } - - if let Some(Value::Number(min_length)) = obj.get("minLength") { - if let Some(min) = min_length.as_u64() { - object_builder = object_builder.min_length(Some(min as usize)); - } - } - if let Some(Value::Number(max_length)) = obj.get("maxLength") { - if let Some(max) = max_length.as_u64() { - object_builder = object_builder.max_length(Some(max as usize)); - } - } - if let Some(Value::String(pattern)) = obj.get("pattern") { - object_builder = object_builder.pattern(Some(pattern.clone())); - } - if let Some(Value::String(format)) = obj.get("format") { - object_builder = object_builder.format(Some(SchemaFormat::Custom(format.clone()))); - } - - RefOr::T(Schema::Object(object_builder.build())) - } - "number" => { - let mut object_builder = ObjectBuilder::new().schema_type(SchemaType::Number); - - if let Some(Value::Number(minimum)) = obj.get("minimum") { - if let Some(min) = minimum.as_f64() { - object_builder = object_builder.minimum(Some(min)); - } - } - if let Some(Value::Number(maximum)) = obj.get("maximum") { - if let Some(max) = maximum.as_f64() { - object_builder = object_builder.maximum(Some(max)); - } - } - if let Some(Value::Number(exclusive_minimum)) = obj.get("exclusiveMinimum") { - if let Some(min) = exclusive_minimum.as_f64() { - object_builder = object_builder.exclusive_minimum(Some(min)); - } - } - if let Some(Value::Number(exclusive_maximum)) = obj.get("exclusiveMaximum") { - if let Some(max) = exclusive_maximum.as_f64() { - object_builder = object_builder.exclusive_maximum(Some(max)); - } - } - if let Some(Value::Number(multiple_of)) = obj.get("multipleOf") { - if let Some(mult) = multiple_of.as_f64() { - object_builder = object_builder.multiple_of(Some(mult)); - } - } - - RefOr::T(Schema::Object(object_builder.build())) - } - "integer" => { - let mut object_builder = ObjectBuilder::new().schema_type(SchemaType::Integer); - - if let Some(Value::Number(minimum)) = obj.get("minimum") { - if let Some(min) = minimum.as_f64() { - object_builder = object_builder.minimum(Some(min)); - } - } - if let Some(Value::Number(maximum)) = obj.get("maximum") { - if let Some(max) = maximum.as_f64() { - object_builder = object_builder.maximum(Some(max)); - } - } - if let Some(Value::Number(exclusive_minimum)) = obj.get("exclusiveMinimum") { - if let Some(min) = exclusive_minimum.as_f64() { - object_builder = object_builder.exclusive_minimum(Some(min)); - } - } - if let Some(Value::Number(exclusive_maximum)) = obj.get("exclusiveMaximum") { - if let Some(max) = exclusive_maximum.as_f64() { - object_builder = object_builder.exclusive_maximum(Some(max)); - } - } - if let Some(Value::Number(multiple_of)) = obj.get("multipleOf") { - if let Some(mult) = multiple_of.as_f64() { - object_builder = object_builder.multiple_of(Some(mult)); - } - } - - RefOr::T(Schema::Object(object_builder.build())) - } - "boolean" => RefOr::T(Schema::Object( - ObjectBuilder::new() - .schema_type(SchemaType::Boolean) - .build(), - )), - "null" => RefOr::T(Schema::Object( - ObjectBuilder::new().schema_type(SchemaType::String).build(), - )), - _ => RefOr::T(Schema::Object(ObjectBuilder::new().build())), - } -} - -derive_utoipa!(Role as RoleSchema); -derive_utoipa!(Content as ContentSchema); -derive_utoipa!(RawContent as ContentBlockSchema => "ContentBlock"); -derive_utoipa!(EmbeddedResource as EmbeddedResourceSchema); -derive_utoipa!(ImageContent as ImageContentSchema); -derive_utoipa!(TextContent as TextContentSchema); -derive_utoipa!(RawTextContent as RawTextContentSchema); -derive_utoipa!(RawImageContent as RawImageContentSchema); -derive_utoipa!(RawAudioContent as RawAudioContentSchema); -derive_utoipa!(RawEmbeddedResource as RawEmbeddedResourceSchema); -derive_utoipa!(RawResource as RawResourceSchema); -derive_utoipa!(Tool as ToolSchema); -derive_utoipa!(ToolAnnotations as ToolAnnotationsSchema); -derive_utoipa!(ToolExecution as ToolExecutionSchema); -derive_utoipa!(TaskSupport as TaskSupportSchema); -derive_utoipa!(Annotations as AnnotationsSchema); -derive_utoipa!(ResourceContents as ResourceContentsSchema); -derive_utoipa!(JsonObject as JsonObjectSchema); -derive_utoipa!(Icon as IconSchema); -derive_utoipa!(IconTheme as IconThemeSchema); - -#[derive(OpenApi)] -#[openapi( - paths( - super::routes::status::status, - super::routes::status::system_info, - super::routes::status::diagnostics, - super::routes::mcp_ui_proxy::mcp_ui_proxy, - super::routes::config_management::validate_config, - super::routes::config_management::upsert_config, - super::routes::config_management::remove_config, - super::routes::config_management::read_config, - super::routes::config_management::add_extension, - super::routes::config_management::remove_extension, - super::routes::config_management::get_extensions, - super::routes::config_management::read_all_config, - super::routes::config_management::providers, - super::routes::config_management::get_provider_models, - super::routes::config_management::get_provider_model_info, - super::routes::config_management::get_slash_commands, - super::routes::config_management::upsert_permissions, - super::routes::config_management::create_custom_provider, - super::routes::config_management::get_custom_provider, - super::routes::config_management::update_custom_provider, - super::routes::config_management::remove_custom_provider, - super::routes::config_management::get_provider_catalog, - super::routes::config_management::get_provider_catalog_template, - super::routes::config_management::cleanup_provider_cache, - super::routes::config_management::check_provider, - super::routes::config_management::set_config_provider, - super::routes::config_management::configure_provider_oauth, - super::routes::config_management::get_canonical_model_info, - super::routes::prompts::get_prompts, - super::routes::prompts::get_prompt, - super::routes::prompts::save_prompt, - super::routes::prompts::reset_prompt, - super::routes::agent::start_agent, - super::routes::agent::resume_agent, - super::routes::agent::stop_agent, - super::routes::agent::restart_agent, - super::routes::agent::update_working_dir, - super::routes::agent::get_tools, - super::routes::agent::read_resource, - super::routes::agent::call_tool, - super::routes::agent::list_apps, - super::routes::agent::export_app, - super::routes::agent::import_app, - super::routes::agent::update_from_session, - super::routes::agent::agent_add_extension, - super::routes::agent::agent_remove_extension, - super::routes::agent::update_agent_provider, - super::routes::agent::update_session, - super::routes::action_required::confirm_tool_action, - super::routes::reply::reply, - super::routes::session_events::session_events, - super::routes::session_events::session_reply, - super::routes::session_events::session_cancel, - super::routes::session::list_sessions, - super::routes::session::search_sessions, - super::routes::session::get_session, - super::routes::session::get_session_insights, - super::routes::session::update_session_name, - super::routes::session::delete_session, - super::routes::session::export_session, - super::routes::session::import_session, - super::routes::session::share_session_nostr, - super::routes::session::import_session_nostr, - super::routes::session::update_session_user_recipe_values, - super::routes::session::fork_session, - super::routes::session::get_session_extensions, - super::routes::schedule::create_schedule, - super::routes::schedule::list_schedules, - super::routes::schedule::delete_schedule, - super::routes::schedule::update_schedule, - super::routes::schedule::run_now_handler, - super::routes::schedule::pause_schedule, - super::routes::schedule::unpause_schedule, - super::routes::schedule::kill_running_job, - super::routes::schedule::inspect_running_job, - super::routes::schedule::sessions_handler, - super::routes::recipe::create_recipe, - super::routes::recipe::encode_recipe, - super::routes::recipe::decode_recipe, - super::routes::recipe::scan_recipe, - super::routes::recipe::list_recipes, - super::routes::recipe::delete_recipe, - super::routes::recipe::schedule_recipe, - super::routes::recipe::set_recipe_slash_command, - super::routes::recipe::save_recipe, - super::routes::recipe::parse_recipe, - super::routes::recipe::recipe_to_yaml, - super::routes::setup::start_openrouter_setup, - super::routes::setup::start_tetrate_setup, - super::routes::setup::start_nanogpt_setup, - super::routes::tunnel::start_tunnel, - super::routes::tunnel::stop_tunnel, - super::routes::tunnel::get_tunnel_status, - super::routes::telemetry::send_telemetry_event, - super::routes::dictation::transcribe_dictation, - super::routes::dictation::get_dictation_config, - super::routes::features::get_features, - ), - components(schemas( - super::routes::config_management::UpsertConfigQuery, - super::routes::config_management::ConfigKeyQuery, - super::routes::config_management::ConfigResponse, - super::routes::config_management::ProvidersResponse, - super::routes::config_management::ProviderDetails, - super::routes::config_management::SlashCommandsResponse, - super::routes::config_management::SlashCommand, - super::routes::config_management::CommandType, - super::routes::config_management::ExtensionResponse, - super::routes::config_management::ExtensionQuery, - super::routes::config_management::ToolPermission, - super::routes::config_management::UpsertPermissionsQuery, - super::routes::config_management::UpdateCustomProviderRequest, - goose::providers::catalog::ProviderCatalogEntry, - goose::providers::catalog::ProviderTemplate, - goose::providers::catalog::ModelTemplate, - goose::providers::catalog::ModelCapabilities, - super::routes::config_management::CreateCustomProviderResponse, - super::routes::config_management::CheckProviderRequest, - super::routes::config_management::SetProviderRequest, - super::routes::config_management::ModelInfoQuery, - super::routes::config_management::ModelInfoResponse, - super::routes::config_management::ModelInfoData, - super::routes::prompts::PromptsListResponse, - super::routes::prompts::PromptContentResponse, - super::routes::prompts::SavePromptRequest, - goose::prompt_template::Template, - super::routes::action_required::ConfirmToolActionRequest, - super::routes::reply::ChatRequest, - super::routes::session_events::SessionReplyRequest, - super::routes::session_events::SessionReplyResponse, - super::routes::session_events::CancelRequest, - super::routes::session::ImportSessionRequest, - super::routes::session::ShareSessionNostrRequest, - super::routes::session::ShareSessionNostrResponse, - super::routes::session::ImportSessionNostrRequest, - super::routes::session::SessionListResponse, - super::routes::session::UpdateSessionNameRequest, - super::routes::session::UpdateSessionUserRecipeValuesRequest, - super::routes::session::UpdateSessionUserRecipeValuesResponse, - super::routes::session::ForkRequest, - super::routes::session::ForkResponse, - super::routes::session::SessionExtensionsResponse, - Message, - MessageContent, - MessageMetadata, - InferenceMetadata, - TokenState, - ContentSchema, - EmbeddedResourceSchema, - ImageContentSchema, - AnnotationsSchema, - TextContentSchema, - RawTextContentSchema, - RawImageContentSchema, - RawAudioContentSchema, - RawEmbeddedResourceSchema, - RawResourceSchema, - ToolResponse, - ToolRequest, - ToolConfirmationRequest, - ActionRequired, - ActionRequiredData, - ThinkingContent, - RedactedThinkingContent, - FrontendToolRequest, - ResourceContentsSchema, - SystemNotificationType, - SystemNotificationContent, - MessageEvent, - JsonObjectSchema, - RoleSchema, - ProviderMetadata, - ProviderType, - LoadedProvider, - ProviderEngine, - DeclarativeProviderConfig, - EnvVarConfig, - ExtensionEntry, - ExtensionConfig, - ConfigKey, - Envs, - RecipeManifest, - ToolSchema, - ToolAnnotationsSchema, - ToolExecutionSchema, - TaskSupportSchema, - ToolInfo, - PermissionLevel, - Permission, - PrincipalType, - ModelInfo, - ModelConfig, - ThinkingEffort, - super::routes::config_management::ProviderModelInfoQuery, - Session, - goose::config::goose_mode::GooseMode, - SessionInsights, - SessionType, - SystemInfo, - Conversation, - IconSchema, - IconThemeSchema, - goose::session::extension_data::ExtensionData, - super::routes::schedule::CreateScheduleRequest, - super::routes::schedule::UpdateScheduleRequest, - super::routes::schedule::KillJobResponse, - super::routes::schedule::InspectJobResponse, - goose::scheduler::ScheduledJob, - super::routes::schedule::RunNowResponse, - super::routes::schedule::ListSchedulesResponse, - super::routes::schedule::SessionsQuery, - super::routes::schedule::SessionDisplayInfo, - super::routes::recipe::CreateRecipeRequest, - super::routes::recipe::AuthorRequest, - super::routes::recipe::CreateRecipeResponse, - super::routes::recipe::EncodeRecipeRequest, - super::routes::recipe::EncodeRecipeResponse, - super::routes::recipe::DecodeRecipeRequest, - super::routes::recipe::DecodeRecipeResponse, - super::routes::recipe::ScanRecipeRequest, - super::routes::recipe::ScanRecipeResponse, - super::routes::recipe::ListRecipeResponse, - super::routes::recipe::ScheduleRecipeRequest, - super::routes::recipe::SetSlashCommandRequest, - super::routes::recipe::DeleteRecipeRequest, - super::routes::recipe::SaveRecipeRequest, - super::routes::recipe::SaveRecipeResponse, - super::routes::errors::ErrorResponse, - super::routes::recipe::ParseRecipeRequest, - super::routes::recipe::ParseRecipeResponse, - super::routes::recipe::RecipeToYamlRequest, - super::routes::recipe::RecipeToYamlResponse, - goose::recipe::Recipe, - goose::recipe::Author, - goose::recipe::Settings, - goose::recipe::RecipeParameter, - goose::recipe::RecipeParameterInputType, - goose::recipe::RecipeParameterRequirement, - goose::recipe::Response, - goose::recipe::SubRecipe, - goose::agents::types::RetryConfig, - goose::agents::types::SuccessCheck, - super::routes::agent::UpdateProviderRequest, - super::routes::agent::UpdateSessionRequest, - super::routes::agent::GetToolsQuery, - super::routes::agent::ReadResourceRequest, - super::routes::agent::ReadResourceResponse, - super::routes::agent::CallToolRequest, - super::routes::agent::CallToolResponse, - ContentBlockSchema, - super::routes::agent::ListAppsRequest, - super::routes::agent::ListAppsResponse, - super::routes::agent::ImportAppRequest, - super::routes::agent::ImportAppResponse, - super::routes::agent::StartAgentRequest, - super::routes::agent::ResumeAgentRequest, - super::routes::agent::StopAgentRequest, - super::routes::agent::RestartAgentRequest, - super::routes::agent::UpdateWorkingDirRequest, - super::routes::agent::UpdateFromSessionRequest, - super::routes::agent::AddExtensionRequest, - super::routes::agent::RemoveExtensionRequest, - super::routes::agent::ResumeAgentResponse, - super::routes::agent::RestartAgentResponse, - goose::agents::ExtensionLoadResult, - super::routes::setup::SetupResponse, - super::tunnel::TunnelInfo, - super::tunnel::TunnelState, - super::routes::telemetry::TelemetryEventRequest, - goose::goose_apps::GooseApp, - goose::goose_apps::WindowProps, - goose::goose_apps::McpAppResource, - goose::goose_apps::CspMetadata, - goose::goose_apps::PermissionsMetadata, - goose::goose_apps::UiMetadata, - goose::goose_apps::ResourceMetadata, - super::routes::dictation::TranscribeRequest, - super::routes::dictation::TranscribeResponse, - goose::dictation::providers::DictationProvider, - super::routes::dictation::DictationProviderStatus, - super::routes::features::FeaturesResponse, - DownloadProgress, - DownloadStatus, - )) -)] -pub struct ApiDoc; - -#[cfg(feature = "local-inference")] -#[derive(OpenApi)] -#[openapi( - paths( - super::routes::dictation::list_models, - super::routes::dictation::download_model, - super::routes::dictation::get_download_progress, - super::routes::dictation::cancel_download, - super::routes::dictation::delete_model, - super::routes::local_inference::list_local_models, - super::routes::local_inference::sync_featured_models, - super::routes::local_inference::search_hf_models, - super::routes::local_inference::list_builtin_chat_templates, - super::routes::local_inference::get_repo_files, - super::routes::local_inference::download_hf_model, - super::routes::local_inference::get_local_model_download_progress, - super::routes::local_inference::cancel_local_model_download, - super::routes::local_inference::delete_local_model, - super::routes::local_inference::get_model_settings, - super::routes::local_inference::update_model_settings, - ), - components(schemas( - super::routes::dictation::WhisperModelResponse, - super::routes::local_inference::LocalModelResponse, - super::routes::local_inference::ModelDownloadStatus, - super::routes::local_inference::DownloadModelRequest, - goose::providers::local_inference::hf_models::HfModelInfo, - goose::providers::local_inference::hf_models::HfGgufFile, - goose::providers::local_inference::hf_models::HfQuantVariant, - super::routes::local_inference::RepoVariantsResponse, - goose::providers::local_inference::local_model_registry::ModelSettings, - goose::providers::local_inference::local_model_registry::ChatTemplate, - goose::providers::local_inference::local_model_registry::SamplingConfig, - goose::providers::local_inference::local_model_registry::ToolCallingMode, - )) -)] -pub struct LocalInferenceApiDoc; - -#[allow(dead_code)] // Used by generate_schema binary -pub fn generate_schema() -> String { - #[allow(unused_mut)] - let mut api_doc = ApiDoc::openapi(); - - #[cfg(feature = "local-inference")] - api_doc.merge(LocalInferenceApiDoc::openapi()); - - serde_json::to_string_pretty(&api_doc).unwrap() -} diff --git a/crates/goose-server/src/routes/action_required.rs b/crates/goose-server/src/routes/action_required.rs 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 25b27eecf9..0000000000 --- a/crates/goose-server/src/routes/agent.rs +++ /dev/null @@ -1,1466 +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::model::ModelConfig; -use goose::providers::create; -use goose::recipe::Recipe; -use goose::recipe_deeplink; -use goose::session::session_manager::SessionType; -use goose::session::{EnabledExtensionsState, ExtensionState, Session}; -use goose::{ - agents::{extension::ToolInfo, extension_manager::get_parameter_names}, - config::permission::PermissionLevel, -}; -use rmcp::model::CallToolRequestParams; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::HashSet; -use std::path::PathBuf; -use std::sync::Arc; -use tokio_util::sync::CancellationToken; -use tracing::{error, warn}; - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct UpdateFromSessionRequest { - session_id: String, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct UpdateProviderRequest { - provider: String, - model: Option, - session_id: String, - context_limit: Option, - request_params: Option>, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct UpdateSessionRequest { - session_id: String, - goose_mode: Option, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct GetToolsQuery { - extension_name: Option, - session_id: String, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct StartAgentRequest { - working_dir: String, - #[serde(default)] - recipe: Option, - #[serde(default)] - recipe_id: Option, - #[serde(default)] - recipe_deeplink: Option, - #[serde(default)] - extension_overrides: Option>, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct StopAgentRequest { - session_id: String, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct RestartAgentRequest { - session_id: String, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct UpdateWorkingDirRequest { - session_id: String, - working_dir: String, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct ResumeAgentRequest { - session_id: String, - load_model_and_extensions: bool, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct AddExtensionRequest { - session_id: String, - config: ExtensionConfig, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct RemoveExtensionRequest { - name: String, - session_id: String, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct SetContainerRequest { - session_id: String, - container_id: Option, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct ReadResourceRequest { - session_id: String, - extension_name: String, - uri: String, -} - -#[derive(Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ReadResourceResponse { - uri: String, - #[serde(skip_serializing_if = "Option::is_none")] - mime_type: Option, - text: String, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - meta: Option>, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct CallToolRequest { - session_id: String, - name: String, - arguments: Value, -} - -/// Ref-only alias so utoipa emits `$ref: "#/components/schemas/ContentBlock"`. -/// The actual schema is registered via `derive_utoipa!(RawContent as ContentBlockSchema => "ContentBlock")`. -#[allow(dead_code)] -pub enum ContentBlock {} - -impl<'s> utoipa::ToSchema<'s> for ContentBlock { - fn schema() -> ( - &'s str, - utoipa::openapi::RefOr, - ) { - // Delegate to the auto-generated schema - crate::openapi::ContentBlockSchema::schema() - } -} - -#[derive(Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CallToolResponse { - #[schema(value_type = Vec)] - content: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - structured_content: Option, - is_error: bool, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "_meta")] - _meta: Option, -} - -#[derive(Serialize, utoipa::ToSchema)] -pub struct ResumeAgentResponse { - pub session: Session, - #[serde(skip_serializing_if = "Option::is_none")] - pub extension_results: Option>, -} - -#[derive(Serialize, utoipa::ToSchema)] -pub struct RestartAgentResponse { - pub extension_results: Vec, -} - -#[utoipa::path( - post, - path = "/agent/start", - request_body = StartAgentRequest, - responses( - (status = 200, description = "Agent started successfully", body = Session), - (status = 400, description = "Bad request", body = ErrorResponse), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 500, description = "Internal server error", body = ErrorResponse) - ) -)] -#[allow(clippy::too_many_lines)] -async fn start_agent( - State(state): State>, - Json(payload): Json, -) -> Result, ErrorResponse> { - #[cfg(feature = "telemetry")] - goose::posthog::set_session_context("desktop", false); - - let StartAgentRequest { - working_dir, - recipe, - recipe_id, - recipe_deeplink, - extension_overrides, - } = payload; - - let original_recipe = if let Some(deeplink) = recipe_deeplink { - match recipe_deeplink::decode(&deeplink) { - Ok(recipe) => Some(recipe), - Err(err) => { - error!("Failed to decode recipe deeplink: {}", err); - #[cfg(feature = "telemetry")] - goose::posthog::emit_error("recipe_deeplink_decode_failed", &err.to_string()); - return Err(ErrorResponse { - message: err.to_string(), - status: StatusCode::BAD_REQUEST, - }); - } - } - } else if let Some(id) = recipe_id { - match load_recipe_by_id(state.as_ref(), &id).await { - Ok(recipe) => Some(recipe), - Err(err) => return Err(err), - } - } else { - recipe - }; - - if let Some(ref recipe) = original_recipe { - if let Err(err) = validate_recipe(recipe) { - return Err(ErrorResponse { - message: err.message, - status: err.status, - }); - } - } - - let name = "New Chat".to_string(); - - let manager = state.session_manager(); - let config = Config::global(); - let current_mode = config.get_goose_mode().unwrap_or_default(); - - let mut session = manager - .create_session( - PathBuf::from(&working_dir), - name, - SessionType::User, - current_mode, - ) - .await - .map_err(|err| { - error!("Failed to create session: {}", err); - #[cfg(feature = "telemetry")] - goose::posthog::emit_error("session_create_failed", &err.to_string()); - ErrorResponse { - message: format!("Failed to create session: {}", err), - status: StatusCode::BAD_REQUEST, - } - })?; - - let recipe_extensions = original_recipe - .as_ref() - .and_then(|r| r.extensions.as_deref()); - let extensions_to_use = - resolve_extensions_for_new_session(recipe_extensions, extension_overrides); - - let mut extension_data = session.extension_data.clone(); - let extensions_state = EnabledExtensionsState::new(extensions_to_use); - if let Err(e) = extensions_state.to_extension_data(&mut extension_data) { - tracing::warn!("Failed to initialize session with extensions: {}", e); - } else { - manager - .update(&session.id) - .extension_data(extension_data.clone()) - .apply() - .await - .map_err(|err| { - error!("Failed to save initial extension state: {}", err); - ErrorResponse { - message: format!("Failed to save initial extension state: {}", err), - status: StatusCode::INTERNAL_SERVER_ERROR, - } - })?; - } - - if let Some(recipe) = original_recipe { - let mut update = manager.update(&session.id).recipe(Some(recipe.clone())); - - if let Some(ref settings) = recipe.settings { - if let Some(ref provider) = settings.goose_provider { - update = update.provider_name(provider); - - if let Some(ref model) = settings.goose_model { - if let Ok(model_config) = ModelConfig::new(model) { - update = update.model_config(model_config); - } - } - } - } - - update.apply().await.map_err(|err| { - error!("Failed to update session with recipe: {}", err); - ErrorResponse { - message: format!("Failed to update session with recipe: {}", err), - status: StatusCode::INTERNAL_SERVER_ERROR, - } - })?; - } - - // Refetch session to get all updates - session = manager - .get_session(&session.id, false) - .await - .map_err(|err| { - error!("Failed to get updated session: {}", err); - ErrorResponse { - message: format!("Failed to get updated session: {}", err), - status: StatusCode::INTERNAL_SERVER_ERROR, - } - })?; - - // Eagerly start loading extensions in the background - let session_for_spawn = session.clone(); - let state_for_spawn = state.clone(); - let session_id_for_task = session.id.clone(); - let task = tokio::spawn(async move { - match state_for_spawn - .get_agent(session_for_spawn.id.clone()) - .await - { - Ok(agent) => { - let results = agent.load_extensions_from_session(&session_for_spawn).await; - tracing::debug!( - "Background extension loading completed for session {}", - session_for_spawn.id - ); - results - } - Err(e) => { - tracing::warn!( - "Failed to create agent for background extension loading: {}", - e - ); - vec![] - } - } - }); - - state - .set_extension_loading_task(session_id_for_task, task) - .await; - - Ok(Json(session)) -} - -#[utoipa::path( - post, - path = "/agent/resume", - request_body = ResumeAgentRequest, - responses( - (status = 200, description = "Agent started successfully", body = ResumeAgentResponse), - (status = 400, description = "Bad request - invalid working directory"), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 500, description = "Internal server error") - ) -)] -async fn resume_agent( - State(state): State>, - Json(payload): Json, -) -> Result, ErrorResponse> { - #[cfg(feature = "telemetry")] - goose::posthog::set_session_context("desktop", true); - - let session = state - .session_manager() - .get_session(&payload.session_id, true) - .await - .map_err(|err| { - error!("Failed to resume session {}: {}", payload.session_id, err); - #[cfg(feature = "telemetry")] - goose::posthog::emit_error("session_resume_failed", &err.to_string()); - ErrorResponse { - message: format!("Failed to resume session: {}", err), - status: StatusCode::NOT_FOUND, - } - })?; - - let (extension_results, session) = if payload.load_model_and_extensions { - let agent = state - .get_agent_for_route(payload.session_id.clone()) - .await - .map_err(|code| ErrorResponse { - message: "Failed to get agent for route".into(), - status: code, - })?; - - let provider_changed = agent - .restore_provider_from_session(&session) - .await - .map_err(|e| ErrorResponse { - message: e.to_string(), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - let session = if provider_changed { - state - .session_manager() - .get_session(&payload.session_id, true) - .await - .map_err(|err| ErrorResponse { - message: format!("Failed to re-fetch session: {}", err), - status: StatusCode::INTERNAL_SERVER_ERROR, - })? - } else { - session - }; - - let extension_results = - if let Some(results) = state.take_extension_loading_task(&payload.session_id).await { - tracing::debug!( - "Using background extension loading results for session {}", - payload.session_id - ); - state - .remove_extension_loading_task(&payload.session_id) - .await; - results - } else { - tracing::debug!( - "No background task found, loading extensions for session {}", - payload.session_id - ); - agent.load_extensions_from_session(&session).await - }; - - (Some(extension_results), session) - } else { - (None, session) - }; - - Ok(Json(ResumeAgentResponse { - session, - extension_results, - })) -} - -#[utoipa::path( - post, - path = "/agent/update_from_session", - request_body = UpdateFromSessionRequest, - responses( - (status = 200, description = "Update agent from session data successfully"), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 424, description = "Agent not initialized"), - ), -)] -async fn update_from_session( - State(state): State>, - Json(payload): Json, -) -> Result { - let agent = state - .get_agent_for_route(payload.session_id.clone()) - .await - .map_err(|status| ErrorResponse { - message: format!("Failed to get agent: {}", status), - status, - })?; - let session = state - .session_manager() - .get_session(&payload.session_id, false) - .await - .map_err(|err| ErrorResponse { - message: format!("Failed to get session: {}", err), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - if let Some(recipe) = session.recipe { - if session.session_type == SessionType::Scheduled { - if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, true).await { - agent - .extend_system_prompt("recipe".to_string(), prompt) - .await; - } - } else { - match build_recipe_with_parameter_values( - &recipe, - session.user_recipe_values.unwrap_or_default(), - ) - .await - { - Ok(Some(recipe)) => { - if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, true).await { - agent - .extend_system_prompt("recipe".to_string(), prompt) - .await; - } - } - Ok(None) => { - // Recipe has missing parameters - } - Err(e) => { - return Err(ErrorResponse { - message: e.to_string(), - status: StatusCode::INTERNAL_SERVER_ERROR, - }); - } - } - } - } - - Ok(StatusCode::OK) -} - -#[utoipa::path( - get, - path = "/agent/tools", - params( - ("extension_name" = Option, Query, description = "Optional extension name to filter tools"), - ("session_id" = String, Query, description = "Required session ID to scope tools to a specific session") - ), - responses( - (status = 200, description = "Tools retrieved successfully", body = Vec), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 424, description = "Agent not initialized"), - (status = 500, description = "Internal server error") - ) -)] -async fn get_tools( - State(state): State>, - Query(query): Query, -) -> Result>, StatusCode> { - let session_id = query.session_id; - let agent = state.get_agent_for_route(session_id.clone()).await?; - let goose_mode = agent.goose_mode().await; - let permission_manager = agent.config.permission_manager.clone(); - - let mut tools: Vec = agent - .list_tools(&session_id, query.extension_name) - .await - .into_iter() - .map(|tool| { - let permission = permission_manager - .get_user_permission(&tool.name) - .or_else(|| { - if goose_mode == GooseMode::SmartApprove { - permission_manager.get_smart_approve_permission(&tool.name) - } else if goose_mode == GooseMode::Approve { - Some(PermissionLevel::AskBefore) - } else { - None - } - }); - - ToolInfo::new( - &tool.name, - tool.description - .as_ref() - .map(|d| d.as_ref()) - .unwrap_or_default(), - get_parameter_names(&tool), - permission, - ) - .with_input_schema(serde_json::Value::Object( - tool.input_schema.as_ref().clone(), - )) - }) - .collect::>(); - tools.sort_by(|a, b| a.name.cmp(&b.name)); - - Ok(Json(tools)) -} - -#[utoipa::path( - post, - path = "/agent/update_provider", - request_body = UpdateProviderRequest, - responses( - (status = 200, description = "Provider updated successfully"), - (status = 400, description = "Bad request - missing or invalid parameters"), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 424, description = "Agent not initialized"), - (status = 500, description = "Internal server error") - ) -)] -async fn update_agent_provider( - State(state): State>, - Json(payload): Json, -) -> Result<(), impl IntoResponse> { - let agent = state - .get_agent_for_route(payload.session_id.clone()) - .await - .map_err(|e| (e, "No agent for session id".to_owned()))?; - - let config = Config::global(); - let model = match payload.model.or_else(|| config.get_goose_model().ok()) { - Some(m) => m, - None => { - return Err((StatusCode::BAD_REQUEST, "No model specified".to_owned())); - } - }; - - let mut model_config = ModelConfig::new(&model) - .map_err(|e| { - ( - StatusCode::BAD_REQUEST, - format!("Invalid model config: {}", e), - ) - })? - .with_canonical_limits(&payload.provider) - .with_context_limit(payload.context_limit); - - if let Some(request_params) = payload.request_params { - model_config = model_config.with_merged_request_params(request_params); - } - let model_info = resolve_provider_model_info(&payload.provider, &model) - .await - .map_err(|e| (e.status, e.message))?; - model_config.reasoning = Some(model_info.reasoning); - - let extensions = - EnabledExtensionsState::for_session(state.session_manager(), &payload.session_id, config) - .await; - - let new_provider = create(&payload.provider, model_config, extensions) - .await - .map_err(|e| { - ( - StatusCode::BAD_REQUEST, - format!("Failed to create {} provider: {}", &payload.provider, e), - ) - })?; - - agent - .update_provider(new_provider, &payload.session_id) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to update provider: {}", e), - ) - })?; - - // Propagate session mode to the new provider - let mode = agent.goose_mode().await; - agent - .update_goose_mode(mode, &payload.session_id) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to propagate mode to provider: {}", e), - ) - })?; - - Ok(()) -} - -#[utoipa::path( - post, - path = "/agent/update_session", - request_body = UpdateSessionRequest, - responses( - (status = 200, description = "Session updated"), - (status = 400, description = "Invalid request"), - (status = 500, description = "Internal error") - ) -)] -async fn update_session( - State(state): State>, - Json(payload): Json, -) -> Result<(), (StatusCode, String)> { - let agent = state - .get_agent_for_route(payload.session_id.clone()) - .await - .map_err(|e| (e, "No agent for session id".to_owned()))?; - - if let Some(mode_str) = payload.goose_mode { - let mode: GooseMode = mode_str.parse().map_err(|_| { - ( - StatusCode::BAD_REQUEST, - format!("Invalid mode: {}", mode_str), - ) - })?; - - agent - .update_goose_mode(mode, &payload.session_id) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to update mode: {}", e), - ) - })?; - } - - Ok(()) -} - -#[utoipa::path( - post, - path = "/agent/add_extension", - request_body = AddExtensionRequest, - responses( - (status = 200, description = "Extension added", body = String), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 424, description = "Agent not initialized"), - (status = 500, description = "Internal server error") - ) -)] -async fn agent_add_extension( - State(state): State>, - Json(request): Json, -) -> Result { - #[cfg(feature = "telemetry")] - let extension_name = request.config.name(); - - let agent = state.get_agent(request.session_id.clone()).await?; - - agent - .add_extension(request.config, &request.session_id) - .await - .map_err(|e| { - #[cfg(feature = "telemetry")] - goose::posthog::emit_error( - "extension_add_failed", - &format!("{}: {}", extension_name, e), - ); - ErrorResponse::internal(format!("Failed to add extension: {}", e)) - })?; - - Ok(StatusCode::OK) -} - -#[utoipa::path( - post, - path = "/agent/remove_extension", - request_body = RemoveExtensionRequest, - responses( - (status = 200, description = "Extension removed", body = String), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 424, description = "Agent not initialized"), - (status = 500, description = "Internal server error") - ) -)] -async fn agent_remove_extension( - State(state): State>, - Json(request): Json, -) -> Result { - let agent = state.get_agent(request.session_id.clone()).await?; - - agent - .remove_extension(&request.name, &request.session_id) - .await - .map_err(|e| { - error!("Failed to remove extension: {}", e); - ErrorResponse { - message: format!("Failed to remove extension: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - } - })?; - - Ok(StatusCode::OK) -} - -#[utoipa::path( - post, - path = "/agent/set_container", - request_body = SetContainerRequest, - responses( - (status = 200, description = "Container set successfully"), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 424, description = "Agent not initialized"), - (status = 500, description = "Internal server error") - ) -)] -async fn set_container( - State(state): State>, - Json(request): Json, -) -> Result { - let agent = state.get_agent(request.session_id.clone()).await?; - - let container = request.container_id.map(Container::new); - agent.set_container(container).await; - - Ok(StatusCode::OK) -} - -#[utoipa::path( - post, - path = "/agent/stop", - request_body = StopAgentRequest, - responses( - (status = 200, description = "Agent stopped successfully", body = String), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 404, description = "Session not found"), - (status = 500, description = "Internal server error") - ) -)] -async fn stop_agent( - State(state): State>, - Json(payload): Json, -) -> Result { - let session_id = payload.session_id; - state - .agent_manager - .remove_session(&session_id) - .await - .map_err(|e| ErrorResponse { - message: format!("Failed to stop agent for session {}: {}", session_id, e), - status: StatusCode::NOT_FOUND, - })?; - - Ok(StatusCode::OK) -} - -async fn restart_agent_internal( - state: &Arc, - session_id: &str, - session: &Session, -) -> Result, ErrorResponse> { - // Remove existing agent (ignore error if not found) - let _ = state.agent_manager.remove_session(session_id).await; - - let agent = state - .get_agent_for_route(session_id.to_string()) - .await - .map_err(|code| ErrorResponse { - message: "Failed to create new agent during restart".into(), - status: code, - })?; - - let provider_future = agent.restore_provider_from_session(session); - let extensions_future = agent.load_extensions_from_session(session); - - let (provider_result, extension_results) = tokio::join!(provider_future, extensions_future); - provider_result.map_err(|e| ErrorResponse { - message: e.to_string(), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - if let Some(ref recipe) = session.recipe { - if session.session_type == SessionType::Scheduled { - if let Some(prompt) = apply_recipe_to_agent(&agent, recipe, true).await { - agent - .extend_system_prompt("recipe".to_string(), prompt) - .await; - } - } else { - match build_recipe_with_parameter_values( - recipe, - session.user_recipe_values.clone().unwrap_or_default(), - ) - .await - { - Ok(Some(recipe)) => { - if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, true).await { - agent - .extend_system_prompt("recipe".to_string(), prompt) - .await; - } - } - Ok(None) => { - // Recipe has missing parameters - } - Err(e) => { - return Err(ErrorResponse { - message: e.to_string(), - status: StatusCode::INTERNAL_SERVER_ERROR, - }); - } - } - } - } - - Ok(extension_results) -} - -#[utoipa::path( - post, - path = "/agent/restart", - request_body = RestartAgentRequest, - responses( - (status = 200, description = "Agent restarted successfully", body = RestartAgentResponse), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 404, description = "Session not found"), - (status = 500, description = "Internal server error") - ) -)] -async fn restart_agent( - State(state): State>, - Json(payload): Json, -) -> Result, ErrorResponse> { - let session_id = payload.session_id.clone(); - - let session = state - .session_manager() - .get_session(&session_id, false) - .await - .map_err(|err| { - error!("Failed to get session during restart: {}", err); - ErrorResponse { - message: format!("Failed to get session: {}", err), - status: StatusCode::NOT_FOUND, - } - })?; - - let extension_results = restart_agent_internal(&state, &session_id, &session).await?; - - Ok(Json(RestartAgentResponse { extension_results })) -} - -#[utoipa::path( - post, - path = "/agent/update_working_dir", - request_body = UpdateWorkingDirRequest, - responses( - (status = 200, description = "Working directory updated and agent restarted successfully"), - (status = 400, description = "Bad request - invalid directory path"), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 404, description = "Session not found"), - (status = 500, description = "Internal server error") - ) -)] -async fn update_working_dir( - State(state): State>, - Json(payload): Json, -) -> Result { - let session_id = payload.session_id.clone(); - let working_dir = payload.working_dir.trim(); - - if working_dir.is_empty() { - return Err(ErrorResponse { - message: "Working directory cannot be empty".into(), - status: StatusCode::BAD_REQUEST, - }); - } - - let path = PathBuf::from(working_dir); - if !path.exists() || !path.is_dir() { - return Err(ErrorResponse { - message: "Invalid directory path".into(), - status: StatusCode::BAD_REQUEST, - }); - } - - // Update the session's working directory - state - .session_manager() - .update(&session_id) - .working_dir(path) - .apply() - .await - .map_err(|e| { - error!("Failed to update session working directory: {}", e); - ErrorResponse { - message: format!("Failed to update working directory: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - } - })?; - - // Get the updated session and restart the agent - let session = state - .session_manager() - .get_session(&session_id, false) - .await - .map_err(|err| { - error!("Failed to get session after working dir update: {}", err); - ErrorResponse { - message: format!("Failed to get session: {}", err), - status: StatusCode::NOT_FOUND, - } - })?; - - restart_agent_internal(&state, &session_id, &session).await?; - - Ok(StatusCode::OK) -} - -async fn ensure_extensions_loaded(state: &AppState, session_id: &str) { - if let Some(_results) = state.take_extension_loading_task(session_id).await { - tracing::debug!( - "Awaited background extension loading for session {} before serving request", - session_id - ); - state.remove_extension_loading_task(session_id).await; - } -} - -#[utoipa::path( - post, - path = "/agent/read_resource", - request_body = ReadResourceRequest, - responses( - (status = 200, description = "Resource read successfully", body = ReadResourceResponse), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 424, description = "Agent not initialized"), - (status = 404, description = "Resource not found"), - (status = 500, description = "Internal server error") - ) -)] -async fn read_resource( - State(state): State>, - Json(payload): Json, -) -> Result, StatusCode> { - use rmcp::model::ResourceContents; - - ensure_extensions_loaded(&state, &payload.session_id).await; - - let agent = state - .get_agent_for_route(payload.session_id.clone()) - .await?; - - let read_result = agent - .extension_manager - .read_resource( - &payload.session_id, - &payload.uri, - &payload.extension_name, - CancellationToken::default(), - ) - .await - .map_err(|e| { - tracing::error!( - "read_resource failed for session={}, uri={}, extension={}: {:?}", - payload.session_id, - payload.uri, - payload.extension_name, - e - ); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - let content = read_result - .contents - .into_iter() - .next() - .ok_or(StatusCode::NOT_FOUND)?; - - let (uri, mime_type, text, meta) = match content { - ResourceContents::TextResourceContents { - uri, - mime_type, - text, - meta, - } => (uri, mime_type, text, meta), - ResourceContents::BlobResourceContents { - uri, - mime_type, - blob, - meta, - } => { - let decoded = match base64::engine::general_purpose::STANDARD.decode(&blob) { - Ok(bytes) => { - String::from_utf8(bytes).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - } - Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), - }; - (uri, mime_type, decoded, meta) - } - }; - - let meta_map = meta.map(|m| m.0); - - Ok(Json(ReadResourceResponse { - uri, - mime_type, - text, - meta: meta_map, - })) -} - -#[utoipa::path( - post, - path = "/agent/call_tool", - request_body = CallToolRequest, - responses( - (status = 200, description = "Resource read successfully", body = CallToolResponse), - (status = 403, description = "Forbidden - tool is not app-visible", body = ErrorResponse), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 424, description = "Frontend tool execution requires the frontend host", body = ErrorResponse), - (status = 404, description = "Resource not found", body = ErrorResponse), - (status = 500, description = "Internal server error", body = ErrorResponse) - ) -)] -async fn call_tool( - State(state): State>, - Json(payload): Json, -) -> Result, ErrorResponse> { - ensure_extensions_loaded(&state, &payload.session_id).await; - - let agent = state - .get_agent_for_route(payload.session_id.clone()) - .await?; - - // Check app-side visibility: reject calls to tools that exclude "app" - let tools = agent.list_tools(&payload.session_id, None).await; - if let Some(tool) = tools.iter().find(|t| *t.name == payload.name) { - if !is_tool_visible_to_app(tool) { - warn!( - tool = %payload.name, - "Rejected app call to model-only tool" - ); - return Err(ErrorResponse { - message: format!("Tool '{}' cannot be called by the app", payload.name), - status: StatusCode::FORBIDDEN, - }); - } - } - - if agent.is_frontend_tool(&payload.name).await { - return Err(ErrorResponse { - message: format!( - "Tool '{}' is provided by the frontend and must be executed by the frontend host", - payload.name - ), - status: StatusCode::FAILED_DEPENDENCY, - }); - } - - let arguments = match payload.arguments { - Value::Object(map) => Some(map), - _ => None, - }; - - let tool_call = { - let mut params = CallToolRequestParams::new(payload.name); - if let Some(args) = arguments { - params = params.with_arguments(args); - } - params - }; - - let ctx = goose::agents::ToolCallContext::new(payload.session_id.clone(), None, None); - let tool_result = agent - .extension_manager - .dispatch_tool_call(&ctx, tool_call, CancellationToken::default()) - .await - .map_err(ErrorResponse::from)?; - - let result = tool_result.result.await.map_err(|err| ErrorResponse { - message: err.to_string(), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - let content = result - .content - .into_iter() - .map(serde_json::to_value) - .collect::, _>>() - .map_err(ErrorResponse::from)?; - - Ok(Json(CallToolResponse { - content, - structured_content: result.structured_content, - is_error: result.is_error.unwrap_or(false), - _meta: result.meta.and_then(|m| serde_json::to_value(m).ok()), - })) -} - -#[derive(Deserialize, utoipa::IntoParams, utoipa::ToSchema)] -pub struct ListAppsRequest { - session_id: Option, -} - -#[derive(Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ListAppsResponse { - pub apps: Vec, -} - -#[utoipa::path( - get, - path = "/agent/list_apps", - params( - ListAppsRequest - ), - responses( - (status = 200, description = "List of apps retrieved successfully", body = ListAppsResponse), - (status = 401, description = "Unauthorized - Invalid or missing API key", body = ErrorResponse), - (status = 500, description = "Internal server error", body = ErrorResponse), - ), - security( - ("api_key" = []) - ), - tag = "Agent" -)] -async fn list_apps( - State(state): State>, - Query(params): Query, -) -> Result, ErrorResponse> { - let cache = McpAppCache::new().ok(); - - let Some(session_id) = params.session_id else { - let apps = cache - .as_ref() - .and_then(|c| c.list_apps().ok()) - .unwrap_or_default(); - return Ok(Json(ListAppsResponse { apps })); - }; - - let agent = state - .get_agent_for_route(session_id.clone()) - .await - .map_err(|status| ErrorResponse { - message: "Failed to get agent".to_string(), - status, - })?; - - let apps = fetch_mcp_apps(&agent.extension_manager, &session_id) - .await - .map_err(|e| ErrorResponse { - message: format!("Failed to list apps: {}", e.message), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - if let Some(cache) = cache.as_ref() { - let active_extensions: HashSet = apps - .iter() - .flat_map(|app| app.mcp_servers.iter().cloned()) - .collect(); - - for extension_name in active_extensions { - if let Err(e) = cache.delete_extension_apps(&extension_name) { - warn!( - "Failed to clean cache for extension {}: {}", - extension_name, e - ); - } - } - - for app in &apps { - if let Err(e) = cache.store_app(app) { - warn!("Failed to cache app {}: {}", app.resource.name, e); - } - } - } - - Ok(Json(ListAppsResponse { apps })) -} - -#[utoipa::path( - get, - path = "/agent/export_app/{name}", - params( - ("name" = String, Path, description = "Name of the app to export") - ), - responses( - (status = 200, description = "App HTML exported successfully", body = String), - (status = 404, description = "App not found", body = ErrorResponse), - (status = 500, description = "Internal server error", body = ErrorResponse), - ), - security( - ("api_key" = []) - ), - tag = "Agent" -)] -async fn export_app( - axum::extract::Path(name): axum::extract::Path, -) -> Result { - let cache = McpAppCache::new().map_err(|e| ErrorResponse { - message: format!("Failed to access app cache: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - let apps = cache.list_apps().map_err(|e| ErrorResponse { - message: format!("Failed to list apps: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - let app = apps - .into_iter() - .find(|a| a.resource.name == name) - .ok_or_else(|| ErrorResponse { - message: format!("App '{}' not found", name), - status: StatusCode::NOT_FOUND, - })?; - - let html = app.to_html().map_err(|e| ErrorResponse { - message: format!("Failed to generate HTML: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - Ok(html) -} - -#[derive(Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ImportAppRequest { - pub html: String, -} - -#[derive(Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ImportAppResponse { - pub name: String, - pub message: String, -} - -#[utoipa::path( - post, - path = "/agent/import_app", - request_body = ImportAppRequest, - responses( - (status = 201, description = "App imported successfully", body = ImportAppResponse), - (status = 400, description = "Bad request - Invalid HTML", body = ErrorResponse), - (status = 500, description = "Internal server error", body = ErrorResponse), - ), - security( - ("api_key" = []) - ), - tag = "Agent" -)] -async fn import_app( - Json(body): Json, -) -> Result<(StatusCode, Json), ErrorResponse> { - let cache = McpAppCache::new().map_err(|e| ErrorResponse { - message: format!("Failed to access app cache: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - let mut app = GooseApp::from_html(&body.html).map_err(|e| ErrorResponse { - message: format!("Invalid Goose App HTML: {}", e), - status: StatusCode::BAD_REQUEST, - })?; - - let original_name = app.resource.name.clone(); - let mut counter = 1; - - let existing_apps = cache.list_apps().unwrap_or_default(); - let existing_names: HashSet = existing_apps - .iter() - .map(|a| a.resource.name.clone()) - .collect(); - - while existing_names.contains(&app.resource.name) { - app.resource.name = format!("{}_{}", original_name, counter); - app.resource.uri = format!("ui://apps/{}", app.resource.name); - counter += 1; - } - - app.mcp_servers = vec!["apps".to_string()]; - - cache.store_app(&app).map_err(|e| ErrorResponse { - message: format!("Failed to store app: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - Ok(( - StatusCode::CREATED, - Json(ImportAppResponse { - name: app.resource.name.clone(), - message: format!("App '{}' imported successfully", app.resource.name), - }), - )) -} - -pub fn routes(state: Arc) -> Router { - Router::new() - .route("/agent/start", post(start_agent)) - .route("/agent/resume", post(resume_agent)) - .route("/agent/restart", post(restart_agent)) - .route("/agent/update_working_dir", post(update_working_dir)) - .route("/agent/tools", get(get_tools)) - .route("/agent/read_resource", post(read_resource)) - .route("/agent/call_tool", post(call_tool)) - .route("/agent/list_apps", get(list_apps)) - .route("/agent/export_app/{name}", get(export_app)) - .route("/agent/import_app", post(import_app)) - .route("/agent/update_provider", post(update_agent_provider)) - .route("/agent/update_session", post(update_session)) - .route("/agent/update_from_session", post(update_from_session)) - .route("/agent/add_extension", post(agent_add_extension)) - .route("/agent/remove_extension", post(agent_remove_extension)) - .route("/agent/set_container", post(set_container)) - .route("/agent/stop", post(stop_agent)) - .with_state(state) -} - -#[cfg(test)] -mod tests { - use super::*; - use goose::config::GooseMode; - use goose::session::session_manager::SessionType; - use rmcp::model::Tool; - use rmcp::object; - - fn frontend_extension() -> ExtensionConfig { - ExtensionConfig::Frontend { - name: "frontend-e2e".to_string(), - description: "Frontend test extension".to_string(), - tools: vec![Tool::new( - "frontend__echo".to_string(), - "Echo a string from the frontend".to_string(), - object!({ - "type": "object", - "properties": { - "message": { "type": "string" } - }, - "required": ["message"] - }), - )], - instructions: Some("Use the frontend echo tool.".to_string()), - bundled: None, - available_tools: vec![], - } - } - - #[tokio::test] - async fn frontend_extensions_are_listed_and_rejected_cleanly_by_call_tool() { - let state = AppState::new(true).await.unwrap(); - let session = state - .session_manager() - .create_session( - std::env::current_dir().unwrap(), - "frontend-route-test".to_string(), - SessionType::Hidden, - GooseMode::default(), - ) - .await - .unwrap(); - - agent_add_extension( - State(state.clone()), - Json(AddExtensionRequest { - session_id: session.id.clone(), - config: frontend_extension(), - }), - ) - .await - .unwrap(); - - let Json(tools) = get_tools( - State(state.clone()), - Query(GetToolsQuery { - extension_name: None, - session_id: session.id.clone(), - }), - ) - .await - .unwrap(); - - assert!(tools.iter().any(|tool| tool.name == "frontend__echo")); - - let error = match call_tool( - State(state), - Json(CallToolRequest { - session_id: session.id, - name: "frontend__echo".to_string(), - arguments: Value::Object(serde_json::Map::new()), - }), - ) - .await - { - Ok(_) => panic!("frontend tools should not be callable through /agent/call_tool"), - Err(error) => error, - }; - - assert_eq!(error.status, StatusCode::FAILED_DEPENDENCY); - assert!(error.message.contains("frontend host")); - } -} diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs deleted file mode 100644 index 69ed487d64..0000000000 --- a/crates/goose-server/src/routes/config_management.rs +++ /dev/null @@ -1,1034 +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 goose::config::declarative_providers::LoadedProvider; -use goose::config::paths::Paths; -use goose::config::ExtensionEntry; -use goose::config::{Config, ConfigError}; -use goose::custom_requests::SourceType; -use goose::model::ModelConfig; -use goose::providers::base::{ModelInfo, ProviderMetadata, ProviderType}; -use goose::providers::canonical::maybe_get_canonical_model; -use goose::providers::catalog::{ - get_provider_template, get_providers_by_format, ProviderCatalogEntry, ProviderFormat, - ProviderTemplate, -}; -use goose::providers::create_with_default_model; -use goose::providers::providers as get_providers; -use goose::{ - agents::execute_commands, agents::ExtensionConfig, config::permission::PermissionLevel, - slash_commands::recipe_slash_command, -}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use serde_yaml; -use std::{collections::HashMap, sync::Arc}; -use utoipa::ToSchema; - -#[derive(Serialize, ToSchema)] -pub struct ExtensionResponse { - pub extensions: Vec, - #[serde(default)] - pub warnings: Vec, -} - -#[derive(Deserialize, ToSchema)] -pub struct ExtensionQuery { - pub name: String, - pub config: ExtensionConfig, - pub enabled: bool, -} - -#[derive(Deserialize, ToSchema)] -pub struct UpsertConfigQuery { - pub key: String, - pub value: Value, - pub is_secret: bool, -} - -#[derive(Deserialize, Serialize, ToSchema)] -pub struct ConfigKeyQuery { - pub key: String, - pub is_secret: bool, -} - -#[derive(Serialize, ToSchema)] -pub struct ConfigResponse { - pub config: HashMap, -} - -#[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct ProviderDetails { - pub name: String, - pub metadata: ProviderMetadata, - pub is_configured: bool, - pub provider_type: ProviderType, - #[serde(skip_serializing_if = "Option::is_none")] - pub saved_model: Option, -} - -#[derive(Serialize, ToSchema)] -pub struct ProvidersResponse { - pub providers: Vec, -} - -#[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct ToolPermission { - pub tool_name: String, - pub permission: PermissionLevel, -} - -#[derive(Deserialize, ToSchema)] -pub struct UpsertPermissionsQuery { - pub tool_permissions: Vec, -} - -#[derive(Deserialize, ToSchema)] -pub struct UpdateCustomProviderRequest { - pub engine: String, - pub display_name: String, - pub api_url: String, - pub api_key: String, - pub models: Vec, - pub supports_streaming: Option, - pub headers: Option>, - #[serde(default = "default_requires_auth")] - pub requires_auth: bool, - #[serde(default)] - pub catalog_provider_id: Option, - #[serde(default)] - pub base_path: Option, - #[serde(default)] - pub preserves_thinking: Option, -} - -fn default_requires_auth() -> bool { - true -} - -fn normalize_custom_provider_api_key(api_key: String) -> Option { - let api_key = api_key.trim().to_string(); - (!api_key.is_empty()).then_some(api_key) -} - -#[derive(Deserialize, ToSchema)] -pub struct CheckProviderRequest { - pub provider: String, -} - -#[derive(Deserialize, ToSchema)] -pub struct SetProviderRequest { - pub provider: String, - pub model: String, -} - -#[derive(Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct MaskedSecret { - pub masked_value: String, -} - -#[derive(Serialize, ToSchema)] -#[serde(untagged)] -pub enum ConfigValueResponse { - Value(Value), - MaskedValue(MaskedSecret), -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub enum CommandType { - Builtin, - Recipe, - Skill, - Agent, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct SlashCommand { - pub command: String, - pub help: String, - pub command_type: CommandType, -} -#[derive(Serialize, ToSchema)] -pub struct SlashCommandsResponse { - pub commands: Vec, -} - -#[utoipa::path( - post, - path = "/config/upsert", - request_body = UpsertConfigQuery, - responses( - (status = 200, description = "Configuration value upserted successfully", body = String), - (status = 500, description = "Internal server error") - ) -)] -pub async fn upsert_config( - Json(query): Json, -) -> Result, ErrorResponse> { - let config = Config::global(); - - // Intercept legacy keys to write structured provider config - if query.key == "GOOSE_PROVIDER" { - if let Some(name) = query.value.as_str() { - // Preserve the target provider's saved model rather than copying - // the current active provider's model into the new entry. - let model = goose::config::get_provider_entry(config, name) - .map(|e| e.model) - .or_else(|| config.get_goose_model().ok()) - .unwrap_or_default(); - goose::config::set_active_provider(config, name, &model)?; - return Ok(Json(Value::String(format!("Upserted key {}", query.key)))); - } - } - if query.key == "GOOSE_MODEL" { - if let Some(model) = query.value.as_str() { - if let Ok(provider) = config.get_goose_provider() { - goose::config::set_active_provider(config, &provider, model)?; - return Ok(Json(Value::String(format!("Upserted key {}", query.key)))); - } - } - } - - config.set(&query.key, &query.value, query.is_secret)?; - Ok(Json(Value::String(format!("Upserted key {}", query.key)))) -} - -#[utoipa::path( - post, - path = "/config/remove", - request_body = ConfigKeyQuery, - responses( - (status = 200, description = "Configuration value removed successfully", body = String), - (status = 404, description = "Configuration key not found"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn remove_config( - Json(query): Json, -) -> Result, ErrorResponse> { - let config = Config::global(); - - if query.is_secret { - config.delete_secret(&query.key)?; - } else if query.key == "GOOSE_PROVIDER" || query.key == "active_provider" { - config.delete("active_provider")?; - config.delete("GOOSE_PROVIDER")?; - } else if query.key == "GOOSE_MODEL" { - if let Ok(provider) = config.get_goose_provider() { - goose::config::set_active_provider(config, &provider, "")?; - } - config.delete("GOOSE_MODEL")?; - } else { - config.delete(&query.key)?; - } - - Ok(Json(format!("Removed key {}", query.key))) -} - -const SECRET_MASK_SHOW_LEN: usize = 8; - -fn mask_secret(secret: Value) -> String { - let as_string = match secret { - Value::String(s) => s, - _ => serde_json::to_string(&secret).unwrap_or_else(|_| secret.to_string()), - }; - - let chars: Vec<_> = as_string.chars().collect(); - let show_len = std::cmp::min(chars.len() / 2, SECRET_MASK_SHOW_LEN); - let visible: String = chars.iter().take(show_len).collect(); - let mask = "*".repeat(chars.len() - show_len); - - format!("{}{}", visible, mask) -} - -fn is_valid_provider_name(provider_name: &str) -> bool { - !provider_name.is_empty() - && provider_name - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') -} - -#[utoipa::path( - post, - path = "/config/read", - request_body = ConfigKeyQuery, - responses( - (status = 200, description = "Configuration value retrieved successfully", body = Value), - (status = 500, description = "Unable to get the configuration value"), - ) -)] -pub async fn read_config( - Json(query): Json, -) -> Result, ErrorResponse> { - let config = Config::global(); - - // Intercept legacy keys to return structured provider config - if query.key == "GOOSE_PROVIDER" || query.key == "active_provider" { - if let Ok(val) = config.get_goose_provider() { - return Ok(Json(ConfigValueResponse::Value(Value::String(val)))); - } - return Ok(Json(ConfigValueResponse::Value(Value::Null))); - } - if query.key == "GOOSE_MODEL" { - if let Ok(val) = config.get_goose_model() { - return Ok(Json(ConfigValueResponse::Value(Value::String(val)))); - } - return Ok(Json(ConfigValueResponse::Value(Value::Null))); - } - - let response_value = match config.get(&query.key, query.is_secret) { - Ok(value) => { - if query.is_secret { - ConfigValueResponse::MaskedValue(MaskedSecret { - masked_value: mask_secret(value), - }) - } else { - ConfigValueResponse::Value(value) - } - } - Err(ConfigError::NotFound(_)) => ConfigValueResponse::Value(Value::Null), - Err(e) => return Err(e.into()), - }; - Ok(Json(response_value)) -} - -#[utoipa::path( - get, - path = "/config/extensions", - responses( - (status = 200, description = "All extensions retrieved successfully", body = ExtensionResponse), - (status = 500, description = "Internal server error") - ) -)] -pub async fn get_extensions() -> Result, ErrorResponse> { - let extensions = goose::config::get_all_extensions() - .into_iter() - .filter(|ext| !goose::agents::extension_manager::is_hidden_extension(&ext.config.name())) - .collect(); - let warnings = goose::config::get_warnings(); - Ok(Json(ExtensionResponse { - extensions, - warnings, - })) -} - -#[utoipa::path( - post, - path = "/config/extensions", - request_body = ExtensionQuery, - responses( - (status = 200, description = "Extension added or updated successfully", body = String), - (status = 400, description = "Invalid request"), - (status = 422, description = "Could not serialize config.yaml"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn add_extension( - Json(extension_query): Json, -) -> Result, ErrorResponse> { - let extensions = goose::config::get_all_extensions(); - let key = goose::config::extensions::name_to_key(&extension_query.name); - - let is_update = extensions.iter().any(|e| e.config.key() == key); - - goose::config::set_extension(ExtensionEntry { - enabled: extension_query.enabled, - config: extension_query.config, - }); - - if is_update { - Ok(Json(format!("Updated extension {}", extension_query.name))) - } else { - Ok(Json(format!("Added extension {}", extension_query.name))) - } -} - -#[utoipa::path( - delete, - path = "/config/extensions/{name}", - responses( - (status = 200, description = "Extension removed successfully", body = String), - (status = 404, description = "Extension not found"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn remove_extension(Path(name): Path) -> Result, ErrorResponse> { - let key = goose::config::extensions::name_to_key(&name); - goose::config::remove_extension(&key); - Ok(Json(format!("Removed extension {}", name))) -} - -#[utoipa::path( - get, - path = "/config", - responses( - (status = 200, description = "All configuration values retrieved successfully", body = ConfigResponse) - ) -)] -pub async fn read_all_config() -> Result, ErrorResponse> { - let config = Config::global(); - let values = config - .all_values() - .map_err(|e| ErrorResponse::unprocessable(e.to_string()))?; - Ok(Json(ConfigResponse { config: values })) -} - -#[utoipa::path( - get, - path = "/config/providers", - responses( - (status = 200, description = "All configuration values retrieved successfully", body = [ProviderDetails]) - ) -)] -pub async fn providers() -> Result>, ErrorResponse> { - let config = Config::global(); - let providers = get_providers().await; - let providers_response: Vec = providers - .into_iter() - .map(|(metadata, provider_type)| { - let is_configured = check_provider_configured(&metadata, provider_type); - let saved_model = goose::config::get_provider_entry(config, &metadata.name) - .map(|e| e.model) - .filter(|m| !m.is_empty()); - - ProviderDetails { - name: metadata.name.clone(), - metadata, - is_configured, - provider_type, - saved_model, - } - }) - .collect(); - - Ok(Json(providers_response)) -} - -#[utoipa::path( - get, - path = "/config/providers/{name}/models", - params( - ("name" = String, Path, description = "Provider name (e.g., openai)") - ), - responses( - (status = 200, description = "Models fetched successfully", body = [ModelInfo]), - (status = 400, description = "Unknown provider, provider not configured, or authentication error"), - (status = 429, description = "Rate limit exceeded"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn get_provider_models( - Path(name): Path, -) -> Result>, ErrorResponse> { - let all = get_providers().await.into_iter().collect::>(); - let Some((metadata, provider_type)) = all.into_iter().find(|(m, _)| m.name == name) else { - return Err(ErrorResponse::bad_request(format!( - "Unknown provider: {}", - name - ))); - }; - if !check_provider_configured(&metadata, provider_type) { - return Err(ErrorResponse::bad_request(format!( - "Provider '{}' is not configured", - name - ))); - } - - let model_config = ModelConfig::new(&metadata.default_model)?.with_canonical_limits(&name); - let provider = goose::providers::create(&name, model_config, Vec::new()).await?; - - let models_result = provider.fetch_recommended_model_info().await; - - match models_result { - Ok(models) => Ok(Json(models)), - Err(provider_error) => Err(provider_error.into()), - } -} - -#[derive(Deserialize, ToSchema)] -pub struct ProviderModelInfoQuery { - pub model: String, -} - -pub async fn resolve_provider_model_info( - name: &str, - model: &str, -) -> Result { - let all = get_providers().await.into_iter().collect::>(); - let Some((metadata, provider_type)) = all.into_iter().find(|(m, _)| m.name == name) else { - return Err(ErrorResponse::bad_request(format!( - "Unknown provider: {}", - name - ))); - }; - if !check_provider_configured(&metadata, provider_type) { - return Err(ErrorResponse::bad_request(format!( - "Provider '{}' is not configured", - name - ))); - } - - let model_config = ModelConfig::new(model)?.with_canonical_limits(name); - let provider = goose::providers::create(name, model_config.clone(), Vec::new()).await?; - match provider.fetch_model_info(model).await { - Ok(info) => Ok(info), - Err(error) => { - let mut info = ModelInfo::new(model, model_config.context_limit()); - info.reasoning = model_config.is_reasoning_model(); - tracing::debug!( - provider = name, - model, - error = %error, - "Falling back to local model metadata" - ); - Ok(info) - } - } -} - -#[utoipa::path( - post, - path = "/config/providers/{name}/model-info", - params( - ("name" = String, Path, description = "Provider name (e.g., openai)") - ), - request_body = ProviderModelInfoQuery, - responses( - (status = 200, description = "Model metadata fetched successfully", body = ModelInfo), - (status = 400, description = "Unknown provider, provider not configured, or authentication error"), - (status = 429, description = "Rate limit exceeded"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn get_provider_model_info( - Path(name): Path, - Json(query): Json, -) -> Result, ErrorResponse> { - resolve_provider_model_info(&name, &query.model) - .await - .map(Json) -} - -#[derive(Deserialize, utoipa::IntoParams)] -pub struct SlashCommandsQuery { - /// Optional working directory to discover local skills from - pub working_dir: Option, -} - -#[utoipa::path( - get, - path = "/config/slash_commands", - params(SlashCommandsQuery), - responses( - (status = 200, description = "Slash commands retrieved successfully", body = SlashCommandsResponse) - ) -)] -pub async fn get_slash_commands( - axum::extract::Query(query): axum::extract::Query, -) -> Result, ErrorResponse> { - let mut commands: Vec<_> = recipe_slash_command::list_commands() - .iter() - .map(|command| SlashCommand { - command: command.command.clone(), - help: command.recipe_path.clone(), - command_type: CommandType::Recipe, - }) - .collect(); - - for cmd_def in execute_commands::list_commands() { - commands.push(SlashCommand { - command: cmd_def.name.to_string(), - help: cmd_def.description.to_string(), - command_type: CommandType::Builtin, - }); - } - - let working_dir = query.working_dir.map(std::path::PathBuf::from); - for source in goose::skills::list_installed_skills(working_dir.as_deref()) { - commands.push(SlashCommand { - command: source.name, - help: source.description, - command_type: CommandType::Skill, - }); - } - - let discover_dir = working_dir - .as_deref() - .unwrap_or_else(|| std::path::Path::new(".")); - for source in - goose::agents::platform_extensions::summon::discover_filesystem_sources(discover_dir) - { - if matches!( - source.source_type, - SourceType::Agent | SourceType::Recipe | SourceType::Subrecipe - ) && !source.content.is_empty() - { - commands.push(SlashCommand { - command: source.name, - help: source.description, - command_type: CommandType::Agent, - }); - } - } - - Ok(Json(SlashCommandsResponse { commands })) -} - -#[derive(Serialize, ToSchema)] -pub struct ModelInfoData { - pub provider: String, - pub model: String, - pub context_limit: usize, - pub max_output_tokens: Option, - pub reasoning: bool, - pub input_token_cost: Option, - pub output_token_cost: Option, - pub cache_read_token_cost: Option, - pub cache_write_token_cost: Option, - pub currency: String, -} - -#[derive(Serialize, ToSchema)] -pub struct ModelInfoResponse { - pub model_info: Option, - pub source: String, -} - -#[derive(Deserialize, ToSchema)] -pub struct ModelInfoQuery { - pub provider: String, - pub model: String, -} - -#[utoipa::path( - post, - path = "/config/canonical-model-info", - request_body = ModelInfoQuery, - responses( - (status = 200, description = "Model information retrieved successfully", body = ModelInfoResponse) - ) -)] -pub async fn get_canonical_model_info( - Json(query): Json, -) -> Json { - let canonical_model = maybe_get_canonical_model(&query.provider, &query.model); - - let model_info = canonical_model.map(|canonical_model| ModelInfoData { - provider: query.provider.clone(), - model: query.model.clone(), - context_limit: canonical_model.limit.context, - max_output_tokens: canonical_model.limit.output, - reasoning: canonical_model - .reasoning - .unwrap_or_else(|| ModelConfig::new_or_fail(&query.model).is_reasoning_model()), - // Costs are per million tokens - client handles division for display - input_token_cost: canonical_model.cost.input, - output_token_cost: canonical_model.cost.output, - cache_read_token_cost: canonical_model.cost.cache_read, - cache_write_token_cost: canonical_model.cost.cache_write, - currency: "$".to_string(), - }); - - Json(ModelInfoResponse { - model_info, - source: "canonical".to_string(), - }) -} - -#[utoipa::path( - post, - path = "/config/permissions", - request_body = UpsertPermissionsQuery, - responses( - (status = 200, description = "Permission update completed", body = String), - (status = 400, description = "Invalid request"), - ) -)] -pub async fn upsert_permissions( - Json(query): Json, -) -> Result, ErrorResponse> { - let permission_manager = goose::config::PermissionManager::instance(); - - for tool_permission in &query.tool_permissions { - permission_manager.update_user_permission( - &tool_permission.tool_name, - tool_permission.permission.clone(), - ); - } - - Ok(Json("Permissions updated successfully".to_string())) -} - -#[utoipa::path( - get, - path = "/config/validate", - responses( - (status = 200, description = "Config validation result", body = String), - (status = 422, description = "Config file is corrupted") - ) -)] -pub async fn validate_config() -> Result, ErrorResponse> { - let config_path = Paths::config_dir().join("config.yaml"); - - if !config_path.exists() { - return Ok(Json("Config file does not exist".to_string())); - } - - let content = std::fs::read_to_string(&config_path)?; - serde_yaml::from_str::(&content) - .map_err(|e| ErrorResponse::unprocessable(format!("Config file is corrupted: {}", e)))?; - - Ok(Json("Config file is valid".to_string())) -} -#[derive(Serialize, ToSchema)] -pub struct CreateCustomProviderResponse { - pub provider_name: String, -} - -#[utoipa::path( - post, - path = "/config/custom-providers", - request_body = UpdateCustomProviderRequest, - responses( - (status = 200, description = "Custom provider created successfully", body = CreateCustomProviderResponse), - (status = 400, description = "Invalid request"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn create_custom_provider( - Json(request): Json, -) -> Result, ErrorResponse> { - let config = goose::config::declarative_providers::create_custom_provider( - goose::config::declarative_providers::CreateCustomProviderParams { - engine: request.engine, - display_name: request.display_name, - api_url: request.api_url, - api_key: normalize_custom_provider_api_key(request.api_key), - models: request.models, - supports_streaming: request.supports_streaming, - headers: request.headers, - requires_auth: request.requires_auth, - catalog_provider_id: request.catalog_provider_id, - base_path: request.base_path, - preserves_thinking: request.preserves_thinking, - }, - )?; - - goose::providers::refresh_custom_providers().await?; - - Ok(Json(CreateCustomProviderResponse { - provider_name: config.id().to_string(), - })) -} - -#[utoipa::path( - get, - path = "/config/custom-providers/{id}", - responses( - (status = 200, description = "Custom provider retrieved successfully", body = LoadedProvider), - (status = 404, description = "Provider not found"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn get_custom_provider( - Path(id): Path, -) -> Result, ErrorResponse> { - let loaded_provider = goose::config::declarative_providers::load_provider(id.as_str()) - .map_err(|e| { - ErrorResponse::not_found(format!("Custom provider '{}' not found: {}", id, e)) - })?; - - Ok(Json(loaded_provider)) -} - -#[utoipa::path( - delete, - path = "/config/custom-providers/{id}", - responses( - (status = 200, description = "Custom provider removed successfully", body = String), - (status = 404, description = "Provider not found"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn remove_custom_provider(Path(id): Path) -> Result, ErrorResponse> { - goose::config::declarative_providers::remove_custom_provider(&id)?; - - goose::providers::refresh_custom_providers().await?; - - Ok(Json(format!("Removed custom provider: {}", id))) -} - -#[utoipa::path( - post, - path = "/config/providers/{name}/cleanup", - params( - ("name" = String, Path, description = "Provider name (e.g., githubcopilot)") - ), - responses( - (status = 200, description = "Provider cache cleaned up successfully", body = String), - (status = 500, description = "Internal server error") - ) -)] -pub async fn cleanup_provider_cache( - Path(name): Path, -) -> Result, ErrorResponse> { - goose::providers::cleanup_provider(&name).await?; - Ok(Json(format!("Cleaned up provider cache: {}", name))) -} - -#[utoipa::path( - put, - path = "/config/custom-providers/{id}", - request_body = UpdateCustomProviderRequest, - responses( - (status = 200, description = "Custom provider updated successfully", body = String), - (status = 404, description = "Provider not found"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn update_custom_provider( - Path(id): Path, - Json(request): Json, -) -> Result, ErrorResponse> { - goose::config::declarative_providers::update_custom_provider( - goose::config::declarative_providers::UpdateCustomProviderParams { - id: id.clone(), - engine: request.engine, - display_name: request.display_name, - api_url: request.api_url, - api_key: normalize_custom_provider_api_key(request.api_key), - models: request.models, - supports_streaming: request.supports_streaming, - headers: request.headers, - requires_auth: request.requires_auth, - catalog_provider_id: request.catalog_provider_id, - base_path: request.base_path, - preserves_thinking: request.preserves_thinking, - }, - )?; - - goose::providers::refresh_custom_providers().await?; - - Ok(Json(format!("Updated custom provider: {}", id))) -} - -#[utoipa::path( - post, - path = "/config/check_provider", - request_body = CheckProviderRequest, -)] -pub async fn check_provider( - Json(CheckProviderRequest { provider }): Json, -) -> Result<(), ErrorResponse> { - // Provider check does not use extensions. - create_with_default_model(&provider, Vec::new()) - .await - .map_err(|err| { - ErrorResponse::bad_request(format!("Provider '{}' check failed: {}", provider, err)) - })?; - Ok(()) -} - -#[utoipa::path( - post, - path = "/config/set_provider", - request_body = SetProviderRequest, -)] -pub async fn set_config_provider( - Json(SetProviderRequest { provider, model }): Json, -) -> Result<(), ErrorResponse> { - // Provider validation does not use extensions. - create_with_default_model(&provider, Vec::new()) - .await - .and_then(|_| { - let config = Config::global(); - goose::config::set_active_provider(config, &provider, &model) - .map_err(|e| anyhow::anyhow!(e)) - }) - .map_err(|err| { - ErrorResponse::bad_request(format!( - "Failed to set provider to '{}' with model '{}': {}", - provider, model, err - )) - })?; - Ok(()) -} - -#[utoipa::path( - get, - path = "/config/provider-catalog", - params( - ("format" = Option, Query, description = "Filter by provider format (openai, anthropic, ollama)") - ), - responses( - (status = 200, description = "Provider catalog retrieved successfully", body = [ProviderCatalogEntry]), - (status = 400, description = "Invalid format parameter") - ) -)] -pub async fn get_provider_catalog( - axum::extract::Query(params): axum::extract::Query>, -) -> Result>, ErrorResponse> { - let format_str = params.get("format").map(|s| s.as_str()).unwrap_or("openai"); - - let format = format_str.parse::().map_err(|_| { - ErrorResponse::bad_request(format!( - "Invalid format '{}'. Must be one of: openai, anthropic, ollama", - format_str - )) - })?; - - let providers = get_providers_by_format(format).await; - Ok(Json(providers)) -} - -#[utoipa::path( - get, - path = "/config/provider-catalog/{id}", - params( - ("id" = String, Path, description = "Provider ID from models.dev") - ), - responses( - (status = 200, description = "Provider template retrieved successfully", body = ProviderTemplate), - (status = 404, description = "Provider not found in catalog") - ) -)] -pub async fn get_provider_catalog_template( - Path(id): Path, -) -> Result, ErrorResponse> { - let template = get_provider_template(&id).ok_or_else(|| { - ErrorResponse::not_found(format!("Provider '{}' not found in catalog", id)) - })?; - - Ok(Json(template)) -} - -#[utoipa::path( - post, - path = "/config/providers/{name}/oauth", - params( - ("name" = String, Path, description = "Provider name") - ), - responses( - (status = 200, description = "OAuth configuration completed"), - (status = 400, description = "OAuth configuration failed") - ) -)] -pub async fn configure_provider_oauth( - Path(provider_name): Path, -) -> Result, ErrorResponse> { - use goose::model::ModelConfig; - use goose::providers::create; - - if !is_valid_provider_name(&provider_name) { - return Err(ErrorResponse::bad_request(format!( - "Invalid provider name: '{}'", - provider_name - ))); - } - - let temp_model = ModelConfig::new("temp") - .map_err(|e| { - ErrorResponse::bad_request(format!("Failed to create temporary model config: {}", e)) - })? - .with_canonical_limits(&provider_name); - - // OAuth configuration does not use extensions. - let provider = create(&provider_name, temp_model, Vec::new()) - .await - .map_err(|e| { - ErrorResponse::bad_request(format!( - "Failed to create provider '{}': {}", - provider_name, e - )) - })?; - - provider.configure_oauth().await.map_err(|e| { - ErrorResponse::bad_request(format!( - "OAuth configuration failed for provider '{}': {}", - provider_name, e - )) - })?; - - // Mark the provider as configured after successful OAuth - let config = goose::config::Config::global(); - if let Some(mut entry) = goose::config::get_provider_entry(config, &provider_name) { - entry.configured = true; - goose::config::set_provider_entry(config, &provider_name, &entry)?; - } else { - let model = if goose::config::get_active_provider(config).as_deref() - == Some(provider_name.as_str()) - { - config.get_goose_model().unwrap_or_default() - } else { - String::new() - }; - goose::config::set_provider_entry( - config, - &provider_name, - &goose::config::ProviderEntry { - enabled: true, - model, - configured: true, - }, - )?; - } - - Ok(Json("OAuth configuration completed".to_string())) -} - -pub fn routes(state: Arc) -> Router { - Router::new() - .route("/config", get(read_all_config)) - .route("/config/upsert", post(upsert_config)) - .route("/config/remove", post(remove_config)) - .route("/config/read", post(read_config)) - .route("/config/extensions", get(get_extensions)) - .route("/config/extensions", post(add_extension)) - .route("/config/extensions/{name}", delete(remove_extension)) - .route("/config/providers", get(providers)) - .route("/config/providers/{name}/models", get(get_provider_models)) - .route( - "/config/providers/{name}/model-info", - post(get_provider_model_info), - ) - .route("/config/provider-catalog", get(get_provider_catalog)) - .route( - "/config/provider-catalog/{id}", - get(get_provider_catalog_template), - ) - .route( - "/config/providers/{name}/cleanup", - post(cleanup_provider_cache), - ) - .route("/config/slash_commands", get(get_slash_commands)) - .route( - "/config/canonical-model-info", - post(get_canonical_model_info), - ) - .route("/config/validate", get(validate_config)) - .route("/config/permissions", post(upsert_permissions)) - .route("/config/custom-providers", post(create_custom_provider)) - .route( - "/config/custom-providers/{id}", - delete(remove_custom_provider), - ) - .route("/config/custom-providers/{id}", put(update_custom_provider)) - .route("/config/custom-providers/{id}", get(get_custom_provider)) - .route("/config/check_provider", post(check_provider)) - .route("/config/set_provider", post(set_config_provider)) - .route( - "/config/providers/{name}/oauth", - post(configure_provider_oauth), - ) - .with_state(state) -} - -#[cfg(test)] -mod tests {} diff --git a/crates/goose-server/src/routes/dictation.rs b/crates/goose-server/src/routes/dictation.rs 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 0866f81293..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::model::ConfigError as ModelConfigError; -use goose::providers::errors::ProviderError; -use serde::Serialize; -use utoipa::ToSchema; - -#[derive(Debug, Serialize, ToSchema)] -pub struct ErrorResponse { - pub message: String, - #[serde(skip)] - pub status: StatusCode, -} - -impl ErrorResponse { - pub(crate) fn internal(message: impl Into) -> Self { - Self { - message: message.into(), - status: StatusCode::INTERNAL_SERVER_ERROR, - } - } - - pub(crate) fn bad_request(message: impl Into) -> Self { - Self { - message: message.into(), - status: StatusCode::BAD_REQUEST, - } - } - - pub(crate) fn not_found(message: impl Into) -> Self { - Self { - message: message.into(), - status: StatusCode::NOT_FOUND, - } - } - - pub(crate) fn unprocessable(message: impl Into) -> Self { - Self { - message: message.into(), - status: StatusCode::UNPROCESSABLE_ENTITY, - } - } -} - -impl IntoResponse for ErrorResponse { - fn into_response(self) -> Response { - if self.status.is_server_error() { - tracing::error!(status = %self.status, message = %self.message, "server error response"); - } else if self.status.is_client_error() { - tracing::warn!(status = %self.status, message = %self.message, "client error response"); - } - - let body = Json(serde_json::json!({ - "message": self.message, - })); - - (self.status, body).into_response() - } -} - -impl From for ErrorResponse { - fn from(err: anyhow::Error) -> Self { - Self::internal(err.to_string()) - } -} - -impl From for ErrorResponse { - fn from(err: ConfigError) -> Self { - match err { - ConfigError::NotFound(key) => Self::not_found(format!("Config key not found: {}", key)), - _ => Self::internal(err.to_string()), - } - } -} - -impl From for ErrorResponse { - fn from(err: ModelConfigError) -> Self { - Self::internal(format!("Model configuration error: {}", err)) - } -} - -impl From for ErrorResponse { - fn from(status: StatusCode) -> Self { - let message = status.canonical_reason().unwrap_or("Unknown error"); - Self { - message: message.to_string(), - status, - } - } -} - -impl From for ErrorResponse { - fn from(err: std::io::Error) -> Self { - Self::internal(format!("IO error: {}", err)) - } -} - -impl From for ErrorResponse { - fn from(err: serde_json::Error) -> Self { - Self::internal(format!("JSON serialization error: {}", err)) - } -} - -impl From for ErrorResponse { - fn from(err: serde_yaml::Error) -> Self { - Self::unprocessable(format!("YAML parsing error: {}", err)) - } -} - -impl From for ErrorResponse { - fn from(err: ProviderError) -> Self { - let (status, message) = match err { - ProviderError::Authentication(_) => ( - StatusCode::BAD_REQUEST, - format!("Authentication failed: {}", err), - ), - ProviderError::UsageError(_) => { - (StatusCode::BAD_REQUEST, format!("Usage error: {}", err)) - } - ProviderError::RateLimitExceeded { .. } => ( - StatusCode::TOO_MANY_REQUESTS, - format!("Rate limit exceeded: {}", err), - ), - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Provider error: {}", err), - ), - }; - - Self { message, status } - } -} diff --git a/crates/goose-server/src/routes/features.rs b/crates/goose-server/src/routes/features.rs 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 8ee9bc4eeb..0000000000 --- a/crates/goose-server/src/routes/local_inference.rs +++ /dev/null @@ -1,792 +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}; -use goose::providers::local_inference::hf_models::{self, HfModelInfo, HfQuantVariant}; -use goose::providers::local_inference::{ - available_inference_memory_bytes, builtin_chat_template_names, - hf_models::{resolve_model_spec_full, HfGgufFile}, - local_model_registry::{ - default_settings_for_model, get_registry, is_featured_model, mmproj_local_path, - model_id_from_repo, LocalModelEntry, ModelDownloadStatus as RegistryDownloadStatus, - ModelSettings, ShardFile, FEATURED_MODELS, - }, - recommend_local_model, -}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use tracing::debug; -use utoipa::ToSchema; - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(tag = "state")] -pub enum ModelDownloadStatus { - NotDownloaded, - Downloading { - progress_percent: f32, - bytes_downloaded: u64, - total_bytes: u64, - speed_bps: Option, - }, - Downloaded, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct LocalModelResponse { - pub id: String, - pub repo_id: String, - pub filename: String, - pub quantization: String, - pub size_bytes: u64, - pub status: ModelDownloadStatus, - pub recommended: bool, - pub settings: ModelSettings, - pub vision_capable: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub mmproj_status: Option, -} - -async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> { - let mut mmproj_downloads_needed: Vec<(String, String, PathBuf)> = Vec::new(); - - struct PendingResolve { - spec: &'static str, - repo_id: String, - quantization: String, - model_id: String, - } - let mut to_resolve = Vec::new(); - - for featured in FEATURED_MODELS { - let (repo_id, quantization) = match hf_models::parse_model_spec(featured.spec) { - Ok(parts) => parts, - Err(_) => continue, - }; - - let model_id = model_id_from_repo(&repo_id, &quantization); - - { - let registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - if let Some(existing) = registry.get_model(&model_id) { - if let Some(path) = &existing.mmproj_path { - if existing.is_downloaded() && !path.exists() { - if let Some(url) = &existing.mmproj_source_url { - mmproj_downloads_needed.push(( - model_id.clone(), - url.clone(), - path.clone(), - )); - } - } - continue; - } - } - } - - to_resolve.push(PendingResolve { - spec: featured.spec, - repo_id, - quantization, - model_id, - }); - } - - let resolved: Vec<(PendingResolve, HfGgufFile, Option)> = - join_all(to_resolve.into_iter().map(|pending| async move { - let (hf_file, mmproj) = match resolve_model_spec_full(pending.spec).await { - Ok((_repo, resolved)) => (resolved.files[0].clone(), resolved.mmproj), - Err(_) => { - let filename = format!( - "{}-{}.gguf", - pending.repo_id.split('/').next_back().unwrap_or("model"), - pending.quantization - ); - ( - HfGgufFile { - filename: filename.clone(), - size_bytes: 0, - quantization: pending.quantization.to_string(), - download_url: format!( - "https://huggingface.co/{}/resolve/main/{}", - pending.repo_id, filename - ), - }, - None, - ) - } - }; - (pending, hf_file, mmproj) - })) - .await; - - let entries_to_add: Vec = resolved - .into_iter() - .map(|(pending, hf_file, mmproj)| { - let local_path = Paths::in_data_dir("models").join(&hf_file.filename); - let settings = default_settings_for_model(&pending.model_id); - let mmproj_path = mmproj - .as_ref() - .map(|mmproj| mmproj_local_path(&pending.repo_id, &mmproj.filename)); - let mmproj_source_url = mmproj.as_ref().map(|mmproj| mmproj.download_url.clone()); - let mmproj_size_bytes = mmproj.as_ref().map_or(0, |mmproj| mmproj.size_bytes); - let mmproj_checked = mmproj.is_some(); - LocalModelEntry { - id: pending.model_id, - repo_id: pending.repo_id, - filename: hf_file.filename, - quantization: pending.quantization, - local_path, - source_url: hf_file.download_url, - settings, - size_bytes: hf_file.size_bytes, - mmproj_path, - mmproj_source_url, - mmproj_size_bytes, - mmproj_checked, - shard_files: vec![], - } - }) - .collect(); - - { - let mut registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - - if !entries_to_add.is_empty() { - registry.sync_with_featured(entries_to_add); - } - } - - let to_backfill: Vec<(String, String, String)> = { - let registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - - registry - .list_models() - .iter() - .filter(|model| model.is_downloaded()) - .filter(|model| model.mmproj_path.is_none()) - .filter(|model| !model.mmproj_checked) - .map(|model| { - ( - model.id.clone(), - model.repo_id.clone(), - model.quantization.clone(), - ) - }) - .collect() - }; - - let mmproj_backfills: Vec<(String, String, Option>)> = join_all( - to_backfill - .into_iter() - .map(|(id, repo_id, quantization)| async move { - let spec = format!("{repo_id}:{quantization}"); - let mmproj = resolve_model_spec_full(&spec) - .await - .ok() - .map(|(_, resolved)| resolved.mmproj); - (id, repo_id, mmproj) - }), - ) - .await; - - { - let mut registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - - for (model_id, repo_id, mmproj_result) in mmproj_backfills { - if let Some(model) = registry - .list_models_mut() - .iter_mut() - .find(|model| model.id == model_id) - { - let Some(mmproj) = mmproj_result else { - continue; - }; - - model.mmproj_checked = true; - if let Some(mmproj) = mmproj { - model.mmproj_path = Some(mmproj_local_path(&repo_id, &mmproj.filename)); - model.mmproj_source_url = Some(mmproj.download_url); - model.mmproj_size_bytes = mmproj.size_bytes; - } - model.refresh_mmproj_metadata(); - } - } - - for model in registry.list_models_mut() { - model.refresh_mmproj_metadata(); - if model.is_downloaded() { - if let Some(path) = &model.mmproj_path { - if !path.exists() { - if let Some(url) = &model.mmproj_source_url { - mmproj_downloads_needed.push(( - model.id.clone(), - url.clone(), - path.clone(), - )); - } - } - } - } - } - let _ = registry.save(); - } - - // Auto-download mmproj files for models that are already downloaded. - // Deduplicate by path since multiple quants share one mmproj file. - let dm = get_download_manager(); - let mut started_paths = std::collections::HashSet::new(); - for (model_id, url, path) in mmproj_downloads_needed { - if !path.exists() && started_paths.insert(path.clone()) { - let download_id = format!("{}-mmproj", model_id); - let dominated_by_active = dm - .get_progress(&download_id) - .is_some_and(|p| p.status == goose::download_manager::DownloadStatus::Downloading); - if !dominated_by_active { - tracing::info!(model_id = %model_id, "Auto-downloading vision encoder for existing model"); - if let Err(e) = dm.download_model(download_id, url, path, None).await { - tracing::warn!(model_id = %model_id, error = %e, "Failed to start mmproj download"); - } - } - } - } - - Ok(()) -} - -#[utoipa::path( - post, - path = "/local-inference/sync-featured", - responses( - (status = 200, description = "Featured models synced to registry") - ) -)] -pub async fn sync_featured_models() -> Result { - ensure_featured_models_in_registry().await?; - Ok(StatusCode::OK) -} - -#[utoipa::path( - get, - path = "/local-inference/models", - responses( - (status = 200, description = "List of available local LLM models", body = Vec) - ) -)] -pub async fn list_local_models( - axum::extract::State(state): axum::extract::State>, -) -> Result>, ErrorResponse> { - let runtime = state.get_inference_runtime()?; - let recommended_id = recommend_local_model(&runtime); - - let registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - - let mut models: Vec = Vec::new(); - - for entry in registry.list_models() { - let goose_status = entry.download_status(); - - let status = match goose_status { - RegistryDownloadStatus::NotDownloaded => ModelDownloadStatus::NotDownloaded, - RegistryDownloadStatus::Downloading { - progress_percent, - bytes_downloaded, - total_bytes, - speed_bps, - } => ModelDownloadStatus::Downloading { - progress_percent, - bytes_downloaded, - total_bytes, - speed_bps: Some(speed_bps), - }, - RegistryDownloadStatus::Downloaded => ModelDownloadStatus::Downloaded, - }; - - let size_bytes = entry.file_size(); - - let vision_capable = entry.settings.vision_capable; - let mmproj_status = if vision_capable { - let ms = entry.mmproj_download_status(); - Some(match ms { - RegistryDownloadStatus::NotDownloaded => ModelDownloadStatus::NotDownloaded, - RegistryDownloadStatus::Downloading { - progress_percent, - bytes_downloaded, - total_bytes, - speed_bps, - } => ModelDownloadStatus::Downloading { - progress_percent, - bytes_downloaded, - total_bytes, - speed_bps: Some(speed_bps), - }, - RegistryDownloadStatus::Downloaded => ModelDownloadStatus::Downloaded, - }) - } else { - None - }; - - models.push(LocalModelResponse { - id: entry.id.clone(), - repo_id: entry.repo_id.clone(), - filename: entry.filename.clone(), - quantization: entry.quantization.clone(), - size_bytes, - status, - recommended: recommended_id == entry.id, - settings: entry.settings.clone(), - vision_capable, - mmproj_status, - }); - } - - models.sort_by(|a, b| { - let a_downloaded = matches!(a.status, ModelDownloadStatus::Downloaded); - let b_downloaded = matches!(b.status, ModelDownloadStatus::Downloaded); - match (b_downloaded, a_downloaded) { - (true, false) => std::cmp::Ordering::Greater, - (false, true) => std::cmp::Ordering::Less, - _ => a.id.cmp(&b.id), - } - }); - - Ok(Json(models)) -} - -#[derive(Debug, Deserialize)] -pub struct SearchQuery { - pub q: String, - pub limit: Option, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct RepoVariantsResponse { - pub variants: Vec, - pub recommended_index: Option, - pub available_memory_bytes: u64, - pub downloaded_quants: Vec, -} - -#[utoipa::path( - get, - path = "/local-inference/search", - params( - ("q" = String, Query, description = "Search query"), - ("limit" = Option, Query, description = "Max results") - ), - responses( - (status = 200, description = "Search results", body = Vec), - (status = 500, description = "Search failed") - ) -)] -pub async fn search_hf_models( - Query(params): Query, -) -> Result>, ErrorResponse> { - let limit = params.limit.unwrap_or(20).min(50); - let results = hf_models::search_gguf_models(¶ms.q, limit) - .await - .map_err(|e| ErrorResponse::internal(format!("Search failed: {}", e)))?; - Ok(Json(results)) -} - -#[utoipa::path( - get, - path = "/local-inference/repo/{author}/{repo}/files", - responses( - (status = 200, description = "GGUF files in the repo", body = RepoVariantsResponse) - ) -)] -pub async fn get_repo_files( - axum::extract::State(state): axum::extract::State>, - Path((author, repo)): Path<(String, String)>, -) -> Result, ErrorResponse> { - let repo_id = format!("{}/{}", author, repo); - let variants = hf_models::get_repo_gguf_variants(&repo_id) - .await - .map_err(|e| ErrorResponse::internal(format!("Failed to fetch repo files: {}", e)))?; - - let runtime = state.get_inference_runtime()?; - let available_memory = available_inference_memory_bytes(&runtime); - let recommended_index = hf_models::recommend_variant(&variants, available_memory); - - let downloaded_quants = { - let registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - registry - .list_models() - .iter() - .filter(|m| m.repo_id == repo_id && m.is_downloaded()) - .map(|m| m.quantization.clone()) - .collect() - }; - - Ok(Json(RepoVariantsResponse { - variants, - recommended_index, - available_memory_bytes: available_memory, - downloaded_quants, - })) -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct DownloadModelRequest { - /// Model spec like "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M" - pub spec: String, -} - -#[utoipa::path( - post, - path = "/local-inference/download", - request_body = DownloadModelRequest, - responses( - (status = 202, description = "Download started", body = String), - (status = 400, description = "Invalid request") - ) -)] -pub async fn download_hf_model( - Json(req): Json, -) -> Result<(StatusCode, Json), ErrorResponse> { - let (repo_id, quantization) = hf_models::parse_model_spec(&req.spec) - .map_err(|e| ErrorResponse::bad_request(format!("Invalid spec format: {e}")))?; - - let (_repo, resolved) = resolve_model_spec_full(&req.spec) - .await - .map_err(|e| ErrorResponse::bad_request(format!("Invalid spec: {}", e)))?; - - let model_id = model_id_from_repo(&repo_id, &quantization); - let models_dir = Paths::in_data_dir("models"); - let first_file = &resolved.files[0]; - let first_local_path = models_dir.join(&first_file.filename); - - let shard_files: Vec = if resolved.files.len() > 1 { - resolved - .files - .iter() - .skip(1) - .map(|f| ShardFile { - filename: f.filename.clone(), - local_path: models_dir.join(&f.filename), - source_url: f.download_url.clone(), - size_bytes: f.size_bytes, - }) - .collect() - } else { - vec![] - }; - - let mmproj_path = resolved - .mmproj - .as_ref() - .map(|mmproj| mmproj_local_path(&repo_id, &mmproj.filename)); - let mmproj_source_url = resolved - .mmproj - .as_ref() - .map(|mmproj| mmproj.download_url.clone()); - let mmproj_size_bytes = resolved - .mmproj - .as_ref() - .map_or(0, |mmproj| mmproj.size_bytes); - let mmproj_checked = true; - - let entry = LocalModelEntry { - id: model_id.clone(), - repo_id, - filename: first_file.filename.clone(), - quantization, - local_path: first_local_path.clone(), - source_url: first_file.download_url.clone(), - settings: default_settings_for_model(&model_id), - size_bytes: resolved.total_size, - mmproj_path, - mmproj_source_url, - mmproj_size_bytes, - mmproj_checked, - shard_files: shard_files.clone(), - }; - - let mmproj_path = { - let mut registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - registry - .add_model(entry) - .map_err(|e| ErrorResponse::internal(format!("{}", e)))?; - registry.get_model(&model_id).and_then(|e| { - e.mmproj_path - .as_ref() - .zip(e.mmproj_source_url.as_ref()) - .map(|(p, u)| (p.clone(), u.clone())) - }) - }; - - let dm = get_download_manager(); - let all_files: Vec<(String, std::path::PathBuf)> = resolved - .files - .iter() - .map(|f| (f.download_url.clone(), models_dir.join(&f.filename))) - .collect(); - - dm.download_model_sharded( - format!("{}-model", model_id), - all_files, - resolved.total_size, - None, - ) - .await - .map_err(|e| ErrorResponse::internal(format!("Download failed: {}", e)))?; - - if let Some((mmproj_path, mmproj_url)) = mmproj_path { - if !mmproj_path.exists() { - dm.download_model( - format!("{}-mmproj", model_id), - mmproj_url, - mmproj_path, - None, - ) - .await - .map_err(|e| ErrorResponse::internal(format!("mmproj download failed: {}", e)))?; - } - } - - Ok((StatusCode::ACCEPTED, Json(model_id))) -} - -#[utoipa::path( - get, - path = "/local-inference/models/{model_id}/download", - responses( - (status = 200, description = "Download progress", body = DownloadProgress), - (status = 404, description = "No active download") - ) -)] -pub async fn get_local_model_download_progress( - Path(model_id): Path, -) -> Result, ErrorResponse> { - let download_id = format!("{}-model", model_id); - debug!(model_id = %model_id, download_id = %download_id, "Getting download progress"); - - let manager = get_download_manager(); - - let model_progress = manager - .get_progress(&download_id) - .ok_or_else(|| ErrorResponse::not_found("No active download"))?; - - Ok(Json(model_progress)) -} - -#[utoipa::path( - delete, - path = "/local-inference/models/{model_id}/download", - responses( - (status = 200, description = "Download cancelled"), - (status = 404, description = "No active download") - ) -)] -pub async fn cancel_local_model_download( - Path(model_id): Path, -) -> Result { - let manager = get_download_manager(); - manager - .cancel_download(&format!("{}-model", model_id)) - .map_err(|e| ErrorResponse::internal(format!("{}", e)))?; - let _ = manager.cancel_download(&format!("{}-mmproj", model_id)); - - Ok(StatusCode::OK) -} - -#[utoipa::path( - delete, - path = "/local-inference/models/{model_id}", - responses( - (status = 200, description = "Model deleted"), - (status = 404, description = "Model not found") - ) -)] -pub async fn delete_local_model(Path(model_id): Path) -> Result { - let (all_paths, primary_path, mmproj_path, other_uses_mmproj) = { - let registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - let entry = registry - .get_model(&model_id) - .ok_or_else(|| ErrorResponse::not_found("Model not found"))?; - let paths: Vec = - entry.all_local_paths().map(|p| p.to_path_buf()).collect(); - let primary = entry.local_path.clone(); - let mp = entry.mmproj_path.clone(); - let shared = mp.as_ref().is_some_and(|target| { - registry.list_models().iter().any(|m| { - m.id != model_id && m.is_downloaded() && m.mmproj_path.as_ref() == Some(target) - }) - }); - (paths, primary, mp, shared) - }; - - for path in &all_paths { - if path.exists() { - tokio::fs::remove_file(path) - .await - .map_err(|e| ErrorResponse::internal(format!("Failed to delete: {}", e)))?; - } - } - - // Clean up empty parent directories (e.g. BF16/ subdirectory) - if let Some(parent) = primary_path.parent() { - let models_dir = Paths::in_data_dir("models"); - if parent != models_dir { - let _ = tokio::fs::remove_dir(parent).await; - } - } - - if !other_uses_mmproj { - if let Some(mmproj) = mmproj_path { - if mmproj.exists() { - let _ = tokio::fs::remove_file(&mmproj).await; - } - } - } - - // Only remove non-featured models from registry (featured ones stay as placeholders) - if !is_featured_model(&model_id) { - let mut registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - registry - .remove_model(&model_id) - .map_err(|e| ErrorResponse::internal(format!("{}", e)))?; - } - - Ok(StatusCode::OK) -} - -#[utoipa::path( - get, - path = "/local-inference/models/{model_id}/settings", - responses( - (status = 200, description = "Model settings", body = ModelSettings), - (status = 404, description = "Model not found") - ) -)] -pub async fn get_model_settings( - Path(model_id): Path, -) -> Result, ErrorResponse> { - let registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - - if let Some(settings) = registry.get_model_settings(&model_id) { - return Ok(Json(settings.clone())); - } - - Err(ErrorResponse::not_found("Model not found")) -} - -#[utoipa::path( - put, - path = "/local-inference/models/{model_id}/settings", - request_body = ModelSettings, - responses( - (status = 200, description = "Settings updated", body = ModelSettings), - (status = 404, description = "Model not found"), - (status = 500, description = "Failed to save settings") - ) -)] -pub async fn update_model_settings( - Path(model_id): Path, - Json(settings): Json, -) -> Result, ErrorResponse> { - let mut registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - - registry - .update_model_settings(&model_id, settings.clone()) - .map_err(|e| ErrorResponse::not_found(format!("{}", e)))?; - - Ok(Json(settings)) -} - -#[utoipa::path( - get, - path = "/local-inference/chat-templates/builtin", - responses( - (status = 200, description = "llama.cpp built-in chat template names", body = Vec) - ) -)] -pub async fn list_builtin_chat_templates() -> Json> { - Json(builtin_chat_template_names()) -} - -pub fn routes(state: Arc) -> Router { - let registered_paths: std::collections::HashSet = get_registry() - .lock() - .map(|reg| { - reg.list_models() - .iter() - .flat_map(|m| { - m.all_local_paths() - .map(|p| p.to_path_buf()) - .chain(m.mmproj_path.as_deref().map(|p| p.to_path_buf())) - }) - .collect() - }) - .unwrap_or_default(); - goose::download_manager::cleanup_partial_downloads( - &Paths::in_data_dir("models"), - ®istered_paths, - ); - - Router::new() - .route("/local-inference/models", get(list_local_models)) - .route("/local-inference/sync-featured", post(sync_featured_models)) - .route("/local-inference/search", get(search_hf_models)) - .route( - "/local-inference/chat-templates/builtin", - get(list_builtin_chat_templates), - ) - .route( - "/local-inference/repo/{author}/{repo}/files", - get(get_repo_files), - ) - .route("/local-inference/download", post(download_hf_model)) - .route( - "/local-inference/models/{model_id}/download", - get(get_local_model_download_progress), - ) - .route( - "/local-inference/models/{model_id}/download", - delete(cancel_local_model_download), - ) - .route( - "/local-inference/models/{model_id}", - delete(delete_local_model), - ) - .route( - "/local-inference/models/{model_id}/settings", - get(get_model_settings), - ) - .route( - "/local-inference/models/{model_id}/settings", - axum::routing::put(update_model_settings), - ) - .with_state(state) -} diff --git a/crates/goose-server/src/routes/mcp_app_proxy.rs b/crates/goose-server/src/routes/mcp_app_proxy.rs 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