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 e0c7e4d15d97..48ffe753ab71 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 51d3018cbb0b..00ba2fc8669b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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/bundle-desktop-intel.yml b/.github/workflows/bundle-desktop-intel.yml index f68e791165c3..e8a1d4f6b744 100644 --- a/.github/workflows/bundle-desktop-intel.yml +++ b/.github/workflows/bundle-desktop-intel.yml @@ -73,11 +73,11 @@ jobs: key: intel-macos-deployment-target-12 - - name: Build goose-server for Intel macOS (x86_64) + - name: Build desktop backend for Intel macOS (x86_64) run: | source ./bin/activate-hermit rustup target add x86_64-apple-darwin - cargo build --release -p goose-server --target x86_64-apple-darwin + cargo build --release -p goose-cli --bin goose --target x86_64-apple-darwin @@ -95,9 +95,13 @@ 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@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 @@ -153,6 +157,10 @@ jobs: fi working-directory: ui/desktop + - name: Verify macOS updater resources + run: node scripts/verify-mac-update-resources.js "out/Goose-darwin-x64/Goose.app" + working-directory: ui/desktop + - name: Clean up signing keychain if: always() run: | diff --git a/.github/workflows/bundle-desktop-linux.yml b/.github/workflows/bundle-desktop-linux.yml index 18ba931a6c48..ad3b84f1946a 100644 --- a/.github/workflows/bundle-desktop-linux.yml +++ b/.github/workflows/bundle-desktop-linux.yml @@ -124,7 +124,7 @@ jobs: with: key: linux-${{ matrix.build-on }}-${{ matrix.variant }} - - name: Build goosed binary + - name: Build desktop backend binary env: RUST_LOG: debug RUST_BACKTRACE: 1 @@ -137,15 +137,16 @@ 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 diff --git a/.github/workflows/bundle-desktop-windows.yml b/.github/workflows/bundle-desktop-windows.yml index 7a3dd2a9fb29..400f9d093a7a 100644 --- a/.github/workflows/bundle-desktop-windows.yml +++ b/.github/workflows/bundle-desktop-windows.yml @@ -113,28 +113,33 @@ jobs: env: CUDA_COMPUTE_CAP: ${{ inputs.windows_variant == 'cuda' && '80' || '' }} run: | - Write-Output "Building Windows executable..." - if ("${{ inputs.windows_variant }}" -eq "cuda") { - cargo build --release --target x86_64-pc-windows-msvc -p goose-server --features cuda + $isCuda = "${{ inputs.windows_variant }}" -eq "cuda" + + Write-Output "Building Windows ACP backend" + if ($isCuda) { + cargo build --release --target x86_64-pc-windows-msvc -p goose-cli --bin goose --features cuda } else { - cargo build --release --target x86_64-pc-windows-msvc -p goose-server + cargo build --release --target x86_64-pc-windows-msvc -p goose-cli --bin goose } + $binaryPath = "./target/x86_64-pc-windows-msvc/release/goose.exe" # Verify build succeeded - if (-not (Test-Path "./target/x86_64-pc-windows-msvc/release/goosed.exe")) { - Write-Error "Windows binary not found." + if (-not (Test-Path $binaryPath)) { + Write-Error "Windows backend binary not found: $binaryPath" Get-ChildItem ./target/x86_64-pc-windows-msvc/release/ -ErrorAction SilentlyContinue exit 1 } - Write-Output "Windows binary found." - Get-Item ./target/x86_64-pc-windows-msvc/release/goosed.exe + Write-Output "Windows backend binary found." + Get-Item $binaryPath - name: Prepare Windows binary shell: bash run: | - if [ ! -f "./target/x86_64-pc-windows-msvc/release/goosed.exe" ]; then - echo "Windows binary not found." + BACKEND_BINARY="./target/x86_64-pc-windows-msvc/release/goose.exe" + + if [ ! -f "$BACKEND_BINARY" ]; then + echo "Windows backend binary not found: $BACKEND_BINARY" exit 1 fi @@ -142,13 +147,14 @@ jobs: rm -rf ./ui/desktop/src/bin mkdir -p ./ui/desktop/src/bin - echo "Copying Windows binary..." - cp -f ./target/x86_64-pc-windows-msvc/release/goosed.exe ./ui/desktop/src/bin/ + echo "Copying Windows backend binary..." + cp -f "$BACKEND_BINARY" ./ui/desktop/src/bin/ if [ -d "./ui/desktop/src/platform/windows/bin" ]; then echo "Copying Windows platform files..." for file in ./ui/desktop/src/platform/windows/bin/*.{exe,dll,cmd}; do - if [ -f "$file" ] && [ "$(basename "$file")" != "goosed.exe" ]; then + filename="$(basename "$file")" + if [ -f "$file" ] && [ "$filename" != "goose.exe" ]; then cp -f "$file" ./ui/desktop/src/bin/ fi done @@ -229,14 +235,14 @@ jobs: certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }} files: | ${{ github.workspace }}/dist-windows/Goose.exe - ${{ github.workspace }}/dist-windows/resources/bin/goosed.exe + ${{ github.workspace }}/dist-windows/resources/bin/goose.exe - name: Verify signed executables shell: pwsh run: | $files = @( "dist-windows/Goose.exe", - "dist-windows/resources/bin/goosed.exe" + "dist-windows/resources/bin/goose.exe" ) foreach ($file in $files) { Write-Output "Verifying signature: $file" diff --git a/.github/workflows/bundle-desktop.yml b/.github/workflows/bundle-desktop.yml index 172571fd93c2..43b1413dd2d5 100644 --- a/.github/workflows/bundle-desktop.yml +++ b/.github/workflows/bundle-desktop.yml @@ -118,8 +118,10 @@ jobs: key: macos-deployment-target-12 # Build the project - - name: Build goosed - run: source ./bin/activate-hermit && cargo build --release -p goose-server + - name: Build desktop backend + run: | + source ./bin/activate-hermit + cargo build --release -p goose-cli --bin goose # Post-build cleanup to free space - name: Post-build cleanup @@ -134,9 +136,13 @@ 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@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 @@ -185,6 +191,10 @@ jobs: fi working-directory: ui/desktop + - name: Verify macOS updater resources + run: node scripts/verify-mac-update-resources.js "out/Goose-darwin-arm64/Goose.app" + working-directory: ui/desktop + - name: Clean up signing keychain if: always() run: | diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 58ac9fb5a857..f93599880c84 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -67,7 +67,7 @@ jobs: path: download_cli.sh # ------------------------------------------------------------ - # 4) Bundle Desktop App (macOS only) - builds goosed and Electron app + # 4) Bundle Desktop App (macOS only) # ------------------------------------------------------------ bundle-desktop: needs: [prepare-version] @@ -80,7 +80,7 @@ jobs: signing: false # ------------------------------------------------------------ - # 5) Bundle Desktop App (macOS Intel) - builds goosed and Electron app + # 5) Bundle Desktop App (macOS Intel) # ------------------------------------------------------------ bundle-desktop-intel: needs: [prepare-version] @@ -93,7 +93,7 @@ jobs: signing: false # ------------------------------------------------------------ - # 6) Bundle Desktop App (Linux) - builds goosed and Electron app + # 6) Bundle Desktop App (Linux) # ------------------------------------------------------------ bundle-desktop-linux: needs: [prepare-version] @@ -102,7 +102,7 @@ jobs: version: ${{ needs.prepare-version.outputs.version }} # ------------------------------------------------------------ - # 6) Bundle Desktop App (Windows) - builds goosed and Electron app + # 6) Bundle Desktop App (Windows) # ------------------------------------------------------------ bundle-desktop-windows: needs: [prepare-version] @@ -138,7 +138,7 @@ jobs: merge-multiple: true - name: Attest build provenance - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-path: | goose-*.tar.bz2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6106534c791b..1216340871d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: - name: Checkout Code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - name: Run cargo fmt run: cargo fmt --check @@ -55,7 +55,7 @@ jobs: - name: Checkout Code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - name: Install Dependencies run: | @@ -119,7 +119,7 @@ jobs: echo "msrv=$msrv" >> "$GITHUB_OUTPUT" echo "MSRV: $msrv" - - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 with: toolchain: ${{ steps.msrv.outputs.msrv }} @@ -145,7 +145,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 @@ -167,7 +167,7 @@ jobs: - name: Checkout Code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - name: Install Dependencies run: | @@ -183,12 +183,6 @@ jobs: cd ui/desktop && pnpm install --frozen-lockfile cd ../sdk && pnpm install --frozen-lockfile - - name: Check OpenAPI Schema is Up-to-Date - run: | - source ./bin/activate-hermit - hermit uninstall rustup - just check-openapi-schema - - name: Check ACP Schema is Up-to-Date run: | source ./bin/activate-hermit diff --git a/.github/workflows/create-version-bump-pr.yaml b/.github/workflows/create-version-bump-pr.yaml index 9f1403ff5702..b278da810552 100644 --- a/.github/workflows/create-version-bump-pr.yaml +++ b/.github/workflows/create-version-bump-pr.yaml @@ -85,7 +85,7 @@ jobs: **Please follow these steps:** - 1. Close and reopen this PR to trigger CI checks. Reason: workflows don't run on PRs opened by `GITHUB_TOKEN` ([docs](https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow)). + 1. Approve workflows for this PR to trigger CI checks. 2. Review and resolve any merge conflicts. 3. Approve and merge this PR. 4. The `release/${{ env.version }}` PR will be created automatically. diff --git a/.github/workflows/docs-update-cli-ref.yml b/.github/workflows/docs-update-cli-ref.yml index cdb4b425349b..9ac5cfca7d0c 100644 --- a/.github/workflows/docs-update-cli-ref.yml +++ b/.github/workflows/docs-update-cli-ref.yml @@ -63,7 +63,7 @@ jobs: sudo apt-get install -y jq ripgrep - name: Set up Rust - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 with: toolchain: stable diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml index 405cb8d3ec47..ae83ad2afeeb 100644 --- a/.github/workflows/pr-smoke-test.yml +++ b/.github/workflows/pr-smoke-test.yml @@ -55,7 +55,7 @@ jobs: with: ref: ${{ github.event.inputs.branch || github.ref }} - - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - name: Install Dependencies run: | @@ -67,7 +67,7 @@ jobs: - name: Build Binary for Smoke Tests run: | - cargo build --bin goose --bin goosed + cargo build --bin goose - name: Upload goose binary uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -76,13 +76,6 @@ jobs: path: target/debug/goose retention-days: 1 - - name: Upload goosed binary - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: goosed-binary - path: target/debug/goosed - retention-days: 1 - smoke-tests: name: Smoke Tests runs-on: ubuntu-latest @@ -253,39 +246,3 @@ jobs: mkdir -p $HOME/.local/share/goose/sessions mkdir -p $HOME/.config/goose bash scripts/test_compaction.sh - - goosed-integration-tests: - name: goose server HTTP integration tests - runs-on: ubuntu-latest - needs: build-binary - steps: - - name: Checkout Code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.event.inputs.branch || github.ref }} - - - name: Download Binary - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: goosed-binary - path: target/debug - - - name: Make Binary Executable - run: chmod +x target/debug/goosed - - - name: Install Node.js Dependencies - run: source ../../bin/activate-hermit && pnpm install --frozen-lockfile - working-directory: ui/desktop - - - name: Run Integration Tests - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - GOOSED_BINARY: ../../target/debug/goosed - GOOSE_PROVIDER: anthropic - GOOSE_MODEL: claude-sonnet-4-5-20250929 - SHELL: /bin/bash - SKIP_BUILD: 1 - run: | - echo 'export PATH=/some/fake/path:$PATH' >> $HOME/.bash_profile - source ../../bin/activate-hermit && pnpm run test:integration:goosed - working-directory: ui/desktop diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 28aaa04e8040..ef0d674400a3 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -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 }}/apemind-agent subject-digest: ${{ steps.docker-push.outputs.digest }} diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index d3a7e1a42dfb..1653b1dc3087 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -32,7 +32,7 @@ jobs: always-auth: true - name: Setup pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: version: 10.30.3 @@ -164,7 +164,7 @@ jobs: always-auth: true - name: Setup pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: version: 10.30.3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16a578887dcb..4e2ec93d95e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,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 @@ -119,10 +124,17 @@ jobs: merge-multiple: true - name: Generate macOS update manifest + if: ${{ env.ENABLE_MAC_NATIVE_AUTO_UPDATE == 'true' }} run: node ui/desktop/scripts/generate-mac-update-manifest.js --version "${GITHUB_REF_NAME}" --directory . + - name: Attest macOS update manifest + if: ${{ env.ENABLE_MAC_NATIVE_AUTO_UPDATE == 'true' }} + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: latest-mac.yml + - name: Attest build provenance - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-path: | goose-*.tar.bz2 @@ -133,7 +145,6 @@ jobs: *.deb *.rpm *.flatpak - latest-mac.yml download_cli.sh # Create/update the versioned release @@ -150,7 +161,6 @@ jobs: *.deb *.rpm *.flatpak - latest-mac.yml download_cli.sh allowUpdates: true omitBody: true @@ -172,8 +182,15 @@ jobs: *.deb *.rpm *.flatpak - latest-mac.yml download_cli.sh allowUpdates: true omitBody: true omitPrereleaseDuringUpdate: true + + - name: Upload macOS update manifest + if: ${{ env.ENABLE_MAC_NATIVE_AUTO_UPDATE == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${GITHUB_REF_NAME}" latest-mac.yml --clobber + gh release upload stable latest-mac.yml --clobber diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index dbe008a54e2e..35b79315dfa1 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/AGENTS.md b/AGENTS.md index e0fda6d5e075..0f01fcde5530 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ cargo build ```bash cargo build # debug cargo build --release # release -just release-binary # release + openapi +just release-binary # release binary ``` ### Test @@ -33,8 +33,8 @@ cargo clippy --all-targets -- -D warnings ### UI ```bash -just generate-openapi # after server changes just run-ui # start desktop +cd ui/desktop && pnpm run typecheck cd ui/desktop && pnpm test # test UI ``` @@ -44,12 +44,10 @@ crates/ ├── goose # core logic ├── goose-acp-macros # ACP proc macros ├── goose-cli # CLI entry -├── goose-server # backend (binary: goosed) ├── goose-mcp # MCP extensions ├── goose-test # test utilities └── goose-test-support # test helpers -evals/open-model-gym/ # benchmarking / evals ui/desktop/ # Electron app ``` @@ -65,7 +63,6 @@ ui/desktop/ # Electron app # 1. cargo build # 2. cargo test -p # 3. cargo clippy --all-targets -- -D warnings -# 4. [if server] just generate-openapi ``` ## Rules @@ -75,7 +72,7 @@ ui/desktop/ # Electron app - Error: Use anyhow::Result - Provider: Implement Provider trait see providers/base.rs - MCP: Extensions in crates/goose-mcp/ -- Server: Changes need just generate-openapi +- UI Desktop: Use ACP SDK types or local `src/types/*` types. Do not import generated OpenAPI types/client code from `ui/desktop/src/api` ## Code Quality @@ -107,7 +104,7 @@ remaining space for dynamic text. ## Never -- Never: Edit ui/desktop/openapi.json manually +- Never: Recreate `ui/desktop/src/api` or add `@hey-api/openapi-ts` to `ui/desktop` - Cargo.toml: For human-authored dependency changes, use `cargo add` instead of manually editing dependency entries unless there is a specific reason not to. - Cargo.toml: Automated dependency bump PRs are exempt; when manual edits are necessary, keep `Cargo.lock` consistent. - Never: Skip cargo fmt @@ -116,6 +113,5 @@ remaining space for dynamic text. ## Entry Points - CLI: crates/goose-cli/src/main.rs -- Server: crates/goose-server/src/main.rs - UI: ui/desktop/src/main.ts - Agent: crates/goose/src/agents/agent.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49e3dcd56ea9..66b96b14ea88 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -184,41 +184,27 @@ cd ui && pnpm install See #8757. -### Regenerating the OpenAPI schema - -The file `ui/desktop/openapi.json` is automatically generated during the build. -It is written by the `generate_schema` binary in `crates/goose-server`. -To update the spec without starting the UI, run: - -``` -just generate-openapi -``` - -This command regenerates `ui/desktop/openapi.json` and then runs the UI's -`generate-api` script to rebuild the TypeScript client from that spec. - -API changes should be made in the Rust source under `crates/goose-server/src/`. - ### Debugging -To debug the Goose server, run it from an IDE. The configuration will depend on the IDE. The command to run is: +To debug the external ACP backend, run it from an IDE. The configuration will depend on the IDE. The command to run is: ``` export GOOSE_SERVER__SECRET_KEY=test -cargo run --package goose-server --bin goosed -- agent # or: `just run-server` +cargo run --package goose-cli --bin goose -- serve --platform desktop --host 127.0.0.1 --port 3000 ``` -The server listens on port `3000` by default; this can be changed by setting the -`GOOSE_PORT` environment variable. +The `debug-ui` recipe connects to `http://127.0.0.1:3000` by default. If the +backend uses another port, set `GOOSE_PORT` when starting the UI, or set +`GOOSE_EXTERNAL_BACKEND_URL` to the backend's HTTP base URL. -Once the server is running, start a UI and connect it to the server by running: +Once the backend is running, start a UI and connect it to the backend by running: ``` just debug-ui ``` -The UI connects to the server started in the IDE, allowing breakpoints -and stepping through the server code while interacting with the UI. +The UI connects to the backend started in the IDE, allowing breakpoints +and stepping through the backend code while interacting with the UI. ## Creating a fork diff --git a/Cargo.lock b/Cargo.lock index bce322a502d9..c22c0c45ee60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5133,6 +5133,7 @@ dependencies = [ "url", "utoipa 4.2.3", "uuid", + "wiremock", ] [[package]] diff --git a/Justfile b/Justfile index 56f0fe330279..c08148357f7d 100644 --- a/Justfile +++ b/Justfile @@ -13,15 +13,13 @@ 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 @@ -34,7 +32,7 @@ release-windows: [windows] release-windows: - @powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'rustup target add x86_64-pc-windows-msvc; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; cargo build --release --target x86_64-pc-windows-msvc -p goose-server; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; Write-Host "Windows executable created at ./target/x86_64-pc-windows-msvc/release/goosed.exe"' + @powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'rustup target add x86_64-pc-windows-msvc; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; cargo build --release --target x86_64-pc-windows-msvc -p goose-cli --bin goose; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; Write-Host "Windows executable created at ./target/x86_64-pc-windows-msvc/release/goose.exe"' # Build for Intel Mac release-intel: @@ -43,14 +41,7 @@ release-intel: @just copy-binary-intel copy-binary BUILD_MODE="release": - @if [ -f ./target/{{BUILD_MODE}}/goosed ]; then \ - echo "Copying goosed binary from target/{{BUILD_MODE}}..."; \ - rm -f ./ui/desktop/src/bin/goosed; \ - cp -p ./target/{{BUILD_MODE}}/goosed ./ui/desktop/src/bin/; \ - else \ - echo "Binary not found in target/{{BUILD_MODE}}"; \ - exit 1; \ - fi + @rm -f ./ui/desktop/src/bin/goosed @if [ -f ./target/{{BUILD_MODE}}/goose ]; then \ echo "Copying goose CLI binary from target/{{BUILD_MODE}}..."; \ rm -f ./ui/desktop/src/bin/goose; \ @@ -62,14 +53,7 @@ copy-binary BUILD_MODE="release": # Copy binary command for Intel build copy-binary-intel: - @if [ -f ./target/x86_64-apple-darwin/release/goosed ]; then \ - echo "Copying Intel goosed binary to ui/desktop/src/bin with permissions preserved..."; \ - rm -f ./ui/desktop/src/bin/goosed; \ - cp -p ./target/x86_64-apple-darwin/release/goosed ./ui/desktop/src/bin/; \ - else \ - echo "Intel release binary not found."; \ - exit 1; \ - fi + @rm -f ./ui/desktop/src/bin/goosed @if [ -f ./target/x86_64-apple-darwin/release/goose ]; then \ echo "Copying Intel goose CLI binary to ui/desktop/src/bin..."; \ rm -f ./ui/desktop/src/bin/goose; \ @@ -87,10 +71,11 @@ copy-binary-windows: [windows] copy-binary-windows: - @powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'if (Test-Path ./target/x86_64-pc-windows-msvc/release/goosed.exe) { \ + @powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'if (Test-Path ./target/x86_64-pc-windows-msvc/release/goose.exe) { \ Write-Host "Copying Windows binary to ui/desktop/src/bin..."; \ New-Item -ItemType Directory -Force "./ui/desktop/src/bin" | Out-Null; \ - Copy-Item -Path "./target/x86_64-pc-windows-msvc/release/goosed.exe" -Destination "./ui/desktop/src/bin/" -Force; \ + Remove-Item -Path "./ui/desktop/src/bin/goosed.exe" -Force -ErrorAction SilentlyContinue; \ + Copy-Item -Path "./target/x86_64-pc-windows-msvc/release/goose.exe" -Destination "./ui/desktop/src/bin/" -Force; \ } else { \ Write-Host "Windows binary not found." -ForegroundColor Red; \ exit 1; \ @@ -116,7 +101,7 @@ run-ui-only: cd ui/desktop && pnpm install && pnpm run start-gui debug-ui: - @echo "🚀 Starting goose frontend in external backend mode" + @echo "🚀 Starting goose frontend in external ACP backend mode" cd ui/desktop && \ export GOOSE_EXTERNAL_BACKEND=true && \ export GOOSE_SERVER__SECRET_KEY="${GOOSE_SERVER__SECRET_KEY:-test}" && \ @@ -161,19 +146,13 @@ 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 + @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 # Generate OpenAPI specification without starting the UI generate-openapi: @echo "Generating OpenAPI schema..." cargo run -p goose-server --bin generate_schema - @echo "Generating frontend API..." - cd ui/desktop && npx @hey-api/openapi-ts # Check if generated ACP schema and TypeScript types are up-to-date check-acp-schema: generate-acp-types @@ -404,6 +383,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/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index d25d7c00bc05..cb87c076c6e6 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. - - -## Regression Testing - -Make a copy of this document for each version and check off as steps are verified. - -### Provider Testing - -- [ ] Run `cd ui/desktop && pnpm run test:integration:providers` locally from the release branch and verify all providers/models work -- [ ] Launch goose, click reset providers, choose databricks and a model - -### Starting Conversations - -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 +It will generate an analysis report in `/tmp/release_report_final.md` and perform testing is necessary for high risk pr changes. -### Settings +## Run the goose self-test recipe -- [ ] Settings page loads and all tabs load -- [ ] Can change dark mode setting +goose run --recipe goose-self-test.yaml -### Follow-up Issues +## Have goose produce a test plan -Link any GitHub issues filed during testing: +Open the release candidate desktop app and have goose produce a test plan by pointing it at this PR. Use a prompt like ---- +> 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. -**Tested by:** _____ -**Date:** _____ -**Notes:** _____ +goose will produce a plan. Follow this plan to finish testing. diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index ce581370a79a..20eab63033ed 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -51,6 +51,22 @@ fn generate_serve_secret_key() -> String { ) } +#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)] +enum ServePlatform { + #[default] + Cli, + Desktop, +} + +impl From for GoosePlatform { + fn from(platform: ServePlatform) -> Self { + match platform { + ServePlatform::Cli => GoosePlatform::GooseCli, + ServePlatform::Desktop => GoosePlatform::GooseDesktop, + } + } +} + #[derive(Parser)] #[command(name = "goose", author, version, display_name = "", about, long_about = None)] pub struct Cli { @@ -840,6 +856,9 @@ enum Command { #[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", @@ -849,6 +868,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 @@ -1342,14 +1375,21 @@ async fn handle_mcp_command(server: McpCommand) -> Result<()> { Ok(()) } -async fn handle_serve_command( +struct ServeCommandArgs { host: String, + port: u16, tls: bool, tls_cert_path: Option, tls_key_path: Option, + platform: ServePlatform, builtins: Vec, -) -> Result<()> { + 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; @@ -1357,6 +1397,18 @@ async fn handle_serve_command( use std::sync::Arc; use tracing::{info, warn}; + let ServeCommandArgs { + host, + port, + tls, + tls_cert_path, + tls_key_path, + platform, + builtins, + dangerously_unauthenticated, + allowed_origins, + } = args; + let builtins = if builtins.is_empty() { vec!["developer".to_string()] } else { @@ -1379,7 +1431,7 @@ async fn handle_serve_command( builtins, data_dir: Paths::data_dir(), config_dir: Paths::config_dir(), - goose_platform: GoosePlatform::GooseCli, + goose_platform: platform.into(), additional_source_roots, scheduler: None, })); @@ -1388,13 +1440,35 @@ async fn handle_serve_command( .map(|secret| secret.trim().to_string()) .filter(|secret| !secret.is_empty()); let require_token = env_secret.is_some(); - if !require_token { + 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; the ACP endpoint will accept unauthenticated connections" + "{GOOSE_SERVER_SECRET_KEY_ENV} is not set and --dangerously-unauthenticated was passed; the ACP endpoint will accept unauthenticated connections" ); } + let additional_allowed_origins = allowed_origins + .into_iter() + .map(|origin| { + let origin = origin.trim(); + if origin.is_empty() || origin == "*" { + anyhow::bail!("--allowed-origin must be a non-wildcard Origin value"); + } + HeaderValue::from_str(origin).map_err(|error| { + anyhow::anyhow!("invalid --allowed-origin value `{origin}`: {error}") + }) + }) + .collect::>>()?; let secret_key = env_secret.unwrap_or_else(generate_serve_secret_key); - let router = create_router(server, secret_key, require_token); + let router = create_router( + server, + secret_key, + require_token, + additional_allowed_origins, + ); let config = Config::global(); let tls_cert_path = @@ -2158,8 +2232,24 @@ pub async fn cli() -> anyhow::Result<()> { tls, tls_cert_path, tls_key_path, + platform, builtins, - }) => handle_serve_command(host, port, tls, tls_cert_path, tls_key_path, 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, @@ -2356,6 +2446,35 @@ mod tests { } } + #[test] + fn serve_command_accepts_dangerously_unauthenticated_flag() { + let cli = Cli::try_parse_from([ + "goose", + "serve", + "--dangerously-unauthenticated", + "--allowed-origin", + "app://localhost", + "--allowed-origin", + "https://app.example", + ]) + .expect("parse failed"); + + match cli.command { + Some(Command::Serve { + dangerously_unauthenticated, + allowed_origins, + .. + }) => { + assert!(dangerously_unauthenticated); + assert_eq!( + allowed_origins, + vec!["app://localhost", "https://app.example"] + ); + } + _ => panic!("expected serve command"), + } + } + #[test] fn review_command_accepts_options() { let cli = Cli::try_parse_from([ diff --git a/crates/goose-providers/Cargo.toml b/crates/goose-providers/Cargo.toml index 76f15b746a7e..0ef45601d223 100644 --- a/crates/goose-providers/Cargo.toml +++ b/crates/goose-providers/Cargo.toml @@ -62,6 +62,7 @@ tempfile = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread"] } tokio-stream = { workspace = true } env-lock = { workspace = true } +wiremock.workspace = true [[example]] name = "streaming" diff --git a/crates/goose-providers/examples/declarative.rs b/crates/goose-providers/examples/declarative.rs new file mode 100644 index 000000000000..7d63ff0e1c04 --- /dev/null +++ b/crates/goose-providers/examples/declarative.rs @@ -0,0 +1,33 @@ +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 deepseek = include_str!("deepseek.json"); + let deepseek_model = ModelConfig::new("deepseek-v4-flash"); + let zai = include_str!("zai.json"); + let zai_model = ModelConfig::new("glm-4.5-flash"); + + for (json, model) in [(deepseek, deepseek_model), (zai, zai_model)] { + let provider = goose_providers::declarative::from_json(json, None, EnvKeyResolver {})?; + println!("{}:", provider.get_name()); + complete(provider.as_ref(), model).await?; + } + Ok(()) +} diff --git a/crates/goose-providers/examples/deepseek.json b/crates/goose-providers/examples/deepseek.json new file mode 100644 index 000000000000..1d220744375d --- /dev/null +++ b/crates/goose-providers/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-providers/examples/zai.json b/crates/goose-providers/examples/zai.json new file mode 100644 index 000000000000..afcb16166216 --- /dev/null +++ b/crates/goose-providers/examples/zai.json @@ -0,0 +1,25 @@ +{ + "name": "zai", + "engine": "anthropic", + "display_name": "Z.AI", + "description": "Z.AI GLM models via Anthropic-compatible API.", + "api_key_env": "ZHIPU_API_KEY", + "base_url": "https://api.z.ai/api/anthropic", + "catalog_provider_id": "zai", + "model_doc_link": "https://docs.z.ai/devpack/tool/goose", + "fast_model": "glm-4.5-air", + "preserves_thinking": true, + "models": [ + { "name": "glm-5.1", "context_limit": 200000 }, + { "name": "glm-5", "context_limit": 204800 }, + { "name": "glm-5-turbo", "context_limit": 200000 }, + { "name": "glm-4.7", "context_limit": 204800 }, + { "name": "glm-4.7-flash", "context_limit": 200000 }, + { "name": "glm-4.7-flashx", "context_limit": 200000 }, + { "name": "glm-4.6", "context_limit": 204800 }, + { "name": "glm-4.5", "context_limit": 131072 }, + { "name": "glm-4.5-air", "context_limit": 131072 }, + { "name": "glm-4.5-flash", "context_limit": 131072 } + ], + "supports_streaming": true +} diff --git a/crates/goose-providers/src/anthropic.rs b/crates/goose-providers/src/anthropic.rs index a0c901721c76..33755a6862e8 100644 --- a/crates/goose-providers/src/anthropic.rs +++ b/crates/goose-providers/src/anthropic.rs @@ -1,4 +1,6 @@ +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; @@ -90,6 +92,24 @@ impl AnthropicProviderBuilder { } } + 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 @@ -287,3 +307,96 @@ impl Provider for AnthropicProvider { })) } } + +fn format_options_for_provider(preserves_thinking: bool) -> AnthropicFormatOptions { + AnthropicFormatOptions { + preserve_unsigned_thinking: preserves_thinking, + preserve_thinking_context: preserves_thinking, + thinking_disabled: false, + } +} + +pub fn from_declarative_config( + config: DeclarativeProviderConfig, + tls_config: Option, + key_resolver: impl KeyResolver, +) -> Result { + let custom_models = if !config.models.is_empty() { + Some( + config + .models + .iter() + .map(|m| m.name.clone()) + .collect::>(), + ) + } else { + None + }; + + if config.dynamic_models == Some(false) && custom_models.is_none() { + return Err(anyhow::anyhow!( + "Provider '{}' has dynamic_models: false but no static models listed; \ + at least one entry in `models` is required.", + config.name + )); + } + + let api_key = if config.api_key_env.is_empty() { + None + } else { + match key_resolver.resolve_key(config.api_key_env.as_str()) { + Ok(key) => Some(key), + Err(err) => { + if config.requires_auth { + anyhow::bail!("missing required key {}: {}", config.api_key_env, err); + } + None + } + } + }; + + let auth = match api_key { + Some(key) if !key.is_empty() => AuthMethod::ApiKey { + header_name: "x-api-key".to_string(), + key, + }, + _ => AuthMethod::NoAuth, + }; + + let format_options = format_options_for_provider(config.preserves_thinking); + + let mut api_client = ApiClient::new_with_tls(config.base_url, auth, tls_config)?; + + if let Some(headers) = &config.headers { + let mut header_map = reqwest::header::HeaderMap::new(); + header_map.insert( + reqwest::header::HeaderName::from_static("anthropic-version"), + reqwest::header::HeaderValue::from_static(ANTHROPIC_API_VERSION), + ); + for (key, value) in headers { + let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?; + let header_value = reqwest::header::HeaderValue::from_str(value)?; + header_map.insert(header_name, header_value); + } + api_client = api_client.with_headers(header_map)?; + } else { + api_client = api_client.with_header("anthropic-version", ANTHROPIC_API_VERSION)?; + } + + let supports_streaming = config.supports_streaming.unwrap_or(true); + + if !supports_streaming { + return Err(anyhow::anyhow!( + "Anthropic provider does not support non-streaming mode. All Claude models support streaming. \ + Please remove 'supports_streaming: false' from your provider configuration." + )); + } + + Ok(AnthropicProviderBuilder::new(api_client) + .supports_streaming(supports_streaming) + .name(config.name.clone()) + .custom_models(custom_models) + .dynamic_models(config.dynamic_models) + .skip_canonical_filtering(config.skip_canonical_filtering) + .format_options(format_options)) +} diff --git a/crates/goose-providers/src/declarative.rs b/crates/goose-providers/src/declarative.rs new file mode 100644 index 000000000000..d3c39929c06b --- /dev/null +++ b/crates/goose-providers/src/declarative.rs @@ -0,0 +1,388 @@ +use std::{collections::HashMap, str::FromStr}; + +use anyhow::Result; +use serde::{Deserialize, Deserializer, Serialize}; +use utoipa::ToSchema; + +use crate::{ + anthropic, + api_client::TlsConfig, + base::{ModelInfo, Provider}, + ollama, openai, +}; + +#[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 +} + +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(()) +} + +fn config_from_json(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); + } + + resolve_config(&mut config)?; + Ok(config) +} + +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; + + 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 from_json_defaults_openai_preserves_thinking_to_true() { + let json = json!({ + "name": "test-provider", + "engine": "openai", + "display_name": "Test Provider", + "base_url": "http://localhost:1234/v1/chat/completions", + "models": [model_json()], + "requires_auth": false, + "dynamic_models": false + }) + .to_string(); + + let config = config_from_json(&json).unwrap(); + + assert!(config.preserves_thinking); + } + + #[test] + fn from_json_preserves_explicit_openai_preserves_thinking_false() { + let json = json!({ + "name": "test-provider", + "engine": "openai", + "display_name": "Test Provider", + "base_url": "http://localhost:1234/v1/chat/completions", + "models": [model_json()], + "requires_auth": false, + "dynamic_models": false, + "preserves_thinking": false + }) + .to_string(); + + let config = config_from_json(&json).unwrap(); + + assert!(!config.preserves_thinking); + } + + #[test] + fn from_json_expands_base_url_from_env_var_default() { + let _guard = env_lock::lock_env([("TEST_PROVIDER_HOST", None::<&str>)]); + let json = json!({ + "name": "test-provider", + "engine": "openai", + "display_name": "Test Provider", + "base_url": "${TEST_PROVIDER_HOST}/v1/chat/completions", + "models": [model_json()], + "requires_auth": false, + "dynamic_models": false, + "env_vars": [{ + "name": "TEST_PROVIDER_HOST", + "default": "http://localhost:1234" + }] + }) + .to_string(); + + let provider = from_json(&json, None, EnvKeyResolver).unwrap(); + + assert_eq!(provider.get_name(), "test-provider"); + } + + #[tokio::test] + async fn from_json_ollama_returns_static_models_when_dynamic_models_false() { + let json = json!({ + "name": "test-ollama", + "engine": "ollama", + "display_name": "Test Ollama", + "base_url": "http://localhost:11434", + "models": [model_json()], + "requires_auth": false, + "dynamic_models": false + }) + .to_string(); + + let provider = from_json(&json, None, EnvKeyResolver).unwrap(); + + assert_eq!( + provider.fetch_supported_models().await.unwrap(), + vec!["test-model".to_string()] + ); + } + + #[test] + fn from_json_errors_when_required_env_var_is_missing() { + let _guard = env_lock::lock_env([("TEST_PROVIDER_REQUIRED_HOST", None::<&str>)]); + let json = json!({ + "name": "test-provider", + "engine": "openai", + "display_name": "Test Provider", + "base_url": "${TEST_PROVIDER_REQUIRED_HOST}/v1/chat/completions", + "models": [model_json()], + "requires_auth": false, + "dynamic_models": false, + "env_vars": [{ + "name": "TEST_PROVIDER_REQUIRED_HOST", + "required": true + }] + }) + .to_string(); + + let err = match from_json(&json, None, EnvKeyResolver) { + Ok(_) => panic!("expected missing required env var error"), + Err(err) => err, + }; + + assert!(err + .to_string() + .contains("Required environment variable TEST_PROVIDER_REQUIRED_HOST is not set")); + } +} diff --git a/crates/goose-providers/src/formats/openai.rs b/crates/goose-providers/src/formats/openai.rs index 0dd54b7077a5..496d3da54b16 100644 --- a/crates/goose-providers/src/formats/openai.rs +++ b/crates/goose-providers/src/formats/openai.rs @@ -239,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)); diff --git a/crates/goose-providers/src/formats/openai_responses.rs b/crates/goose-providers/src/formats/openai_responses.rs index d6daef785340..05aed70ff18f 100644 --- a/crates/goose-providers/src/formats/openai_responses.rs +++ b/crates/goose-providers/src/formats/openai_responses.rs @@ -6,7 +6,7 @@ use crate::formats::openai::{ }; use crate::mcp_utils::extract_text_from_resource; use crate::model::ModelConfig; -use anyhow::Error; +use anyhow::{anyhow, Error}; use async_stream::try_stream; use chrono; use futures::Stream; @@ -317,19 +317,24 @@ pub struct ResponseMetadata { #[serde(rename_all = "snake_case")] pub enum ResponseOutputItemInfo { Reasoning { - id: String, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, #[serde(default)] summary: Vec, }, Message { - id: String, - status: String, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, role: String, content: Vec, }, FunctionCall { - id: String, - status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + status: Option, #[serde(skip_serializing_if = "Option::is_none")] call_id: Option, name: String, @@ -691,7 +696,9 @@ pub fn responses_api_to_message(response: &ResponsesApiResponse) -> anyhow::Resu arguments, .. } => { - let request_id = call_id.clone().or_else(|| id.clone()).unwrap_or_default(); + let request_id = call_id.clone().or_else(|| id.clone()).ok_or_else(|| { + anyhow!("Responses function_call output missing call_id and id") + })?; let parsed_args = if arguments.is_empty() { json!({}) } else { @@ -724,7 +731,7 @@ pub fn get_responses_usage(response: &ResponsesApiResponse) -> Usage { fn process_streaming_output_items( output_items: Vec, is_text_response: bool, -) -> Vec { +) -> anyhow::Result> { let mut content = Vec::new(); for item in output_items { @@ -772,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 { @@ -787,7 +796,7 @@ fn process_streaming_output_items( } } - content + Ok(content) } pub fn responses_api_to_streaming_message( @@ -945,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); @@ -1053,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!({ @@ -1789,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", @@ -1799,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] @@ -1822,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] diff --git a/crates/goose-providers/src/images.rs b/crates/goose-providers/src/images.rs index 1f194862fcc3..5a1a11b8460e 100644 --- a/crates/goose-providers/src/images.rs +++ b/crates/goose-providers/src/images.rs @@ -1,4 +1,4 @@ -use std::{io::Read as _, path::Path}; +use std::{borrow::Cow, io::Read as _, path::Path}; use base64::Engine as _; use rmcp::model::{AnnotateAble as _, ImageContent, RawImageContent}; @@ -33,11 +33,11 @@ pub fn convert_image(image: &ImageContent, image_format: &ImageFormat) -> Value } } -pub fn detect_image_path(text: &str) -> Option<&str> { +pub fn detect_image_path(text: &str) -> Option> { const EXTENSIONS: [&str; 3] = [".png", ".jpg", ".jpeg"]; const MAX_PATH_LEN: usize = 4096; - let mut best: Option<(usize, &str)> = None; + let mut best: Option<(usize, Cow<'_, str>)> = None; let mut from = 0; while from < text.len() { let Some(end) = EXTENSIONS @@ -70,17 +70,16 @@ pub fn detect_image_path(text: &str) -> Option<&str> { let Some(candidate) = text.get(start..end) else { continue; }; - let path = Path::new(candidate); - if path.is_absolute() && path.is_file() && is_image_file(path) { + if let Some(candidate_path) = image_path_candidate(candidate) { // Keep the first referenced path, but allow a longer // match anchored at the same start to extend it (a // whitespace-terminated extension may be a prefix of a // spaced filename ending in a later extension). match best { Some((best_start, _)) if start == best_start => { - best = Some((start, candidate)); + best = Some((start, candidate_path)); } - None => best = Some((start, candidate)), + None => best = Some((start, candidate_path)), Some(_) => {} } break; @@ -93,6 +92,54 @@ pub fn detect_image_path(text: &str) -> Option<&str> { best.map(|(_, candidate)| candidate) } +fn clean_path(path: &str) -> Cow<'_, str> { + if !path.contains('\\') { + return Cow::Borrowed(path); + } + + let mut cleaned = String::with_capacity(path.len()); + let mut chars = path.chars().peekable(); + let mut changed = false; + + while let Some(c) = chars.next() { + if c == '\\' { + if let Some(&next) = chars.peek() { + if !next.is_alphanumeric() { + cleaned.push(next); + chars.next(); + changed = true; + continue; + } + } + } + cleaned.push(c); + } + + if changed { + Cow::Owned(cleaned) + } else { + Cow::Borrowed(path) + } +} + +fn image_path_candidate(candidate: &str) -> Option> { + if is_existing_image_path(candidate) { + return Some(Cow::Borrowed(candidate)); + } + + let cleaned = clean_path(candidate); + if cleaned.as_ref() != candidate && is_existing_image_path(cleaned.as_ref()) { + return Some(cleaned); + } + + None +} + +fn is_existing_image_path(candidate: &str) -> bool { + let path = Path::new(candidate); + path.is_absolute() && path.is_file() && is_image_file(path) +} + /// Case-insensitive ASCII substring search returning a byte index into /// `haystack` (no allocation, so the index stays valid for slicing). fn find_ascii_ci(haystack: &str, needle: &str, from: usize) -> Option { @@ -196,23 +243,23 @@ mod tests { // Test with valid PNG file using absolute path let text = format!("Here is an image {}", png_path_str); - assert_eq!(detect_image_path(&text), Some(png_path_str)); + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); // Test with non-image file that has .png extension let text = format!("Here is a fake image {}", fake_png_path.to_str().unwrap()); - assert_eq!(detect_image_path(&text), None); + assert_eq!(detect_image_path(&text).as_deref(), None); // Test with nonexistent file let text = "Here is a fake.png that doesn't exist"; - assert_eq!(detect_image_path(text), None); + assert_eq!(detect_image_path(text).as_deref(), None); // Test with non-image file let text = "Here is a file.txt"; - assert_eq!(detect_image_path(text), None); + assert_eq!(detect_image_path(text).as_deref(), None); // Test with relative path (should not match) let text = "Here is a relative/path/image.png"; - assert_eq!(detect_image_path(text), None); + assert_eq!(detect_image_path(text).as_deref(), None); } #[test] @@ -225,25 +272,25 @@ mod tests { let png_path_str = png_path.to_str().unwrap(); let text = format!("please describe {} for me", png_path_str); - assert_eq!(detect_image_path(&text), Some(png_path_str)); + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); // Case-insensitive extension also matches. let upper = temp_dir.path().join("Another Shot.PNG"); std::fs::write(&upper, png_data).unwrap(); let upper_str = upper.to_str().unwrap(); let text = format!("see {}", upper_str); - assert_eq!(detect_image_path(&text), Some(upper_str)); + assert_eq!(detect_image_path(&text).as_deref(), Some(upper_str)); // Quoted path with spaces: the closing quote terminates the candidate. let text = format!("describe \"{}\" please", png_path_str); - assert_eq!(detect_image_path(&text), Some(png_path_str)); + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); let text = format!("describe '{}'", png_path_str); - assert_eq!(detect_image_path(&text), Some(png_path_str)); + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); // A stray closing quote in prose must not act as a terminator for an // unquoted path. let text = format!("here {}\" trailing", png_path_str); - assert_eq!(detect_image_path(&text), Some(png_path_str)); + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); // When a spaced filename contains an earlier image extension, prefer // the longer existing candidate over the embedded prefix. @@ -253,7 +300,7 @@ mod tests { let prefix = temp_dir.path().join("Screen Shot.png"); std::fs::write(&prefix, png_data).unwrap(); let text = format!("look at {}", edited_str); - assert_eq!(detect_image_path(&text), Some(edited_str)); + assert_eq!(detect_image_path(&text).as_deref(), Some(edited_str)); // With multiple distinct images, the first referenced one wins even if // a later one has a longer path. @@ -266,7 +313,46 @@ mod tests { a.to_str().unwrap(), longer.to_str().unwrap() ); - assert_eq!(detect_image_path(&text), Some(a.to_str().unwrap())); + assert_eq!( + detect_image_path(&text).as_deref(), + Some(a.to_str().unwrap()) + ); + } + + #[test] + fn test_detect_image_path_with_shell_escaped_metacharacters() { + let temp_dir = tempfile::tempdir().unwrap(); + let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + let png_path = temp_dir + .path() + .join("Bob's Project (v2) & $draft [final].png"); + std::fs::write(&png_path, png_data).unwrap(); + let png_path_str = png_path.to_str().unwrap(); + + let escaped_path = png_path_str + .replace(' ', "\\ ") + .replace('(', "\\(") + .replace(')', "\\)") + .replace('&', "\\&") + .replace('$', "\\$") + .replace('\'', "\\'") + .replace('[', "\\[") + .replace(']', "\\]"); + let text = format!("please describe {}", escaped_path); + + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); + } + + #[test] + fn test_detect_image_path_prefers_existing_literal_backslash_path() { + let temp_dir = tempfile::tempdir().unwrap(); + let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + let png_path = temp_dir.path().join("literal\\&name.png"); + std::fs::write(&png_path, png_data).unwrap(); + let png_path_str = png_path.to_str().unwrap(); + let text = format!("please describe {}", png_path_str); + + assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str)); } #[test] @@ -280,14 +366,14 @@ mod tests { let png_path = temp_dir.path().join("photo.png"); std::fs::write(&png_path, png_data).unwrap(); let url = format!("https:/{}/photo.png", dir); - assert_eq!(detect_image_path(&url), None); + assert_eq!(detect_image_path(&url).as_deref(), None); // A backup file sharing the image extension prefix must not be // truncated to the bare image path. let real = temp_dir.path().join("shot.png"); std::fs::write(&real, png_data).unwrap(); let backup = format!("{}.backup", real.to_str().unwrap()); - assert_eq!(detect_image_path(&backup), None); + assert_eq!(detect_image_path(&backup).as_deref(), None); } #[test] @@ -295,7 +381,7 @@ mod tests { // Many extension-like tokens but no real absolute path: must scan // cheaply (bounded) and find nothing. let text = "see foo.png and bar.jpg and baz.jpeg ".repeat(500); - assert_eq!(detect_image_path(&text), None); + assert_eq!(detect_image_path(&text).as_deref(), None); } #[test] diff --git a/crates/goose-providers/src/lib.rs b/crates/goose-providers/src/lib.rs index 99944f92d906..f84486bdc0f8 100644 --- a/crates/goose-providers/src/lib.rs +++ b/crates/goose-providers/src/lib.rs @@ -3,6 +3,7 @@ pub mod api_client; pub mod base; pub mod canonical; pub mod conversation; +pub mod declarative; pub mod errors; pub mod formats; pub mod goose_mode; diff --git a/crates/goose-providers/src/ollama.rs b/crates/goose-providers/src/ollama.rs index 7db1fb374200..9d7fb35cf1d9 100644 --- a/crates/goose-providers/src/ollama.rs +++ b/crates/goose-providers/src/ollama.rs @@ -2,8 +2,10 @@ 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; @@ -13,7 +15,7 @@ use anyhow::{Error, Result}; use async_stream::try_stream; use async_trait::async_trait; use futures::TryStreamExt; -use reqwest::Response; +use reqwest::{Response, StatusCode}; use rmcp::model::Tool; use serde_json::{json, Value}; use std::time::Duration; @@ -21,6 +23,7 @@ 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"; @@ -79,10 +82,88 @@ 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, @@ -90,12 +171,58 @@ impl OllamaProvider { skip_canonical_filtering: bool, options: OllamaOptions, ) -> Self { - Self { - api_client, - name, - skip_canonical_filtering, - options, + 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> { + let response = self + .api_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_response = response.json::().await.map_err(|e| { + ProviderError::RequestFailed(format!("Failed to parse response: {}", e)) + })?; + + let models = json_response + .get("models") + .and_then(|m| m.as_array()) + .ok_or_else(|| { + ProviderError::RequestFailed("No models array in response".to_string()) + })?; + + let mut model_names: Vec = models + .iter() + .filter_map(|model| model.get("name").and_then(|n| n.as_str()).map(String::from)) + .collect(); + + model_names.sort(); + + Ok(model_names) } } @@ -136,6 +263,100 @@ fn apply_ollama_options(payload: &mut Value, options: &OllamaOptions, model_conf } } +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( @@ -213,39 +434,26 @@ impl Provider for OllamaProvider { } async fn fetch_supported_models(&self) -> Result, ProviderError> { - let response = self - .api_client - .request("api/tags") - .response_get() - .await - .map_err(|e| ProviderError::RequestFailed(format!("Failed to fetch models: {}", e)))?; + if let Some(custom_models) = &self.custom_models { + if self.dynamic_models == Some(false) { + return Ok(custom_models.clone()); + } - if !response.status().is_success() { - return Err(ProviderError::RequestFailed(format!( - "Failed to fetch models: HTTP {}", - response.status() - ))); + 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), + } } - let json_response = response.json::().await.map_err(|e| { - ProviderError::RequestFailed(format!("Failed to parse response: {}", e)) - })?; - - let models = json_response - .get("models") - .and_then(|m| m.as_array()) - .ok_or_else(|| { - ProviderError::RequestFailed("No models array in response".to_string()) - })?; - - let mut model_names: Vec = models - .iter() - .filter_map(|model| model.get("name").and_then(|n| n.as_str()).map(String::from)) - .collect(); - - model_names.sort(); - - Ok(model_names) + self.fetch_models_from_api().await } } @@ -323,6 +531,105 @@ fn stream_ollama( #[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() { diff --git a/crates/goose-providers/src/openai.rs b/crates/goose-providers/src/openai.rs index e44acbb60a04..a5e6e8630fd3 100644 --- a/crates/goose-providers/src/openai.rs +++ b/crates/goose-providers/src/openai.rs @@ -1,8 +1,10 @@ use super::api_client::ApiClient; use super::base::{ConfigKey, ModelInfo, Provider, ProviderMetadata}; use super::retry::ProviderRetry; +use crate::api_client::{AuthMethod, TlsConfig}; use crate::conversation::message::Message; use crate::conversation::token_usage::ProviderUsage; +use crate::declarative::{DeclarativeProviderConfig, KeyResolver}; use crate::errors::ProviderError; use crate::formats::openai::is_openai_responses_model; use crate::formats::openai::{ @@ -61,6 +63,7 @@ pub const OPEN_AI_KNOWN_MODELS: &[(&str, usize)] = &[ ]; 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); @@ -178,6 +181,19 @@ impl OpenAiProviderBuilder { self } + pub fn map_api_client(mut self, f: impl FnOnce(ApiClient) -> ApiClient) -> Self { + self.api_client = f(self.api_client); + self + } + + pub fn try_map_api_client( + mut self, + f: impl FnOnce(ApiClient) -> Result, + ) -> Result { + self.api_client = f(self.api_client)?; + Ok(self) + } + pub fn base_path(mut self, base_path: impl Into) -> Self { self.base_path = base_path.into(); self @@ -687,6 +703,97 @@ impl Provider for OpenAiProvider { } } +pub fn from_declarative_config( + config: DeclarativeProviderConfig, + tls_config: Option, + key_resolver: impl KeyResolver, +) -> Result { + let custom_models = if !config.models.is_empty() { + Some( + config + .models + .iter() + .map(|m| m.name.clone()) + .collect::>(), + ) + } else { + None + }; + + if config.dynamic_models == Some(false) && custom_models.is_none() { + return Err(anyhow::anyhow!( + "Provider '{}' has dynamic_models: false but no static models listed; \ + at least one entry in `models` is required.", + config.name + )); + } + + let api_key = if config.api_key_env.is_empty() { + None + } else { + match key_resolver.resolve_key(config.api_key_env.as_str()) { + Ok(key) => Some(key), + Err(err) => { + if config.requires_auth { + anyhow::bail!("missing required key {}: {}", config.api_key_env, err); + } + None + } + } + }; + + let normalized_base_url = ensure_url_scheme(&config.base_url); + let url = url::Url::parse(&normalized_base_url) + .map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?; + + let host = url[..url::Position::BeforePath].to_string(); + let base_path = if let Some(ref explicit_path) = config.base_path { + explicit_path.trim_start_matches('/').to_string() + } else { + derive_base_path(url.path()) + }; + + let timeout_secs = config.timeout_seconds.unwrap_or(DEFAULT_TIMEOUT_SECONDS); + + let auth = match api_key { + Some(key) if !key.is_empty() => AuthMethod::BearerToken(key), + _ => AuthMethod::NoAuth, + }; + let mut api_client = ApiClient::with_timeout_and_tls( + host, + auth, + std::time::Duration::from_secs(timeout_secs), + tls_config, + )?; + + if let Some(query) = url.query() { + let query_params = url::form_urlencoded::parse(query.as_bytes()) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect(); + api_client = api_client.with_query(query_params); + } + + if let Some(headers) = &config.headers { + let mut header_map = reqwest::header::HeaderMap::new(); + for (key, value) in headers { + let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?; + let header_value = reqwest::header::HeaderValue::from_str(value)?; + header_map.insert(header_name, header_value); + } + api_client = api_client.with_headers(header_map)?; + } + + Ok(OpenAiProviderBuilder::new(api_client) + .base_path(base_path) + .custom_headers(config.headers) + .supports_streaming(config.supports_streaming.unwrap_or(true)) + .name(config.name.clone()) + .custom_models(custom_models) + .dynamic_models(config.dynamic_models) + .skip_canonical_filtering(config.skip_canonical_filtering) + .preserve_thinking_context(config.preserves_thinking)) +} + pub fn parse_custom_headers(s: String) -> HashMap { s.split(',') .filter_map(|header| { @@ -698,6 +805,26 @@ pub fn parse_custom_headers(s: String) -> HashMap { .collect() } +pub fn derive_base_path(url_path: &str) -> String { + let stripped = url_path.trim_start_matches('/'); + let normalized = stripped.trim_end_matches('/'); + if normalized.is_empty() { + "v1/chat/completions".to_string() + } else if normalized.ends_with("chat/completions") { + stripped.to_string() + } else if ends_with_version_segment(normalized) { + format!("{}/chat/completions", normalized) + } else { + format!("{}/v1/chat/completions", normalized) + } +} + +fn ends_with_version_segment(path: &str) -> bool { + let last = path.rsplit('/').next().unwrap_or(path); + last.strip_prefix('v') + .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())) +} + #[cfg(test)] mod tests { use super::*; @@ -950,6 +1077,60 @@ mod tests { ); } + fn custom_config(base_url: &str) -> DeclarativeProviderConfig { + DeclarativeProviderConfig { + name: "test-openai".to_string(), + engine: crate::declarative::ProviderEngine::OpenAI, + display_name: "Test OpenAI".to_string(), + description: None, + api_key_env: String::new(), + base_url: base_url.to_string(), + models: vec![crate::base::ModelInfo::new("test-model", 4096)], + headers: None, + timeout_seconds: None, + supports_streaming: None, + requires_auth: false, + catalog_provider_id: None, + base_path: None, + env_vars: None, + dynamic_models: Some(false), + skip_canonical_filtering: false, + model_doc_link: None, + setup_steps: vec![], + fast_model: None, + preserves_thinking: false, + } + } + + #[test] + fn from_custom_config_preserves_ipv6_authority() { + let provider = from_declarative_config( + custom_config("http://[::1]:1234/v1"), + None, + crate::declarative::EnvKeyResolver, + ) + .unwrap() + .build(); + + assert_eq!(provider.api_client.host(), "http://[::1]:1234"); + } + + #[test] + fn from_custom_config_preserves_userinfo_authority() { + let provider = from_declarative_config( + custom_config("https://user:pass@gateway.example/v1"), + None, + crate::declarative::EnvKeyResolver, + ) + .unwrap() + .build(); + + assert_eq!( + provider.api_client.host(), + "https://user:pass@gateway.example" + ); + } + #[test] fn parse_n_ctx_falls_back_to_sole_entry_when_id_differs() { let body = json!({ @@ -970,4 +1151,37 @@ mod tests { }); assert_eq!(parse_n_ctx_from_models(&body, "model-c"), None); } + + #[test] + fn derive_base_path_not_removing_api_path() { + let r = derive_base_path("https://opencode.ai/zen/go"); + assert_eq!(r, "https://opencode.ai/zen/go/v1/chat/completions"); + } + + #[test] + fn derive_base_path_should_support_v1() { + let r = derive_base_path("https://opencode.ai/zen/go/v1"); + assert_eq!(r, "https://opencode.ai/zen/go/v1/chat/completions"); + } + + #[test] + fn derive_base_path_should_support_no_base_path() { + let r = derive_base_path("https://opencode.ai/"); + assert_eq!(r, "https://opencode.ai/v1/chat/completions"); + } + + #[test] + fn derive_base_path_preserves_non_v1_version_prefix() { + // Zhipu's default base_url is https://open.bigmodel.cn/api/paas/v4 and + // from_custom_config passes url.path() ("/api/paas/v4") here. The + // existing /api/paas/v4 version must not gain an extra /v1 segment. + let r = derive_base_path("/api/paas/v4"); + assert_eq!(r, "api/paas/v4/chat/completions"); + } + + #[test] + fn derive_base_path_does_not_treat_v_word_as_version() { + let r = derive_base_path("/api/voice"); + assert_eq!(r, "api/voice/v1/chat/completions"); + } } diff --git a/crates/goose-server/src/commands/agent.rs b/crates/goose-server/src/commands/agent.rs index 0845fef61c49..e5c597fb39d6 100644 --- a/crates/goose-server/src/commands/agent.rs +++ b/crates/goose-server/src/commands/agent.rs @@ -4,10 +4,10 @@ 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::acp::transport::create_authenticated_acp_router; use goose::agents::GoosePlatform; use goose::config::paths::Paths; -use goose_server::auth::{check_acp_token, check_token}; +use goose_server::auth::check_token; use std::sync::Arc; use tower_http::cors::{Any, CorsLayer}; use tracing::info; @@ -72,15 +72,15 @@ pub async fn run() -> Result<()> { scheduler: Some(app_state.scheduler()), })); - let rest_router = crate::routes::configure(app_state.clone(), secret_key.clone()).layer( - middleware::from_fn_with_state(secret_key.clone(), check_token), - ); - let acp_router = create_acp_router(acp_server).layer(middleware::from_fn_with_state( - secret_key.clone(), - check_acp_token, - )); + let rest_router = crate::routes::configure(app_state.clone(), secret_key.clone()) + .layer(middleware::from_fn_with_state( + secret_key.clone(), + check_token, + )) + .layer(cors); + let acp_router = create_authenticated_acp_router(acp_server, secret_key.clone()); - let app = rest_router.merge(acp_router).layer(cors); + let app = rest_router.merge(acp_router); let addr = settings.socket_addr(); diff --git a/crates/goose/src/acp/transport/mod.rs b/crates/goose/src/acp/transport/mod.rs index 39d5d6eb9712..6c2f1d980b0c 100644 --- a/crates/goose/src/acp/transport/mod.rs +++ b/crates/goose/src/acp/transport/mod.rs @@ -6,26 +6,161 @@ use std::sync::Arc; use agent_client_protocol_http::{AcpHttpServer, CorsOptions, ServerOptions}; use axum::{ - http::{header, HeaderName, Method}, + extract::{Request, State}, + http::{header, HeaderName, HeaderValue, Method, StatusCode}, + middleware::Next, + response::Response, routing::get, Router, }; -use tower_http::cors::{Any, CorsLayer}; +use tower_http::cors::{AllowOrigin, Any, CorsLayer}; use crate::acp::server::GooseAgentConnection; use crate::acp::server_factory::AcpServer; +// The upstream ACP HTTP server only supports exact origin allowlists for +// WebSocket upgrades; Goose applies its richer loopback predicate before this. +const UPSTREAM_WS_ALLOWED_ORIGIN: &str = "http://goose.local"; +const DESKTOP_FILE_ORIGIN: &str = "null"; + +#[derive(Clone)] +struct AcpOriginPolicy { + exact_origins: Arc<[HeaderValue]>, + allow_loopback: bool, +} + +impl AcpOriginPolicy { + fn loopback() -> Self { + Self { + exact_origins: Vec::new().into(), + allow_loopback: true, + } + } + + fn exact(origins: Vec) -> Self { + Self { + exact_origins: origins.into(), + allow_loopback: false, + } + } + + fn loopback_and(origins: Vec) -> Self { + Self { + exact_origins: origins.into(), + allow_loopback: true, + } + } + + fn origin_allowed(&self, origin: &HeaderValue) -> bool { + if self + .exact_origins + .iter() + .any(|allowed_origin| allowed_origin == origin) + { + return true; + } + + if !self.allow_loopback { + return false; + } + + let Ok(origin) = origin.to_str() else { + return false; + }; + + let Ok(url) = url::Url::parse(origin) else { + return false; + }; + + if !matches!(url.scheme(), "http" | "https") { + return false; + } + + match url.host() { + Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(url::Host::Ipv4(addr)) => addr.is_loopback(), + Some(url::Host::Ipv6(addr)) => addr.is_loopback(), + None => false, + } + } +} + fn acp_http_options() -> ServerOptions { ServerOptions { path: "/acp".to_string(), - cors: CorsOptions::allow_any_origin(), + cors: CorsOptions::allow_origins([UPSTREAM_WS_ALLOWED_ORIGIN]) + .expect("static origin is valid"), health_endpoint: false, } } +fn header_contains_token(value: Option<&HeaderValue>, token: &str) -> bool { + value + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .any(|part| part.trim().eq_ignore_ascii_case(token)) + }) +} + +fn is_websocket_upgrade(request: &Request) -> bool { + request.method() == Method::GET + && header_contains_token(request.headers().get(header::CONNECTION), "upgrade") + && request + .headers() + .get(header::UPGRADE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.eq_ignore_ascii_case("websocket")) +} + +async fn enforce_websocket_origin( + State(policy): State, + mut request: Request, + next: Next, +) -> Result { + if is_websocket_upgrade(&request) { + if let Some(origin) = request.headers().get(header::ORIGIN) { + if !policy.origin_allowed(origin) { + return Err(StatusCode::FORBIDDEN); + } + } + + request.headers_mut().insert( + header::ORIGIN, + HeaderValue::from_static(UPSTREAM_WS_ALLOWED_ORIGIN), + ); + } + + Ok(next.run(request).await) +} + +fn acp_cors_layer(policy: AcpOriginPolicy) -> CorsLayer { + CorsLayer::new() + .allow_origin(AllowOrigin::predicate(move |origin, _request_parts| { + policy.origin_allowed(origin) + })) + .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS]) + .allow_headers([ + header::CONTENT_TYPE, + header::ACCEPT, + HeaderName::from_static("x-secret-key"), + HeaderName::from_static("acp-connection-id"), + HeaderName::from_static("acp-session-id"), + header::SEC_WEBSOCKET_VERSION, + header::SEC_WEBSOCKET_KEY, + header::CONNECTION, + header::UPGRADE, + ]) + .expose_headers([ + HeaderName::from_static("acp-connection-id"), + HeaderName::from_static("acp-session-id"), + ]) +} + /// CORS for the auxiliary routes (`/health`, `/status`, MCP app proxy) served by -/// `goose serve`. The ACP routes get their CORS from `AcpHttpServer`; this also -/// allows the `x-secret-key` auth header the proxy routes rely on. +/// `goose serve`. This allows the `x-secret-key` auth header the proxy routes +/// rely on. fn aux_cors_layer() -> CorsLayer { CorsLayer::new() .allow_origin(Any) @@ -37,12 +172,43 @@ fn aux_cors_layer() -> CorsLayer { ]) } -/// The bare ACP HTTP/WebSocket router (POST/GET/DELETE on `/acp`), without auth -/// or goose-specific auxiliary routes. -pub fn create_acp_router(server: Arc) -> Router { +fn create_acp_router_inner(server: Arc, policy: AcpOriginPolicy) -> Router { AcpHttpServer::new(move || GooseAgentConnection::new(server.clone())) .with_options(acp_http_options()) .into_router() + .layer(axum::middleware::from_fn_with_state( + policy, + enforce_websocket_origin, + )) +} + +fn create_acp_router_with_policy( + server: Arc, + policy: AcpOriginPolicy, + secret_key: Option, +) -> Router { + let mut acp_routes = create_acp_router_inner(server, policy.clone()); + if let Some(secret_key) = secret_key { + acp_routes = acp_routes.layer(axum::middleware::from_fn_with_state( + secret_key, + auth::check_acp_token, + )); + } + acp_routes.layer(acp_cors_layer(policy)) +} + +/// The bare ACP HTTP/WebSocket router (POST/GET/DELETE on `/acp`), without auth +/// or goose-specific auxiliary routes. +pub fn create_acp_router(server: Arc) -> Router { + create_acp_router_with_policy(server, AcpOriginPolicy::loopback(), None) +} + +pub fn create_authenticated_acp_router(server: Arc, secret_key: String) -> Router { + create_acp_router_with_policy( + server, + AcpOriginPolicy::loopback_and(vec![HeaderValue::from_static(DESKTOP_FILE_ORIGIN)]), + Some(secret_key), + ) } async fn health() -> &'static str { @@ -51,14 +217,19 @@ async fn health() -> &'static str { /// The full standalone ACP server router used by `goose serve`: ACP transport, /// optional token auth, health/status endpoints, and the MCP app proxy. -pub fn create_router(server: Arc, secret_key: String, require_token: bool) -> Router { - let mut acp_routes = create_acp_router(server); - if require_token { - acp_routes = acp_routes.layer(axum::middleware::from_fn_with_state( - secret_key.clone(), - auth::check_acp_token, - )); - } +pub fn create_router( + server: Arc, + secret_key: String, + require_token: bool, + additional_allowed_origins: Vec, +) -> Router { + let policy = if additional_allowed_origins.is_empty() { + AcpOriginPolicy::loopback() + } else { + AcpOriginPolicy::exact(additional_allowed_origins) + }; + let acp_routes = + create_acp_router_with_policy(server, policy, require_token.then_some(secret_key.clone())); let aux_routes = Router::new() .route("/health", get(health)) diff --git a/crates/goose/src/config/declarative_providers.rs b/crates/goose/src/config/declarative_providers.rs index 7eabb0cdbcc9..64cf0bd5eac1 100644 --- a/crates/goose/src/config/declarative_providers.rs +++ b/crates/goose/src/config/declarative_providers.rs @@ -10,126 +10,26 @@ use crate::providers::openai_def::OpenAiProviderDef; use anyhow::Result; use include_dir::{include_dir, Dir}; use once_cell::sync::Lazy; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Serialize}; use std::str::FromStr; -/// 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())) -} use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Mutex; use utoipa::ToSchema; +pub use goose_providers::declarative::*; + static FIXED_PROVIDERS: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/providers/declarative"); pub fn custom_providers_dir() -> std::path::PathBuf { Paths::config_dir().join("custom_providers") } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum ProviderEngine { - OpenAI, - Ollama, - 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 EnvVarConfig { - pub name: String, - #[serde(default)] - pub required: bool, - #[serde(default)] - pub secret: bool, - /// When true, the field is shown prominently in the UI (not collapsed). - /// Defaults to the value of `required` if not specified. - pub primary: Option, - pub description: Option, - pub default: Option, -} - -#[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 -} - fn should_preserve_thinking_by_default(engine: &ProviderEngine) -> bool { matches!(engine, ProviderEngine::OpenAI) } -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 - } -} - /// Expand `${VAR_NAME}` placeholders in a template string using the given env var configs. /// Resolves values via Config (secret if `secret`, param otherwise), falls back to `default`. /// Returns an error if a `required` var is missing. @@ -464,6 +364,7 @@ pub fn load_provider(id: &str) -> Result { Err(anyhow::anyhow!("Provider not found: {}", id)) } + pub fn load_custom_providers(dir: &Path) -> Result> { if !dir.exists() { return Ok(Vec::new()); diff --git a/crates/goose/src/providers/anthropic_def.rs b/crates/goose/src/providers/anthropic_def.rs index b0e695365cfe..cadbb9a85f6f 100644 --- a/crates/goose/src/providers/anthropic_def.rs +++ b/crates/goose/src/providers/anthropic_def.rs @@ -1,12 +1,14 @@ use anyhow::Result; use futures::future::BoxFuture; -use crate::{config::DeclarativeProviderConfig, providers::base::ProviderDef}; +use crate::{ + config::{Config, DeclarativeProviderConfig}, + providers::{base::ProviderDef, custom_provider_config::ConfigKeyResolver}, +}; use goose_providers::{ - anthropic::{AnthropicProvider, AnthropicProviderBuilder, ANTHROPIC_API_VERSION}, - api_client::{ApiClient, AuthMethod}, + anthropic::{self, AnthropicProvider, AnthropicProviderBuilder, ANTHROPIC_API_VERSION}, + api_client::{ApiClient, AuthMethod, TlsConfig}, base::ProviderDescriptor, - formats::anthropic::AnthropicFormatOptions, }; pub struct AnthropicProviderDef; @@ -51,79 +53,17 @@ async fn from_env( pub fn from_custom_config( config: DeclarativeProviderConfig, - tls_config: Option, + tls_config: Option, ) -> 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 global_config = crate::config::Config::global(); - let api_key: String = global_config - .get_secret(&config.api_key_env) - .map_err(|_| anyhow::anyhow!("Missing API key: {}", config.api_key_env))?; - - let auth = AuthMethod::ApiKey { - header_name: "x-api-key".to_string(), - key: api_key, - }; - - let format_options = format_options_for_provider(config.preserves_thinking); - - let mut api_client = ApiClient::new_with_tls(config.base_url, auth, tls_config)? - .with_request_builder(crate::session_context::session_id_request_builder()) - .with_header("anthropic-version", ANTHROPIC_API_VERSION)?; - - 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!( - "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) - .build()) -} - -fn format_options_for_provider(preserves_thinking: bool) -> AnthropicFormatOptions { - AnthropicFormatOptions { - preserve_unsigned_thinking: preserves_thinking, - preserve_thinking_context: preserves_thinking, - thinking_disabled: false, - } + anthropic::from_declarative_config(config, tls_config, ConfigKeyResolver::new(Config::global())) + .map(|builder| { + builder + .map_api_client(|api_client| { + api_client + .with_request_builder(crate::session_context::session_id_request_builder()) + }) + .build() + }) } #[cfg(test)] diff --git a/crates/goose/src/providers/custom_provider_config.rs b/crates/goose/src/providers/custom_provider_config.rs new file mode 100644 index 000000000000..aaf1d56f3312 --- /dev/null +++ b/crates/goose/src/providers/custom_provider_config.rs @@ -0,0 +1,21 @@ +use goose_providers::declarative::KeyResolver; + +use crate::config::{Config, ConfigError}; + +pub struct ConfigKeyResolver<'a> { + config: &'a Config, +} + +impl<'a> ConfigKeyResolver<'a> { + pub fn new(config: &'a Config) -> Self { + Self { config } + } +} + +impl<'a> KeyResolver for ConfigKeyResolver<'a> { + type Error = ConfigError; + + fn resolve_key(&self, key: &str) -> std::result::Result { + self.config.get_secret(key) + } +} diff --git a/crates/goose/src/providers/declarative/empiriolabs.json b/crates/goose/src/providers/declarative/empiriolabs.json index 5357e73972c8..f0d6f536df58 100644 --- a/crates/goose/src/providers/declarative/empiriolabs.json +++ b/crates/goose/src/providers/declarative/empiriolabs.json @@ -1,7 +1,7 @@ { "name": "empiriolabs", "engine": "openai", - "display_name": "EmpirioLabs", + "display_name": "EmpirioLabs AI", "description": "Frontier open and proprietary chat models through one OpenAI-compatible API with streaming support", "api_key_env": "EMPIRIOLABS_API_KEY", "base_url": "https://api.empiriolabs.ai/v1/chat/completions", diff --git a/crates/goose/src/providers/formats/databricks.rs b/crates/goose/src/providers/formats/databricks.rs index 8c675f521eb2..7ba1141a0b7a 100644 --- a/crates/goose/src/providers/formats/databricks.rs +++ b/crates/goose/src/providers/formats/databricks.rs @@ -34,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 @@ -303,12 +303,10 @@ fn apply_claude_thinking_config( } } -pub fn format_tools(tools: &[Tool], model_name: &str) -> anyhow::Result> { +pub fn format_tools(tools: &[Tool], _model_name: &str) -> anyhow::Result> { let mut tool_names = std::collections::HashSet::new(); let mut result = Vec::new(); - let is_gemini = model_name.contains("gemini"); - for tool in tools { if !tool_names.insert(&tool.name) { return Err(anyhow!("Duplicate tool name: {}", tool.name)); @@ -320,25 +318,17 @@ pub fn format_tools(tools: &[Tool], model_name: &str) -> anyhow::Result, ) -> Result { - let timeout = Duration::from_secs(config.timeout_seconds.unwrap_or(OLLAMA_TIMEOUT)); - - let base = if config.base_url.starts_with("http://") || config.base_url.starts_with("https://") - { - 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 explicit_default_port = - config.base_url.ends_with(":80") || config.base_url.ends_with(":443"); - let is_https = base_url.scheme() == "https"; - - if base_url.port().is_none() && !explicit_default_port && !is_https { - base_url - .set_port(Some(OLLAMA_DEFAULT_PORT)) - .map_err(|_| anyhow::anyhow!("Failed to set default port"))?; - } - - let mut api_client = ApiClient::with_timeout_and_tls( - base_url.to_string(), - AuthMethod::NoAuth, - timeout, - tls_config, - )? - .with_request_builder(crate::session_context::session_id_request_builder()); - - 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(OllamaProvider::new( - api_client, - config.name.clone(), - config.skip_canonical_filtering, - options_from_config(), - )) + ollama::from_declarative_config(config, tls_config, ConfigKeyResolver::new(Config::global())) + .map(|builder| { + builder + .map_api_client(|api_client| { + api_client + .with_request_builder(crate::session_context::session_id_request_builder()) + }) + .options(options_from_config()) + .build() + }) } pub fn options_from_config() -> OllamaOptions { diff --git a/crates/goose/src/providers/openai_def.rs b/crates/goose/src/providers/openai_def.rs index 208d2cc7570c..679a32cbb7d8 100644 --- a/crates/goose/src/providers/openai_def.rs +++ b/crates/goose/src/providers/openai_def.rs @@ -4,12 +4,13 @@ use goose_providers::base::ProviderDescriptor; use std::collections::HashMap; use crate::config::declarative_providers::DeclarativeProviderConfig; +use crate::config::Config; use crate::providers::base::{ProviderDef, DEFAULT_PROVIDER_TIMEOUT_SECS}; +use crate::providers::custom_provider_config::ConfigKeyResolver; use goose_providers::api_client::{ApiClient, AuthMethod}; use goose_providers::openai::{ - ensure_url_scheme, parse_custom_headers, parse_openai_base_url, OpenAiProvider, - OpenAiProviderBuilder, OPEN_AI_DEFAULT_BASE_PATH, OPEN_AI_DEFAULT_FAST_MODEL, - OPEN_AI_VERSIONLESS_BASE_PATH, + parse_custom_headers, parse_openai_base_url, OpenAiProvider, OpenAiProviderBuilder, + OPEN_AI_DEFAULT_BASE_PATH, OPEN_AI_DEFAULT_FAST_MODEL, OPEN_AI_VERSIONLESS_BASE_PATH, }; pub struct OpenAiProviderDef; @@ -203,85 +204,19 @@ pub fn from_custom_config( config: DeclarativeProviderConfig, tls_config: Option, ) -> 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 global_config = crate::config::Config::global(); - let api_key = resolve_api_key(&config, &|key| global_config.get_secret(key))?; - - 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 = if let Some(port) = url.port() { - format!( - "{}://{}:{}", - url.scheme(), - url.host_str().unwrap_or(""), - port - ) - } else { - format!("{}://{}", url.scheme(), url.host_str().unwrap_or("")) - }; - 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_PROVIDER_TIMEOUT_SECS); - - 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), + goose_providers::openai::from_declarative_config( + config, tls_config, - )? - .with_request_builder(crate::session_context::session_id_request_builder()); - - 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) - .build()) + ConfigKeyResolver::new(Config::global()), + ) + .map(|builder| { + builder + .map_api_client(|api_client| { + api_client + .with_request_builder(crate::session_context::session_id_request_builder()) + }) + .build() + }) } /// Components extracted from an `OPENAI_BASE_URL` value. @@ -359,26 +294,6 @@ fn is_direct_openai_host(host: &str) -> bool { .unwrap_or(false) } -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::*; @@ -426,12 +341,6 @@ mod tests { assert!(!r.has_v1); } - #[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 is_direct_openai_host_matches_only_openai() { assert!(is_direct_openai_host("https://api.openai.com")); @@ -442,33 +351,6 @@ mod tests { assert!(!is_direct_openai_host("https://router.huggingface.co/v1")); } - #[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"); - } - #[test] fn parse_base_url_preserves_query_params() { let r = parse_base_url("https://gw.example.com/v1?api-version=2024-02-01").unwrap(); diff --git a/crates/goose/tests/acp_transport_auth_test.rs b/crates/goose/tests/acp_transport_auth_test.rs index a3107f540218..32198be829fa 100644 --- a/crates/goose/tests/acp_transport_auth_test.rs +++ b/crates/goose/tests/acp_transport_auth_test.rs @@ -1,16 +1,48 @@ use std::sync::Arc; use axum::body::Body; -use axum::http::{Method, Request, StatusCode}; +use axum::http::{HeaderValue, Method, Request, Response, StatusCode}; use axum::Router; use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig}; -use goose::acp::transport::create_router; +use goose::acp::transport::{create_acp_router, create_authenticated_acp_router, create_router}; use goose::agents::GoosePlatform; use tower::ServiceExt; const SECRET: &str = "test-secret-token"; fn test_router(require_token: bool, dir: &tempfile::TempDir) -> Router { + test_router_with_origins(require_token, dir, Vec::new()) +} + +fn test_acp_router(dir: &tempfile::TempDir) -> Router { + let server = Arc::new(AcpServer::new(AcpServerFactoryConfig { + builtins: vec![], + data_dir: dir.path().join("data"), + config_dir: dir.path().join("config"), + goose_platform: GoosePlatform::GooseCli, + additional_source_roots: Vec::new(), + scheduler: None, + })); + create_acp_router(server) +} + +fn test_authenticated_acp_router(dir: &tempfile::TempDir) -> Router { + let server = Arc::new(AcpServer::new(AcpServerFactoryConfig { + builtins: vec![], + data_dir: dir.path().join("data"), + config_dir: dir.path().join("config"), + goose_platform: GoosePlatform::GooseCli, + additional_source_roots: Vec::new(), + scheduler: None, + })); + create_authenticated_acp_router(server, SECRET.to_string()) +} + +fn test_router_with_origins( + require_token: bool, + dir: &tempfile::TempDir, + additional_allowed_origins: Vec, +) -> Router { let server = Arc::new(AcpServer::new(AcpServerFactoryConfig { builtins: vec![], data_dir: dir.path().join("data"), @@ -19,16 +51,30 @@ fn test_router(require_token: bool, dir: &tempfile::TempDir) -> Router { additional_source_roots: Vec::new(), scheduler: None, })); - create_router(server, SECRET.to_string(), require_token) + create_router( + server, + SECRET.to_string(), + require_token, + additional_allowed_origins, + ) } async fn send(router: &Router, method: Method, uri: &str, headers: &[(&str, &str)]) -> StatusCode { + send_response(router, method, uri, headers).await.status() +} + +async fn send_response( + router: &Router, + method: Method, + uri: &str, + headers: &[(&str, &str)], +) -> Response { let mut builder = Request::builder().method(method).uri(uri); for (name, value) in headers { builder = builder.header(*name, *value); } let request = builder.body(Body::empty()).unwrap(); - router.clone().oneshot(request).await.unwrap().status() + router.clone().oneshot(request).await.unwrap() } #[tokio::test] @@ -62,6 +108,190 @@ async fn websocket_handshake_without_token_is_unauthorized() { assert_eq!(status, StatusCode::UNAUTHORIZED); } +#[tokio::test] +async fn websocket_handshake_rejects_arbitrary_web_origins() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let status = send( + &router, + Method::GET, + "/acp", + &[ + ("origin", "https://evil.example"), + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ) + .await; + + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn acp_router_websocket_handshake_rejects_arbitrary_web_origins() { + let dir = tempfile::tempdir().unwrap(); + let router = test_acp_router(&dir); + + let status = send( + &router, + Method::GET, + "/acp", + &[ + ("origin", "https://evil.example"), + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ) + .await; + + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn authenticated_acp_router_allows_packaged_desktop_null_websocket_origin() { + let dir = tempfile::tempdir().unwrap(); + let router = test_authenticated_acp_router(&dir); + + let status = send( + &router, + Method::GET, + &format!("/acp?token={SECRET}"), + &[ + ("origin", "null"), + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ) + .await; + + assert_eq!(status, StatusCode::NOT_ACCEPTABLE); +} + +#[tokio::test] +async fn serve_router_rejects_null_websocket_origin_by_default() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let status = send( + &router, + Method::GET, + "/acp", + &[ + ("origin", "null"), + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ) + .await; + + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn websocket_handshake_allows_loopback_web_origins_by_default() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let status = send( + &router, + Method::GET, + "/acp", + &[ + ("origin", "http://localhost:5173"), + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ) + .await; + + assert_eq!(status, StatusCode::NOT_ACCEPTABLE); +} + +#[tokio::test] +async fn websocket_handshake_allows_ipv6_loopback_web_origins_by_default() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let status = send( + &router, + Method::GET, + "/acp", + &[ + ("origin", "http://[::1]:5173"), + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ) + .await; + + assert_eq!(status, StatusCode::NOT_ACCEPTABLE); +} + +#[tokio::test] +async fn websocket_handshake_explicit_origins_replace_loopback_defaults() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router_with_origins( + false, + &dir, + vec![HeaderValue::from_static("app://localhost")], + ); + + let status = send( + &router, + Method::GET, + "/acp", + &[ + ("origin", "http://localhost:5173"), + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ) + .await; + + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn websocket_handshake_allows_configured_origins() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router_with_origins( + false, + &dir, + vec![HeaderValue::from_static("app://localhost")], + ); + + let status = send( + &router, + Method::GET, + "/acp", + &[ + ("origin", "app://localhost"), + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ) + .await; + + assert_eq!(status, StatusCode::NOT_ACCEPTABLE); +} + #[tokio::test] async fn header_token_is_accepted() { let dir = tempfile::tempdir().unwrap(); @@ -106,10 +336,269 @@ async fn health_endpoints_skip_token_check() { } #[tokio::test] -async fn acp_open_when_no_secret_configured() { +async fn acp_open_when_auth_disabled() { let dir = tempfile::tempdir().unwrap(); let router = test_router(false, &dir); let status = send(&router, Method::GET, "/acp", &[]).await; assert_eq!(status, StatusCode::NOT_ACCEPTABLE); } + +#[tokio::test] +async fn acp_cors_rejects_arbitrary_web_origins() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "https://evil.example"), + ("Access-Control-Request-Method", "POST"), + ( + "Access-Control-Request-Headers", + "content-type,acp-connection-id", + ), + ], + ) + .await; + + assert!(response + .headers() + .get("access-control-allow-origin") + .is_none()); +} + +#[tokio::test] +async fn acp_cors_rejects_custom_app_origins_unless_configured() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "app://localhost"), + ("Access-Control-Request-Method", "POST"), + ( + "Access-Control-Request-Headers", + "content-type,acp-connection-id", + ), + ], + ) + .await; + + assert!(response + .headers() + .get("access-control-allow-origin") + .is_none()); +} + +#[tokio::test] +async fn acp_cors_allows_loopback_web_origins() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "http://localhost:5173"), + ("Access-Control-Request-Method", "POST"), + ( + "Access-Control-Request-Headers", + "content-type,acp-connection-id", + ), + ], + ) + .await; + + assert_eq!( + response + .headers() + .get("access-control-allow-origin") + .and_then(|value| value.to_str().ok()), + Some("http://localhost:5173") + ); +} + +#[tokio::test] +async fn acp_cors_allows_ipv6_loopback_web_origins() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "http://[::1]:5173"), + ("Access-Control-Request-Method", "POST"), + ( + "Access-Control-Request-Headers", + "content-type,acp-connection-id", + ), + ], + ) + .await; + + assert_eq!( + response + .headers() + .get("access-control-allow-origin") + .and_then(|value| value.to_str().ok()), + Some("http://[::1]:5173") + ); +} + +#[tokio::test] +async fn authenticated_acp_cors_preflight_skips_token_check() { + let dir = tempfile::tempdir().unwrap(); + let router = test_authenticated_acp_router(&dir); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "http://localhost:5173"), + ("Access-Control-Request-Method", "POST"), + ( + "Access-Control-Request-Headers", + "content-type,x-secret-key,acp-connection-id", + ), + ], + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("access-control-allow-origin") + .and_then(|value| value.to_str().ok()), + Some("http://localhost:5173") + ); +} + +#[tokio::test] +async fn authenticated_acp_cors_allows_packaged_desktop_null_origin() { + let dir = tempfile::tempdir().unwrap(); + let router = test_authenticated_acp_router(&dir); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "null"), + ("Access-Control-Request-Method", "POST"), + ( + "Access-Control-Request-Headers", + "content-type,x-secret-key,acp-connection-id", + ), + ], + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("access-control-allow-origin") + .and_then(|value| value.to_str().ok()), + Some("null") + ); +} + +#[tokio::test] +async fn serve_cors_rejects_null_origin_by_default() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "null"), + ("Access-Control-Request-Method", "POST"), + ( + "Access-Control-Request-Headers", + "content-type,x-secret-key,acp-connection-id", + ), + ], + ) + .await; + + assert!(response + .headers() + .get("access-control-allow-origin") + .is_none()); +} + +#[tokio::test] +async fn acp_cors_explicit_origins_replace_loopback_defaults() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router_with_origins( + false, + &dir, + vec![HeaderValue::from_static("app://localhost")], + ); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "http://localhost:5173"), + ("Access-Control-Request-Method", "POST"), + ( + "Access-Control-Request-Headers", + "content-type,acp-connection-id", + ), + ], + ) + .await; + + assert!(response + .headers() + .get("access-control-allow-origin") + .is_none()); +} + +#[tokio::test] +async fn acp_cors_allows_additional_configured_origins() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router_with_origins( + false, + &dir, + vec![HeaderValue::from_static("app://localhost")], + ); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "app://localhost"), + ("Access-Control-Request-Method", "POST"), + ( + "Access-Control-Request-Headers", + "content-type,acp-connection-id", + ), + ], + ) + .await; + + assert_eq!( + response + .headers() + .get("access-control-allow-origin") + .and_then(|value| value.to_str().ok()), + Some("app://localhost") + ); +} diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index e6be19789c3a..604d609bcfa8 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -31,7 +31,7 @@ goose is compatible with a wide range of LLM providers, allowing you to choose a | [ChatGPT Codex](https://chatgpt.com/codex) | Access GPT-5 Codex models optimized for code generation and understanding. **Requires a ChatGPT Plus/Pro subscription.** | No manual key. Uses browser-based OAuth authentication for both CLI and Desktop. | | [Databricks](https://www.databricks.com/) | Unified data analytics and AI platform for building and deploying models. | `DATABRICKS_HOST`, `DATABRICKS_TOKEN` | | [Docker Model Runner](https://docs.docker.com/ai/model-runner/) | Local models running in Docker Desktop or Docker CE with OpenAI-compatible API endpoints. **Because this provider runs locally, you must first [download a model](#local-llms).** | `OPENAI_HOST`, `OPENAI_BASE_PATH` | -| [EmpirioLabs](https://empiriolabs.ai/) | Frontier open and proprietary chat models (Qwen, DeepSeek, GLM, Kimi, MiniMax) through one OpenAI-compatible API with streaming. Catalog available at `https://api.empiriolabs.ai/v1/models`. | `EMPIRIOLABS_API_KEY` | +| [EmpirioLabs AI](https://empiriolabs.ai/) | Frontier open and proprietary chat models (Qwen, DeepSeek, GLM, Kimi, MiniMax) through one OpenAI-compatible API with streaming. Catalog available at `https://api.empiriolabs.ai/v1/models`. | `EMPIRIOLABS_API_KEY` | | [FuturMix](https://futurmix.ai/) | Unified AI gateway providing access to models from Anthropic, Google, OpenAI, and DeepSeek through an OpenAI-compatible API. | `FUTURMIX_API_KEY` | | [Gemini](https://ai.google.dev/gemini-api/docs) | Advanced LLMs by Google with multimodal capabilities (text, images). Gemini 3 models support configurable [thinking levels](#gemini-3-thinking-levels). | `GOOGLE_API_KEY`, `GEMINI3_THINKING_LEVEL` (optional) | | [GCP Vertex AI](https://cloud.google.com/vertex-ai) | Google Cloud's Vertex AI platform, supporting Gemini and Claude models. **Credentials must be [configured in advance](https://cloud.google.com/vertex-ai/docs/authentication).** Filters for allowed models by organization policy (if configured). | `GCP_PROJECT_ID`, `GCP_LOCATION` and optionally `GCP_MAX_RATE_LIMIT_RETRIES` (5), `GCP_MAX_OVERLOADED_RETRIES` (5), `GCP_INITIAL_RETRY_INTERVAL_MS` (5000), `GCP_BACKOFF_MULTIPLIER` (2.0), `GCP_MAX_RETRY_INTERVAL_MS` (320_000). | @@ -705,8 +705,8 @@ To set up Groq with goose, follow these steps: -### EmpirioLabs -[EmpirioLabs](https://empiriolabs.ai/) provides access to frontier open and proprietary chat models through a single OpenAI-compatible API with streaming. To use EmpirioLabs with goose, you need an API key from [EmpirioLabs](https://platform.empiriolabs.ai/dashboard/api-keys). +### EmpirioLabs AI +[EmpirioLabs AI](https://empiriolabs.ai/) provides access to frontier open and proprietary chat models through a single OpenAI-compatible API with streaming. To use EmpirioLabs with goose, you need an API key from [EmpirioLabs](https://platform.empiriolabs.ai/dashboard/api-keys). EmpirioLabs offers models that support tool calling, including: - **qwen3-7-plus** - Qwen3.7 Plus with a 1M context window @@ -729,7 +729,7 @@ To set up EmpirioLabs with goose, follow these steps: 2. Click the `Settings` button on the sidebar. 3. Click the `Models` tab. 4. Click `Configure Providers` - 5. Choose `EmpirioLabs` as provider from the list. + 5. Choose `EmpirioLabs AI` as provider from the list. 6. Click `Configure`, enter your API key, and click `Submit`. 7. Select the EmpirioLabs model of your choice. @@ -740,7 +740,7 @@ To set up EmpirioLabs with goose, follow these steps: goose configure ``` 2. Select `Configure Providers` from the menu. - 3. Follow the prompts to choose `EmpirioLabs` as the provider. + 3. Follow the prompts to choose `EmpirioLabs AI` as the provider. 4. Enter your API key when prompted. 5. Select the EmpirioLabs model of your choice. diff --git a/documentation/docs/guides/acp-clients.md b/documentation/docs/guides/acp-clients.md index 003907c3fd19..44c68faf5d11 100644 --- a/documentation/docs/guides/acp-clients.md +++ b/documentation/docs/guides/acp-clients.md @@ -204,12 +204,12 @@ For servers that support the draft standard ACP over Streamable HTTP https://git npm start -- --server http://HOST:PORT # example server -cargo run -p goose-cli --bin goose -- serve +GOOSE_SERVER__SECRET_KEY='a-long-random-secret' cargo run -p goose-cli --bin goose -- serve ``` ### Server Authentication -Set the `GOOSE_SERVER__SECRET_KEY` environment variable to require authentication on the ACP endpoint. When it is set, `goose serve` rejects any request that doesn't present a matching token: +Set the `GOOSE_SERVER__SECRET_KEY` environment variable to authenticate the ACP endpoint. `goose serve` refuses to start without this secret unless you explicitly pass `--dangerously-unauthenticated`: ```bash GOOSE_SERVER__SECRET_KEY='a-long-random-secret' goose serve @@ -217,7 +217,16 @@ GOOSE_SERVER__SECRET_KEY='a-long-random-secret' goose serve Clients authenticate by sending the token in the `X-Secret-Key` header, or as a `?token=` query parameter for WebSocket connections (the browser WebSocket API can't set custom headers). Requests without a matching token receive `401 Unauthorized`, including WebSocket handshakes. -When `GOOSE_SERVER__SECRET_KEY` is not set, the endpoint accepts unauthenticated connections and `goose serve` logs a warning at startup. +ACP WebSocket Origin validation allows loopback web origins by default. For `goose serve`, ACP CORS follows the same policy. If you pass any `--allowed-origin` values, that explicit list replaces the default loopback origins, so include every origin the client needs: + +```bash +GOOSE_SERVER__SECRET_KEY='a-long-random-secret' goose serve \ + --allowed-origin 'http://localhost:5173' \ + --allowed-origin 'app://localhost' \ + --allowed-origin 'https://app.example' +``` + +For local development only, `goose serve --dangerously-unauthenticated` starts without a secret and logs a warning. Do not use this mode with shell-capable builtins enabled unless the server is isolated from untrusted browser traffic. ### Single Prompt Mode diff --git a/documentation/docs/guides/environment-variables.md b/documentation/docs/guides/environment-variables.md index 144cbfeba1df..ce6c62b9f8b5 100644 --- a/documentation/docs/guides/environment-variables.md +++ b/documentation/docs/guides/environment-variables.md @@ -514,7 +514,7 @@ These variables configure the `goosed` server process. They are most often used | `GOOSE_HOST` | Interface the server binds to. Use `0.0.0.0` to accept connections from other machines; `localhost` or `127.0.0.1` restricts to the local machine. | Hostname or IP | `127.0.0.1` | | `GOOSE_PORT` | TCP port the server listens on | Port number | `3000` | | `GOOSE_TLS` | Enable TLS with a self-signed certificate. Required when connecting goose Desktop to a remote `goosed`. | `true`, `false` | `true` | -| `GOOSE_SERVER__SECRET_KEY` | Shared secret required in the `X-Secret-Key` header on all client requests. When set, it is also enforced on the `goose serve` ACP endpoint. | Secret string | Random (auto-generated) | +| `GOOSE_SERVER__SECRET_KEY` | Shared secret required in the `X-Secret-Key` header on all client requests. `goosed` auto-generates one when unset; `goose serve` requires this variable unless started with `--dangerously-unauthenticated`. | Secret string | Random for `goosed`; required for `goose serve` | **Examples** diff --git a/documentation/package-lock.json b/documentation/package-lock.json index ffec5c1dc579..14d7f45bbd2e 100644 --- a/documentation/package-lock.json +++ b/documentation/package-lock.json @@ -36,7 +36,7 @@ "@docusaurus/types": "3.7.0", "globby": "^13.2.2", "gray-matter": "^4.0.3", - "js-yaml": "^4.1.1", + "js-yaml": "^4.2.0", "typescript": "~5.6.2", "yaml-loader": "^0.8.1" }, @@ -10957,9 +10957,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/documentation/package.json b/documentation/package.json index eebb397df964..c8c53288d6dc 100644 --- a/documentation/package.json +++ b/documentation/package.json @@ -45,7 +45,7 @@ "@docusaurus/types": "3.7.0", "gray-matter": "^4.0.3", "globby": "^13.2.2", - "js-yaml": "^4.1.1", + "js-yaml": "^4.2.0", "typescript": "~5.6.2", "yaml-loader": "^0.8.1" }, diff --git a/documentation/src/pages/deeplink-generator.tsx b/documentation/src/pages/deeplink-generator.tsx index e1ab46beb3f7..350fe5d50c1e 100644 --- a/documentation/src/pages/deeplink-generator.tsx +++ b/documentation/src/pages/deeplink-generator.tsx @@ -68,7 +68,7 @@ export default function DeeplinkGenerator() { const urlParams = new URLSearchParams(window.location.search); if (urlParams.toString()) { try { - if (urlParams.get('cmd') === 'goosed' && urlParams.getAll('arg').includes('mcp')) { + if (urlParams.get('cmd') === 'goose' && urlParams.getAll('arg').includes('mcp')) { const args = urlParams.getAll('arg'); const extensionId = args[args.indexOf('mcp') + 1]; if (!extensionId) { @@ -187,7 +187,7 @@ export default function DeeplinkGenerator() { const generateDeeplink = (server: ServerConfig): string => { if (server.is_builtin) { const queryParams = [ - 'cmd=goosed', + 'cmd=goose', 'arg=mcp', `arg=${encodeURIComponent(server.id)}`, `description=${encodeURIComponent(server.id)}` diff --git a/documentation/src/utils/install-links.ts b/documentation/src/utils/install-links.ts index a70467748513..73a235e02987 100644 --- a/documentation/src/utils/install-links.ts +++ b/documentation/src/utils/install-links.ts @@ -3,7 +3,7 @@ import type { MCPServer } from "../types/server"; export function getGooseInstallLink(server: MCPServer): string { if (server.is_builtin) { const queryParams = [ - 'cmd=goosed', + 'cmd=goose', 'arg=mcp', `arg=${encodeURIComponent(server.id)}`, `description=${encodeURIComponent(server.id)}` @@ -53,4 +53,4 @@ export function getGooseInstallLink(server: MCPServer): string { ].join("&"); return `goose://extension?${queryParams}`; -} \ No newline at end of file +} diff --git a/evals/open-model-gym/.gitignore b/evals/open-model-gym/.gitignore deleted file mode 100644 index b95b7690d3db..000000000000 --- a/evals/open-model-gym/.gitignore +++ /dev/null @@ -1,72 +0,0 @@ -# Dependencies -node_modules/ -.pnpm-store/ -.workdir/ -report.html - -# Build outputs -dist/ -build/ -out/ -.next/ -.nuxt/ -.output/ -.opencode-root -suite/.pi-root - -# TypeScript -*.tsbuildinfo -*.d.ts.map -.g3/ -# Logs -logs/ -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* - -# Runtime data -pids/ -*.pid -*.seed -*.pid.lock - -# Coverage & testing -coverage/ -.nyc_output/ -.jest/ - -# Caches -.cache/ -.parcel-cache/ -.turbo/ -.eslintcache -.stylelintcache -*.swp -*.swo - -# IDE & editors -.idea/ -.vscode/ -*.sublime-project -*.sublime-workspace - -# OS files -.DS_Store -Thumbs.db - -# Environment variables -.env -.env.local -.env.*.local - -# Lock files (optional - uncomment if you don't want to track) -# package-lock.json -# yarn.lock -# pnpm-lock.yaml - -# Temporary files -tmp/ -temp/ -*.tmp diff --git a/evals/open-model-gym/Justfile b/evals/open-model-gym/Justfile deleted file mode 100644 index 09ccdf62d3bf..000000000000 --- a/evals/open-model-gym/Justfile +++ /dev/null @@ -1,71 +0,0 @@ -# Agent Runner - Test Suite (supports goose and opencode) - -# Default recipe -default: run - -# Full test run - all scenarios, all agents, 3 repetitions (worst kept) -run: _install - cd suite && npm run test - -# Full run with artifacts isolated under ~/.goose/gym-runs/ (keeps the repo clean) -run-clean: _install - #!/usr/bin/env bash - set -euo pipefail - export GYM_OUTPUT_DIR="$HOME/.goose/gym-runs/$(date +%Y%d%m%H%M%S)" - echo "Artifacts → $GYM_OUTPUT_DIR" - cd suite && npm run test - -# Quick test - file-editing + everyday-app-automation, single run each (no repetition) -test: _install - cd suite && npx tsx src/runner.ts --scenario=file-editing,everyday-app-automation --run-count=1 - -# Run a specific scenario (all agents, 3 reps) -scenario name: _install - cd suite && npx tsx src/runner.ts --scenario={{name}} - -# Run against a specific agent (all scenarios, 3 reps) -agent name: _install - cd suite && npx tsx src/runner.ts --agent={{name}} - -# Open report in browser (honors GYM_OUTPUT_DIR if set) -report: - #!/usr/bin/env bash - set -euo pipefail - dir="${GYM_OUTPUT_DIR:-.}" - open "${dir/#\~/$HOME}/report.html" - -# Install all dependencies -install: - cd suite && npm install - cd mcp-harness && npm install && npm run build - @# Install pi-mcp-adapter for Pi runner MCP support - @pi list 2>/dev/null | grep -q "pi-mcp-adapter" || pi install npm:pi-mcp-adapter - -# Build TypeScript -build: _install - cd suite && npm run build - -# Clear the test cache -clear-cache: - cd suite && npx tsx src/runner.ts --clear-cache - -# Run tests ignoring cache (force fresh runs) -run-fresh: _install - cd suite && npx tsx src/runner.ts --no-cache - -# Show cache stats -cache-stats: - @if [ -f suite/.cache/index.json ]; then \ - echo "Cache entries: $$(cat suite/.cache/index.json | grep -o '"[a-f0-9]\{16\}":' | wc -l | tr -d ' ')"; \ - echo "Cache size: $$(du -sh suite/.cache 2>/dev/null | cut -f1 || echo '0')"; \ - else \ - echo "No cache found"; \ - fi - -# Internal: install if node_modules missing, always rebuild mcp-harness -_install: - @[ -d suite/node_modules ] || (cd suite && npm install) - @[ -d mcp-harness/node_modules ] || (cd mcp-harness && npm install) - @cd mcp-harness && npm run build - @# Ensure pi-mcp-adapter is installed for Pi runner - @pi list 2>/dev/null | grep -q "pi-mcp-adapter" || pi install npm:pi-mcp-adapter diff --git a/evals/open-model-gym/README.md b/evals/open-model-gym/README.md deleted file mode 100644 index 7aa8899a7f2b..000000000000 --- a/evals/open-model-gym/README.md +++ /dev/null @@ -1,323 +0,0 @@ -# Open Model Gym - -Run agent tests across a matrix of **models × runners × scenarios**. - -It isn't hard for any agent to do ok with opus, but lets scale things in the other direction. What do we have to break things down to. - -image - -## Quick Start - -```bash -just install # one-time setup -just run # run full matrix (3 reps each) -just report # view results -``` - -## How It Works - -The test harness runs every combination of models, runners, and scenarios defined in your matrix. Each test runs multiple times (default 3) and keeps the **worst result** — if a test fails even once, it's marked failed. This catches flaky passes. - -## Configuration - -Edit `config.yaml` to define your test matrix: - -### Models - -LLMs to test against. Supports any provider (Anthropic, OpenAI, Ollama, etc.): - -```yaml -models: - - name: opus - provider: anthropic - model: claude-opus-4-5-20251101 - - - name: qwen3-coder - provider: ollama - model: qwen3-coder:64k - - - name: gpt4 - provider: openai - model: gpt-4-turbo -``` - -### Runners - -Agent frameworks that execute the tests. Each runner has its own binary, type, and configuration: - -```yaml -runners: - # Goose agent with extensions - - name: goose-full - type: goose - bin: goose # path to binary (can be absolute) - extensions: [developer, todo, skills] - stdio: - - node mcp-harness/dist/index.js - - # OpenCode agent - - name: opencode - type: opencode - bin: opencode # path to binary - stdio: - - node mcp-harness/dist/index.js - - # Custom goose binary path - - name: goose-dev - type: goose - bin: /path/to/my/goose-dev - extensions: [developer] -``` - -**Supported runner types:** -- `goose` — [Goose](https://github.com/aaif-goose/goose) agent framework -- `opencode` — [OpenCode](https://opencode.ai) agent framework -- `pi` — [Pi](https://github.com/badlogic/pi-mono) coding agent - -## Runner Details - -Each runner has different setup requirements, MCP integration methods, and session handling. - -### Goose - -[Goose](https://github.com/aaif-goose/goose) is an open-source coding agent with built-in MCP support. - -**Setup:** Install via `brew install goose` or from source. - -**MCP Integration:** Native support. The harness writes a `config.yaml` to an isolated `.goose-root/` directory with extensions and MCP servers: - -```yaml -extensions: - developer: - enabled: true - mcp_harness: - type: stdio - enabled: true - cmd: node - args: [mcp-harness/dist/index.js] -``` - -**Session Handling:** Uses `--name ` for named sessions, `--resume` to continue: -- Turn 1: `goose run -i --name ` -- Turn 2+: `goose run -i --name --resume` -- Single-turn: `goose run -i --no-session` - -### OpenCode - -[OpenCode](https://opencode.ai) is a terminal-based coding agent. - -**Setup:** Install via their website or package manager. - -**MCP Integration:** Native support. The harness writes an `opencode.json` config to the workdir: - -```json -{ - "mcp": { - "harness": { - "type": "local", - "command": ["node", "mcp-harness/dist/index.js"], - "enabled": true - } - }, - "model": "anthropic/claude-opus-4-5-20251101" -} -``` - -**Session Handling:** Uses `--continue` to resume the last session in the working directory: -- Turn 1: `opencode run ""` -- Turn 2+: `opencode run --continue ""` - -⚠️ OpenCode doesn't support named sessions, so multi-turn scenarios exclude it. - -### Pi - -[Pi](https://github.com/badlogic/pi-mono) is a lightweight coding agent that requires an adapter for MCP support. - -**Setup:** -```bash -# Install Pi -npm install -g @anthropic/pi # or from source - -# Install the MCP adapter (required for MCP tools) -pi install npm:pi-mcp-adapter -``` - -The `just install` recipe auto-installs pi-mcp-adapter if missing. - -**MCP Integration:** Via [pi-mcp-adapter](https://github.com/nicobailon/pi-mcp-adapter). The harness dynamically writes a `.pi-mcp.json` config to the workdir: - -```json -{ - "mcpServers": { - "harness": { - "command": "node", - "args": ["mcp-harness/dist/index.js"], - "lifecycle": "eager", - "env": { "MCP_HARNESS_LOG": "/tool-calls.log" } - } - }, - "settings": { "directTools": true } -} -``` - -Key settings: -- `directTools: true` — Registers MCP tools directly in Pi's tool list (no wrapper) -- `lifecycle: "eager"` — Connects to MCP servers at startup - -**Model Configuration:** Pi requires custom models (like Ollama) to be defined in `models.json`. The harness automatically generates this config in an isolated `.pi-root/` directory and sets `PI_CODING_AGENT_DIR` to use it: - -```json -{ - "providers": { - "ollama": { - "baseUrl": "http://localhost:11434/v1", - "api": "openai-completions", - "apiKey": "ollama", - "models": [{ "id": "model-name", "name": "Model Name", ... }] - } - } -} -``` - -The harness copies `auth.json` from your real Pi config (`~/.pi/agent/`) so API keys work. - -**Session Handling:** Uses `--session ` for file-based sessions, `--continue` to resume: -- Turn 1: `pi -p --session ""` -- Turn 2+: `pi -p --continue --session ""` -- Single-turn: `pi -p --no-session ""` - -The `-p` flag runs Pi in non-interactive "print" mode for automation - -### Matrix - -Define which scenarios run against which models/runners: - -```yaml -matrix: - - scenario: file-editing - models: [opus, qwen3-coder] # omit to run all models - runners: [goose-full, opencode] # omit to run all runners - - - scenario: everyday-app-automation - # runs against ALL models and ALL runners -``` - -## Scenarios - -Scenarios live in `suite/scenarios/` as YAML files: - -```yaml -name: file-editing -description: Create and edit files -prompt: | - 1. Create joke.md containing a short joke - 2. Edit hello.rs to add a debug function - -setup: - hello.rs: | - fn main() { println!("Hello!"); } - -validate: - - type: file_exists - path: joke.md - - type: file_matches - path: hello.rs - regex: "fn\\s+debug" -``` - -### Validation Rules - -| Rule | Description | -|------|-------------| -| `file_exists` | File exists at path | -| `file_not_empty` | File exists and has content | -| `file_contains` | File contains literal string | -| `file_matches` | File matches regex pattern | -| `command_succeeds` | Shell command exits 0 | -| `tool_called` | MCP tool was called with matching args (regex supported) | - -**Tool call validation example:** -```yaml -validate: - - type: tool_called - tool: slack_search_messages - args: - query: /quarterly.?review/ # regex pattern - - type: tool_called - tool: jira_create_issue - args: - summary: /Q1.*Review/ - description: /David Brown/ -``` - -## MCP Harness - -Mock MCP server providing simulated tools for testing agent tool-use without hitting real APIs. - -```bash -cd mcp-harness && npm install && npm run build -``` - -**Available tools:** gdrive, sheets, salesforce, slack, calendar, gmail, jira, github - -Each tool returns realistic mock data. Tool calls are logged to `tool-calls.log` in the workdir for validation. - -## Commands - -| Command | Description | -|---------|-------------| -| `just run` | Full test run (3 reps each, worst kept) | -| `just run-clean` | Full run with artifacts isolated under `~/.goose/gym-runs/` | -| `just test` | Quick run (1 rep each) | -| `just scenario ` | Run specific scenario | -| `just agent ` | Run specific agent | -| `just report` | Open HTML results | - -### CLI Flags - -```bash -# Filter by scenario, model, or runner -npx tsx src/runner.ts --scenario=file-editing --model=opus --runner=goose - -# Control repetition count -npx tsx src/runner.ts --run-count=5 - -# Don't auto-open browser -npx tsx src/runner.ts --no-open - -# Redirect all run artifacts outside the repo (see Output below) -npx tsx src/runner.ts --output-dir=~/.goose/gym-runs/latest - -# Raise the per-agent timeout (seconds) for slow local models on heavy -# scenarios. Default 300s; also settable via GYM_AGENT_TIMEOUT. -npx tsx src/runner.ts --agent-timeout=1200 -``` - -## Output - -- `report.html` — Live-updating HTML matrix showing pass/fail status, duration, and validation details -- `logs/` — Full agent output logs for each run - -By default these (plus the cache, scratch workdir, and isolated agent config -roots `.goose-root/` / `.opencode-root/` / `.pi-root/`) are written inside the -gym directory. They're gitignored, but still pile up in your checkout — awkward -if you want to run the bench regularly or from a worktree. - -To keep the repo clean, redirect **all** run artifacts to a single base -directory with the `GYM_OUTPUT_DIR` env var (or the `--output-dir=` flag). -`config.yaml` and `scenarios/` are still read from the repo. - -```bash -# Everything lands under a timestamped dir outside the repo (YYYYDDMMHHMMSS) -GYM_OUTPUT_DIR=~/.goose/gym-runs/$(date +%Y%d%m%H%M%S) just run - -# Convenience recipe that does the timestamping for you -just run-clean - -# View the report from a redirected run -GYM_OUTPUT_DIR=~/.goose/gym-runs/20261406101500 just report -``` - -> Note: the run cache lives under the output dir too, so a fresh timestamped -> dir means a fresh (cold) cache. Point `GYM_OUTPUT_DIR` at a stable directory -> if you want cache reuse across runs. diff --git a/evals/open-model-gym/config.yaml b/evals/open-model-gym/config.yaml deleted file mode 100644 index 64aede15d0b6..000000000000 --- a/evals/open-model-gym/config.yaml +++ /dev/null @@ -1,85 +0,0 @@ -# ============================================================================= -# Models - the LLMs to test -# ============================================================================= -models: - - name: opus - provider: anthropic - model: claude-opus-4-5-20251101 - - - name: glm-4.7-flash - provider: ollama - model: glm-4.7-flash:latest - - # too slow on 64g: - #- name: frob/qwen3-coder-next:latest - # provider: ollama - # model: frob/qwen3-coder-next:latest - - - name: kimi-k2.5 - provider: ollama - model: kimi-k2.5:cloud - - - name: gpt-oss-120b - provider: ollama - model: gpt-oss:120b-cloud - - - name: gpt-oss-20b - provider: ollama - model: gpt-oss:20b - - - name: qwen3-coder:latest - provider: ollama - model: qwen3-coder:latest - - # good but too slow on 64G - #- name: nemotron-3-nano - # provider: ollama - # model: nemotron-3-nano:latest - -# ============================================================================= -# Runners - agent frameworks with their specific configurations -# ============================================================================= -# Each runner has its own binary, extensions/config, and isolated config directory -runners: - # - name: goose - # type: goose - # bin: goose - # extensions: [developer] - # stdio: - # - node mcp-harness/dist/index.js - - - name: goose-full - type: goose - bin: goose - extensions: [developer, todo, skills, code_execution, extensionmanager] - stdio: - - node mcp-harness/dist/index.js - - - name: opencode - type: opencode - bin: opencode - stdio: - - node mcp-harness/dist/index.js - - - name: pi - type: pi - bin: pi - # Pi takes provider/model from the test matrix, not config - # MCP support via pi-mcp-adapter: `pi install npm:pi-mcp-adapter` - stdio: - - node mcp-harness/dist/index.js - -# ============================================================================= -# Test Matrix -# ============================================================================= -# scenarios × models × runners -# - Omit 'models' to run against ALL models -# - Omit 'runners' to run against ALL runners -matrix: - # Single-turn scenarios: all models × all runners - - scenario: everyday-app-automation - - scenario: file-editing - - # Multi-turn: goose and pi only (opencode doesn't support session continuation) - - scenario: multi-turn-edit - runners: [goose-full] diff --git a/evals/open-model-gym/gym.png b/evals/open-model-gym/gym.png deleted file mode 100644 index 242cf3be7e8e..000000000000 Binary files a/evals/open-model-gym/gym.png and /dev/null differ diff --git a/evals/open-model-gym/mcp-harness/README.md b/evals/open-model-gym/mcp-harness/README.md deleted file mode 100644 index 726ea118983c..000000000000 --- a/evals/open-model-gym/mcp-harness/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# MCP Harness - -A simulated MCP server with realistic fake tools for testing. Provides mock implementations of common business integrations without requiring actual API credentials. - -## Tools Included (35 tools) - -### Google Drive -- `gdrive_search` - Search files by name, content, or type -- `gdrive_read_file` - Read file contents -- `gdrive_create_file` - Create new files -- `gdrive_share_file` - Share files with users - -### Google Sheets -- `sheets_read` - Read spreadsheet data -- `sheets_write` - Write/update cells -- `sheets_append` - Append rows -- `sheets_create` - Create new spreadsheets - -### Salesforce -- `salesforce_query` - Execute SOQL queries -- `salesforce_get_record` - Get record by ID -- `salesforce_create_record` - Create records -- `salesforce_update_record` - Update records -- `salesforce_search` - SOSL search - -### Slack -- `slack_send_message` - Send messages -- `slack_get_messages` - Get channel messages -- `slack_search_messages` - Search messages -- `slack_list_channels` - List channels -- `slack_get_user_info` - Get user info -- `slack_set_status` - Set user status - -### Google Calendar -- `calendar_list_events` - List events -- `calendar_create_event` - Create events -- `calendar_update_event` - Update events -- `calendar_delete_event` - Delete events - -### Gmail -- `gmail_search` - Search emails -- `gmail_read_message` - Read email content -- `gmail_send` - Send emails -- `gmail_create_draft` - Create drafts - -### Jira -- `jira_search_issues` - Search with JQL -- `jira_get_issue` - Get issue details -- `jira_create_issue` - Create issues -- `jira_update_issue` - Update issues -- `jira_add_comment` - Add comments - -### GitHub -- `github_search_repos` - Search repositories -- `github_list_issues` - List issues -- `github_create_issue` - Create issues -- `github_list_prs` - List pull requests - -## Setup - -```bash -npm install -npm run build -``` - -## Run - -```bash -npm run start -# or -./run.sh -``` - -## MCP Config - -Add to your MCP client config: - -```json -{ - "mcpServers": { - "harness": { - "command": "node", - "args": ["/path/to/mcp-harness/dist/index.js"] - } - } -} -``` diff --git a/evals/open-model-gym/mcp-harness/package-lock.json b/evals/open-model-gym/mcp-harness/package-lock.json deleted file mode 100644 index b8dfaf41edf4..000000000000 --- a/evals/open-model-gym/mcp-harness/package-lock.json +++ /dev/null @@ -1,1173 +0,0 @@ -{ - "name": "mcp-harness", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "mcp-harness", - "version": "1.0.0", - "dependencies": { - "@modelcontextprotocol/sdk": "^1.26.0" - }, - "devDependencies": { - "@types/node": "^25.2.0", - "typescript": "^5.6.3" - } - }, - "node_modules/@hono/node-server": { - "version": "1.19.13", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", - "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@types/node": { - "version": "25.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz", - "integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", - "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.12.23", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", - "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", - "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } - } - } -} diff --git a/evals/open-model-gym/mcp-harness/package.json b/evals/open-model-gym/mcp-harness/package.json deleted file mode 100644 index 76c14dcb5457..000000000000 --- a/evals/open-model-gym/mcp-harness/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "mcp-harness", - "version": "1.0.0", - "description": "Simulated real-world MCP tools for testing - Google Drive, Sheets, Salesforce, Slack, and more", - "private": true, - "type": "module", - "scripts": { - "build": "tsc -p tsconfig.json", - "start": "node dist/index.js" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.26.0" - }, - "devDependencies": { - "@types/node": "^25.2.0", - "typescript": "^5.6.3" - } -} diff --git a/evals/open-model-gym/mcp-harness/run.sh b/evals/open-model-gym/mcp-harness/run.sh deleted file mode 100755 index 02497dd6eeca..000000000000 --- a/evals/open-model-gym/mcp-harness/run.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash -cd "$(dirname "$0")" -npm run build && npm run start diff --git a/evals/open-model-gym/mcp-harness/src/index.ts b/evals/open-model-gym/mcp-harness/src/index.ts deleted file mode 100644 index 57f1980416db..000000000000 --- a/evals/open-model-gym/mcp-harness/src/index.ts +++ /dev/null @@ -1,1040 +0,0 @@ -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; -import * as fs from 'fs'; -import * as path from 'path'; - -// Logging configuration -const LOG_FILE = process.env.MCP_HARNESS_LOG || path.join(process.cwd(), 'tool-calls.log'); - -function logToolCall(toolName: string, args: Record, result: any) { - const entry = { - timestamp: new Date().toISOString(), - tool: toolName, - arguments: args, - result: result, - }; - const line = JSON.stringify(entry) + '\n'; - fs.appendFileSync(LOG_FILE, line); -} - -// Fake data generators -const fakeUsers = [ - { id: 'U001', name: 'Alice Johnson', email: 'alice@company.com', department: 'Engineering' }, - { id: 'U002', name: 'Bob Smith', email: 'bob@company.com', department: 'Sales' }, - { id: 'U003', name: 'Carol Williams', email: 'carol@company.com', department: 'Marketing' }, - { id: 'U004', name: 'David Brown', email: 'david@company.com', department: 'Finance' }, - { id: 'U005', name: 'Emma Davis', email: 'emma@company.com', department: 'Engineering' }, -]; - -const fakeCompanies = [ - { id: 'ACC001', name: 'Acme Corp', industry: 'Technology', revenue: 5000000 }, - { id: 'ACC002', name: 'GlobalTech Inc', industry: 'Manufacturing', revenue: 12000000 }, - { id: 'ACC003', name: 'StartupXYZ', industry: 'SaaS', revenue: 800000 }, - { id: 'ACC004', name: 'MegaCorp Ltd', industry: 'Retail', revenue: 45000000 }, - { id: 'ACC005', name: 'InnovateCo', industry: 'Healthcare', revenue: 3200000 }, -]; - -const fakeOpportunities = [ - { id: 'OPP001', name: 'Enterprise Deal - Acme', accountId: 'ACC001', stage: 'Negotiation', amount: 150000, closeDate: '2026-03-15' }, - { id: 'OPP002', name: 'Expansion - GlobalTech', accountId: 'ACC002', stage: 'Proposal', amount: 75000, closeDate: '2026-02-28' }, - { id: 'OPP003', name: 'New Business - StartupXYZ', accountId: 'ACC003', stage: 'Discovery', amount: 25000, closeDate: '2026-04-10' }, - { id: 'OPP004', name: 'Renewal - MegaCorp', accountId: 'ACC004', stage: 'Closed Won', amount: 200000, closeDate: '2026-01-20' }, -]; - -const fakeFiles = [ - { id: 'FILE001', name: 'Q4 Report.docx', mimeType: 'application/vnd.google-apps.document', size: 245000, modifiedTime: '2026-01-28T14:30:00Z', owner: 'alice@company.com' }, - { id: 'FILE002', name: 'Sales Forecast.xlsx', mimeType: 'application/vnd.google-apps.spreadsheet', size: 128000, modifiedTime: '2026-02-01T09:15:00Z', owner: 'bob@company.com' }, - { id: 'FILE003', name: 'Marketing Plan 2026.pdf', mimeType: 'application/pdf', size: 1520000, modifiedTime: '2026-01-25T16:45:00Z', owner: 'carol@company.com' }, - { id: 'FILE004', name: 'Budget Template.xlsx', mimeType: 'application/vnd.google-apps.spreadsheet', size: 89000, modifiedTime: '2026-01-30T11:00:00Z', owner: 'david@company.com' }, - { id: 'FILE005', name: 'Architecture Diagram.png', mimeType: 'image/png', size: 456000, modifiedTime: '2026-02-02T08:20:00Z', owner: 'emma@company.com' }, -]; - -const fakeSpreadsheets: Record = { - 'SHEET001': { - title: 'Sales Pipeline Q1 2026', - sheets: [ - { - name: 'Deals', - data: [ - ['Deal Name', 'Company', 'Amount', 'Stage', 'Close Date'], - ['Enterprise License', 'Acme Corp', '$150,000', 'Negotiation', '2026-03-15'], - ['Platform Upgrade', 'GlobalTech', '$75,000', 'Proposal', '2026-02-28'], - ['Starter Package', 'StartupXYZ', '$25,000', 'Discovery', '2026-04-10'], - ] - }, - { - name: 'Summary', - data: [ - ['Metric', 'Value'], - ['Total Pipeline', '$250,000'], - ['Deals in Negotiation', '1'], - ['Expected Close Rate', '65%'], - ] - } - ] - }, - 'SHEET002': { - title: 'Employee Directory', - sheets: [ - { - name: 'Employees', - data: [ - ['Name', 'Email', 'Department', 'Start Date'], - ['Alice Johnson', 'alice@company.com', 'Engineering', '2022-03-01'], - ['Bob Smith', 'bob@company.com', 'Sales', '2021-08-15'], - ['Carol Williams', 'carol@company.com', 'Marketing', '2023-01-10'], - ] - } - ] - } -}; - -const fakeSlackChannels = [ - { id: 'C001', name: 'general', memberCount: 150, topic: 'Company-wide announcements' }, - { id: 'C002', name: 'engineering', memberCount: 45, topic: 'Engineering discussions' }, - { id: 'C003', name: 'sales', memberCount: 28, topic: 'Sales team coordination' }, - { id: 'C004', name: 'random', memberCount: 142, topic: 'Non-work banter' }, -]; - -const fakeSlackMessages = [ - { channel: 'C001', user: 'U001', text: 'Reminder: All-hands meeting tomorrow at 2pm', ts: '1706886000.000100' }, - { channel: 'C001', user: 'U003', text: 'Thanks for the reminder!', ts: '1706886060.000200' }, - { channel: 'C002', user: 'U005', text: 'Just merged the new auth PR', ts: '1706885400.000300' }, - { channel: 'C002', user: 'U001', text: 'Great work! Any breaking changes?', ts: '1706885460.000400' }, - { channel: 'C003', user: 'U002', text: 'Closed the MegaCorp deal! 🎉', ts: '1706884800.000500' }, - { channel: 'C001', user: 'U004', text: 'Please review the quarterly review document I shared. Key metrics show 15% growth.', ts: '1706886100.000600' }, -]; - -const fakeCalendarEvents = [ - { id: 'EVT001', summary: 'Weekly Standup', start: '2026-02-03T09:00:00Z', end: '2026-02-03T09:30:00Z', attendees: ['alice@company.com', 'emma@company.com'] }, - { id: 'EVT002', summary: 'Client Call - Acme Corp', start: '2026-02-03T14:00:00Z', end: '2026-02-03T15:00:00Z', attendees: ['bob@company.com', 'alice@company.com'] }, - { id: 'EVT003', summary: 'Product Review', start: '2026-02-04T11:00:00Z', end: '2026-02-04T12:00:00Z', attendees: ['carol@company.com', 'david@company.com', 'emma@company.com'] }, -]; - -const fakeEmails = [ - { id: 'MSG001', from: 'client@acme.com', to: 'bob@company.com', subject: 'Re: Proposal Follow-up', snippet: 'Thanks for sending over the revised proposal...', date: '2026-02-02T10:30:00Z' }, - { id: 'MSG002', from: 'hr@company.com', to: 'all@company.com', subject: 'February Benefits Update', snippet: 'Please review the updated benefits information...', date: '2026-02-01T08:00:00Z' }, - { id: 'MSG003', from: 'alice@company.com', to: 'emma@company.com', subject: 'Code Review Request', snippet: 'Could you take a look at PR #423...', date: '2026-02-02T14:15:00Z' }, -]; - -// Utility functions -function generateId(prefix: string): string { - return `${prefix}${Date.now().toString(36)}${Math.random().toString(36).substr(2, 5)}`; -} - -function now(): string { - return new Date().toISOString(); -} - -function randomDelay(): number { - return Math.floor(Math.random() * 200) + 50; -} - -// Tool definitions -const tools = [ - // === Google Drive Tools === - { - name: 'gdrive_search', - description: 'Search for files in Google Drive by name, content, or type. Returns matching files with metadata.', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query (supports name:, type:, owner: prefixes)' }, - limit: { type: 'integer', minimum: 1, maximum: 50, default: 10, description: 'Maximum results to return' }, - includeShared: { type: 'boolean', default: true, description: 'Include files shared with you' }, - }, - required: ['query'], - }, - }, - { - name: 'gdrive_read_file', - description: 'Read the contents of a file from Google Drive. Supports documents, text files, and exports spreadsheets as CSV.', - inputSchema: { - type: 'object', - properties: { - fileId: { type: 'string', description: 'The Google Drive file ID' }, - exportFormat: { type: 'string', enum: ['text', 'html', 'csv', 'pdf'], default: 'text', description: 'Export format for Google Docs' }, - }, - required: ['fileId'], - }, - }, - { - name: 'gdrive_create_file', - description: 'Create a new file in Google Drive with the specified content.', - inputSchema: { - type: 'object', - properties: { - name: { type: 'string', description: 'File name including extension' }, - content: { type: 'string', description: 'File content' }, - mimeType: { type: 'string', description: 'MIME type of the file' }, - folderId: { type: 'string', description: 'Parent folder ID (optional)' }, - }, - required: ['name', 'content'], - }, - }, - { - name: 'gdrive_share_file', - description: 'Share a file with specific users or make it publicly accessible.', - inputSchema: { - type: 'object', - properties: { - fileId: { type: 'string', description: 'The Google Drive file ID' }, - email: { type: 'string', description: 'Email address to share with' }, - role: { type: 'string', enum: ['reader', 'commenter', 'writer'], default: 'reader', description: 'Permission level' }, - sendNotification: { type: 'boolean', default: true, description: 'Send email notification' }, - }, - required: ['fileId', 'email'], - }, - }, - - // === Google Sheets Tools === - { - name: 'sheets_read', - description: 'Read data from a Google Sheets spreadsheet. Returns cell values from the specified range.', - inputSchema: { - type: 'object', - properties: { - spreadsheetId: { type: 'string', description: 'The spreadsheet ID' }, - range: { type: 'string', description: 'A1 notation range (e.g., "Sheet1!A1:D10")' }, - valueRenderOption: { type: 'string', enum: ['FORMATTED_VALUE', 'UNFORMATTED_VALUE', 'FORMULA'], default: 'FORMATTED_VALUE' }, - }, - required: ['spreadsheetId', 'range'], - }, - }, - { - name: 'sheets_write', - description: 'Write data to a Google Sheets spreadsheet. Overwrites existing data in the specified range.', - inputSchema: { - type: 'object', - properties: { - spreadsheetId: { type: 'string', description: 'The spreadsheet ID' }, - range: { type: 'string', description: 'A1 notation range (e.g., "Sheet1!A1")' }, - values: { type: 'array', items: { type: 'array', items: { type: 'string' } }, description: '2D array of values to write' }, - }, - required: ['spreadsheetId', 'range', 'values'], - }, - }, - { - name: 'sheets_append', - description: 'Append rows to a Google Sheets spreadsheet. Adds data after the last row with content.', - inputSchema: { - type: 'object', - properties: { - spreadsheetId: { type: 'string', description: 'The spreadsheet ID' }, - range: { type: 'string', description: 'A1 notation range indicating the table (e.g., "Sheet1!A:D")' }, - values: { type: 'array', items: { type: 'array', items: { type: 'string' } }, description: '2D array of rows to append' }, - }, - required: ['spreadsheetId', 'range', 'values'], - }, - }, - { - name: 'sheets_create', - description: 'Create a new Google Sheets spreadsheet with optional initial data.', - inputSchema: { - type: 'object', - properties: { - title: { type: 'string', description: 'Spreadsheet title' }, - sheetNames: { type: 'array', items: { type: 'string' }, description: 'Names of sheets to create' }, - initialData: { type: 'object', description: 'Map of sheet name to 2D array of initial values' }, - }, - required: ['title'], - }, - }, - - // === Salesforce Tools === - { - name: 'salesforce_query', - description: 'Execute a SOQL query against Salesforce. Returns matching records with pagination support.', - inputSchema: { - type: 'object', - properties: { - soql: { type: 'string', description: 'SOQL query (e.g., "SELECT Id, Name FROM Account WHERE Industry = \'Technology\'")' }, - limit: { type: 'integer', minimum: 1, maximum: 2000, default: 100, description: 'Maximum records to return' }, - }, - required: ['soql'], - }, - }, - { - name: 'salesforce_get_record', - description: 'Get a single Salesforce record by ID with all or specified fields.', - inputSchema: { - type: 'object', - properties: { - objectType: { type: 'string', description: 'Salesforce object type (e.g., Account, Contact, Opportunity)' }, - recordId: { type: 'string', description: 'The record ID' }, - fields: { type: 'array', items: { type: 'string' }, description: 'Fields to retrieve (optional, returns all if not specified)' }, - }, - required: ['objectType', 'recordId'], - }, - }, - { - name: 'salesforce_create_record', - description: 'Create a new record in Salesforce.', - inputSchema: { - type: 'object', - properties: { - objectType: { type: 'string', description: 'Salesforce object type' }, - data: { type: 'object', description: 'Field values for the new record' }, - }, - required: ['objectType', 'data'], - }, - }, - { - name: 'salesforce_update_record', - description: 'Update an existing Salesforce record.', - inputSchema: { - type: 'object', - properties: { - objectType: { type: 'string', description: 'Salesforce object type' }, - recordId: { type: 'string', description: 'The record ID to update' }, - data: { type: 'object', description: 'Field values to update' }, - }, - required: ['objectType', 'recordId', 'data'], - }, - }, - { - name: 'salesforce_search', - description: 'Execute a SOSL search across multiple Salesforce objects.', - inputSchema: { - type: 'object', - properties: { - searchTerm: { type: 'string', description: 'Search term' }, - objects: { type: 'array', items: { type: 'string' }, description: 'Objects to search (e.g., ["Account", "Contact"])' }, - limit: { type: 'integer', minimum: 1, maximum: 200, default: 20 }, - }, - required: ['searchTerm'], - }, - }, - - // === Slack Tools === - { - name: 'slack_send_message', - description: 'Send a message to a Slack channel or direct message.', - inputSchema: { - type: 'object', - properties: { - channel: { type: 'string', description: 'Channel ID or name (e.g., "#general" or "C001")' }, - text: { type: 'string', description: 'Message text (supports Slack markdown)' }, - threadTs: { type: 'string', description: 'Thread timestamp to reply to (optional)' }, - }, - required: ['channel', 'text'], - }, - }, - { - name: 'slack_get_messages', - description: 'Retrieve recent messages from a Slack channel.', - inputSchema: { - type: 'object', - properties: { - channel: { type: 'string', description: 'Channel ID or name' }, - limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 }, - oldest: { type: 'string', description: 'Only messages after this timestamp' }, - latest: { type: 'string', description: 'Only messages before this timestamp' }, - }, - required: ['channel'], - }, - }, - { - name: 'slack_search_messages', - description: 'Search for messages across Slack channels.', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query (supports from:, in:, has: modifiers)' }, - sort: { type: 'string', enum: ['score', 'timestamp'], default: 'score' }, - limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 }, - }, - required: ['query'], - }, - }, - { - name: 'slack_list_channels', - description: 'List available Slack channels the user has access to.', - inputSchema: { - type: 'object', - properties: { - types: { type: 'string', enum: ['public', 'private', 'mpim', 'im', 'all'], default: 'public' }, - limit: { type: 'integer', minimum: 1, maximum: 200, default: 50 }, - }, - }, - }, - { - name: 'slack_get_user_info', - description: 'Get information about a Slack user.', - inputSchema: { - type: 'object', - properties: { - userId: { type: 'string', description: 'User ID' }, - }, - required: ['userId'], - }, - }, - { - name: 'slack_set_status', - description: 'Set your Slack status message and emoji.', - inputSchema: { - type: 'object', - properties: { - statusText: { type: 'string', description: 'Status message' }, - statusEmoji: { type: 'string', description: 'Status emoji (e.g., ":calendar:")' }, - expirationMinutes: { type: 'integer', description: 'Minutes until status expires (optional)' }, - }, - required: ['statusText'], - }, - }, - - // === Google Calendar Tools === - { - name: 'calendar_list_events', - description: 'List upcoming calendar events.', - inputSchema: { - type: 'object', - properties: { - calendarId: { type: 'string', default: 'primary', description: 'Calendar ID' }, - timeMin: { type: 'string', description: 'Start time (ISO 8601)' }, - timeMax: { type: 'string', description: 'End time (ISO 8601)' }, - maxResults: { type: 'integer', minimum: 1, maximum: 250, default: 10 }, - }, - }, - }, - { - name: 'calendar_create_event', - description: 'Create a new calendar event.', - inputSchema: { - type: 'object', - properties: { - summary: { type: 'string', description: 'Event title' }, - description: { type: 'string', description: 'Event description' }, - start: { type: 'string', description: 'Start time (ISO 8601)' }, - end: { type: 'string', description: 'End time (ISO 8601)' }, - attendees: { type: 'array', items: { type: 'string' }, description: 'Attendee email addresses' }, - location: { type: 'string', description: 'Event location' }, - }, - required: ['summary', 'start', 'end'], - }, - }, - { - name: 'calendar_update_event', - description: 'Update an existing calendar event.', - inputSchema: { - type: 'object', - properties: { - eventId: { type: 'string', description: 'Event ID' }, - summary: { type: 'string', description: 'New event title' }, - description: { type: 'string', description: 'New description' }, - start: { type: 'string', description: 'New start time' }, - end: { type: 'string', description: 'New end time' }, - }, - required: ['eventId'], - }, - }, - { - name: 'calendar_delete_event', - description: 'Delete a calendar event.', - inputSchema: { - type: 'object', - properties: { - eventId: { type: 'string', description: 'Event ID to delete' }, - sendNotifications: { type: 'boolean', default: true, description: 'Notify attendees' }, - }, - required: ['eventId'], - }, - }, - - // === Gmail Tools === - { - name: 'gmail_search', - description: 'Search emails in Gmail.', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Gmail search query (supports from:, to:, subject:, has:attachment, etc.)' }, - maxResults: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - labelIds: { type: 'array', items: { type: 'string' }, description: 'Filter by label IDs' }, - }, - required: ['query'], - }, - }, - { - name: 'gmail_read_message', - description: 'Read a specific email message.', - inputSchema: { - type: 'object', - properties: { - messageId: { type: 'string', description: 'Message ID' }, - format: { type: 'string', enum: ['full', 'metadata', 'minimal'], default: 'full' }, - }, - required: ['messageId'], - }, - }, - { - name: 'gmail_send', - description: 'Send an email.', - inputSchema: { - type: 'object', - properties: { - to: { type: 'array', items: { type: 'string' }, description: 'Recipient email addresses' }, - cc: { type: 'array', items: { type: 'string' }, description: 'CC recipients' }, - bcc: { type: 'array', items: { type: 'string' }, description: 'BCC recipients' }, - subject: { type: 'string', description: 'Email subject' }, - body: { type: 'string', description: 'Email body (plain text or HTML)' }, - isHtml: { type: 'boolean', default: false, description: 'Whether body is HTML' }, - replyToMessageId: { type: 'string', description: 'Message ID to reply to' }, - }, - required: ['to', 'subject', 'body'], - }, - }, - { - name: 'gmail_create_draft', - description: 'Create an email draft.', - inputSchema: { - type: 'object', - properties: { - to: { type: 'array', items: { type: 'string' }, description: 'Recipient email addresses' }, - subject: { type: 'string', description: 'Email subject' }, - body: { type: 'string', description: 'Email body' }, - }, - required: ['to', 'subject', 'body'], - }, - }, - - // === Jira Tools === - { - name: 'jira_search_issues', - description: 'Search for Jira issues using JQL.', - inputSchema: { - type: 'object', - properties: { - jql: { type: 'string', description: 'JQL query (e.g., "project = PROJ AND status = Open")' }, - maxResults: { type: 'integer', minimum: 1, maximum: 100, default: 50 }, - fields: { type: 'array', items: { type: 'string' }, description: 'Fields to return' }, - }, - required: ['jql'], - }, - }, - { - name: 'jira_get_issue', - description: 'Get details of a specific Jira issue.', - inputSchema: { - type: 'object', - properties: { - issueKey: { type: 'string', description: 'Issue key (e.g., "PROJ-123")' }, - expand: { type: 'array', items: { type: 'string' }, description: 'Fields to expand (e.g., ["changelog", "comments"])' }, - }, - required: ['issueKey'], - }, - }, - { - name: 'jira_create_issue', - description: 'Create a new Jira issue.', - inputSchema: { - type: 'object', - properties: { - projectKey: { type: 'string', description: 'Project key' }, - issueType: { type: 'string', description: 'Issue type (Bug, Task, Story, Epic)' }, - summary: { type: 'string', description: 'Issue summary/title' }, - description: { type: 'string', description: 'Issue description' }, - priority: { type: 'string', enum: ['Highest', 'High', 'Medium', 'Low', 'Lowest'], default: 'Medium' }, - assignee: { type: 'string', description: 'Assignee username' }, - labels: { type: 'array', items: { type: 'string' }, description: 'Issue labels' }, - }, - required: ['projectKey', 'issueType', 'summary'], - }, - }, - { - name: 'jira_update_issue', - description: 'Update an existing Jira issue.', - inputSchema: { - type: 'object', - properties: { - issueKey: { type: 'string', description: 'Issue key' }, - fields: { type: 'object', description: 'Fields to update' }, - transition: { type: 'string', description: 'Transition to apply (e.g., "Done", "In Progress")' }, - }, - required: ['issueKey'], - }, - }, - { - name: 'jira_add_comment', - description: 'Add a comment to a Jira issue.', - inputSchema: { - type: 'object', - properties: { - issueKey: { type: 'string', description: 'Issue key' }, - body: { type: 'string', description: 'Comment text' }, - }, - required: ['issueKey', 'body'], - }, - }, - - // === GitHub Tools === - { - name: 'github_search_repos', - description: 'Search GitHub repositories.', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query' }, - sort: { type: 'string', enum: ['stars', 'forks', 'updated'], default: 'stars' }, - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - }, - required: ['query'], - }, - }, - { - name: 'github_list_issues', - description: 'List issues in a GitHub repository.', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string', description: 'Repository owner' }, - repo: { type: 'string', description: 'Repository name' }, - state: { type: 'string', enum: ['open', 'closed', 'all'], default: 'open' }, - labels: { type: 'array', items: { type: 'string' }, description: 'Filter by labels' }, - limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, - }, - required: ['owner', 'repo'], - }, - }, - { - name: 'github_create_issue', - description: 'Create a new GitHub issue.', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string', description: 'Repository owner' }, - repo: { type: 'string', description: 'Repository name' }, - title: { type: 'string', description: 'Issue title' }, - body: { type: 'string', description: 'Issue body' }, - labels: { type: 'array', items: { type: 'string' }, description: 'Labels to apply' }, - assignees: { type: 'array', items: { type: 'string' }, description: 'Assignee usernames' }, - }, - required: ['owner', 'repo', 'title'], - }, - }, - { - name: 'github_list_prs', - description: 'List pull requests in a GitHub repository.', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string', description: 'Repository owner' }, - repo: { type: 'string', description: 'Repository name' }, - state: { type: 'string', enum: ['open', 'closed', 'all'], default: 'open' }, - limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, - }, - required: ['owner', 'repo'], - }, - }, -]; - -// Tool handlers -async function handleTool(name: string, args: Record): Promise { - const timestamp = now(); - - switch (name) { - // Google Drive - case 'gdrive_search': { - const query = (args.query || '').toLowerCase(); - const limit = args.limit || 10; - const results = fakeFiles.filter(f => - f.name.toLowerCase().includes(query) || - f.owner.toLowerCase().includes(query) - ).slice(0, limit); - return { success: true, files: results, totalResults: results.length, query: args.query }; - } - - case 'gdrive_read_file': { - const file = fakeFiles.find(f => f.id === args.fileId); - if (!file) return { success: false, error: `File not found: ${args.fileId}` }; - return { - success: true, - file: file, - content: `[Simulated content for ${file.name}]\n\nThis is placeholder content representing the file "${file.name}".\nIn a real implementation, this would contain the actual file contents.`, - }; - } - - case 'gdrive_create_file': { - const newFile = { - id: generateId('FILE'), - name: args.name, - mimeType: args.mimeType || 'text/plain', - size: (args.content || '').length, - modifiedTime: timestamp, - owner: 'you@company.com', - }; - return { success: true, file: newFile, message: 'File created successfully' }; - } - - case 'gdrive_share_file': { - return { - success: true, - fileId: args.fileId, - sharedWith: args.email, - role: args.role || 'reader', - permissionId: generateId('PERM'), - message: `File shared with ${args.email} as ${args.role || 'reader'}`, - }; - } - - // Google Sheets - case 'sheets_read': { - const sheet = fakeSpreadsheets[args.spreadsheetId]; - if (!sheet) return { success: false, error: `Spreadsheet not found: ${args.spreadsheetId}` }; - const sheetData = sheet.sheets[0]; - return { - success: true, - spreadsheetId: args.spreadsheetId, - range: args.range, - values: sheetData.data, - majorDimension: 'ROWS', - }; - } - - case 'sheets_write': { - return { - success: true, - spreadsheetId: args.spreadsheetId, - updatedRange: args.range, - updatedRows: (args.values || []).length, - updatedColumns: (args.values?.[0] || []).length, - updatedCells: (args.values || []).flat().length, - }; - } - - case 'sheets_append': { - return { - success: true, - spreadsheetId: args.spreadsheetId, - tableRange: args.range, - updates: { - updatedRange: `${args.range.split('!')[0]}!A${Math.floor(Math.random() * 100) + 10}`, - updatedRows: (args.values || []).length, - updatedCells: (args.values || []).flat().length, - }, - }; - } - - case 'sheets_create': { - const newId = generateId('SHEET'); - return { - success: true, - spreadsheetId: newId, - spreadsheetUrl: `https://docs.google.com/spreadsheets/d/${newId}`, - title: args.title, - sheets: (args.sheetNames || ['Sheet1']).map((name: string, i: number) => ({ - sheetId: i, - title: name, - })), - }; - } - - // Salesforce - case 'salesforce_query': { - const soql = (args.soql || '').toLowerCase(); - let records: any[] = []; - - if (soql.includes('account')) { - records = fakeCompanies.map(c => ({ Id: c.id, Name: c.name, Industry: c.industry, AnnualRevenue: c.revenue })); - } else if (soql.includes('opportunity')) { - records = fakeOpportunities.map(o => ({ Id: o.id, Name: o.name, StageName: o.stage, Amount: o.amount, CloseDate: o.closeDate })); - } else if (soql.includes('contact') || soql.includes('user')) { - records = fakeUsers.map(u => ({ Id: u.id, Name: u.name, Email: u.email, Department: u.department })); - } - - return { - success: true, - totalSize: records.length, - done: true, - records: records.slice(0, args.limit || 100), - }; - } - - case 'salesforce_get_record': { - let record: any = null; - if (args.objectType === 'Account') { - record = fakeCompanies.find(c => c.id === args.recordId); - } else if (args.objectType === 'Opportunity') { - record = fakeOpportunities.find(o => o.id === args.recordId); - } - if (!record) return { success: false, error: `Record not found: ${args.recordId}` }; - return { success: true, record }; - } - - case 'salesforce_create_record': { - const newId = generateId(args.objectType?.substring(0, 3).toUpperCase() || 'REC'); - return { - success: true, - id: newId, - objectType: args.objectType, - message: `${args.objectType} created successfully`, - }; - } - - case 'salesforce_update_record': { - return { - success: true, - id: args.recordId, - objectType: args.objectType, - updatedFields: Object.keys(args.data || {}), - message: `${args.objectType} updated successfully`, - }; - } - - case 'salesforce_search': { - const term = (args.searchTerm || '').toLowerCase(); - const results: any[] = []; - fakeCompanies.filter(c => c.name.toLowerCase().includes(term)).forEach(c => results.push({ type: 'Account', ...c })); - fakeUsers.filter(u => u.name.toLowerCase().includes(term)).forEach(u => results.push({ type: 'Contact', ...u })); - return { success: true, searchRecords: results.slice(0, args.limit || 20) }; - } - - // Slack - case 'slack_send_message': { - return { - success: true, - ok: true, - channel: args.channel, - ts: `${Date.now() / 1000}.000100`, - message: { text: args.text, user: 'U001', ts: `${Date.now() / 1000}.000100` }, - }; - } - - case 'slack_get_messages': { - const channelId = args.channel.startsWith('#') ? fakeSlackChannels.find(c => c.name === args.channel.slice(1))?.id : args.channel; - const messages = fakeSlackMessages.filter(m => m.channel === channelId).slice(0, args.limit || 20); - return { success: true, ok: true, messages, hasMore: false }; - } - - case 'slack_search_messages': { - const query = (args.query || '').toLowerCase(); - const matches = fakeSlackMessages.filter(m => m.text.toLowerCase().includes(query)); - return { - success: true, - ok: true, - query: args.query, - messages: { total: matches.length, matches: matches.slice(0, args.limit || 20) }, - }; - } - - case 'slack_list_channels': { - return { success: true, ok: true, channels: fakeSlackChannels }; - } - - case 'slack_get_user_info': { - const user = fakeUsers.find(u => u.id === args.userId); - if (!user) return { success: false, ok: false, error: 'user_not_found' }; - return { success: true, ok: true, user: { ...user, realName: user.name, displayName: user.name.split(' ')[0] } }; - } - - case 'slack_set_status': { - return { - success: true, - ok: true, - profile: { - statusText: args.statusText, - statusEmoji: args.statusEmoji || ':speech_balloon:', - statusExpiration: args.expirationMinutes ? Date.now() + args.expirationMinutes * 60000 : 0, - }, - }; - } - - // Calendar - case 'calendar_list_events': { - return { success: true, items: fakeCalendarEvents }; - } - - case 'calendar_create_event': { - const newEvent = { - id: generateId('EVT'), - summary: args.summary, - description: args.description, - start: args.start, - end: args.end, - attendees: args.attendees || [], - htmlLink: `https://calendar.google.com/event?eid=${generateId('E')}`, - }; - return { success: true, event: newEvent }; - } - - case 'calendar_update_event': { - return { - success: true, - event: { - id: args.eventId, - ...(args.summary && { summary: args.summary }), - ...(args.description && { description: args.description }), - ...(args.start && { start: args.start }), - ...(args.end && { end: args.end }), - updated: timestamp, - }, - }; - } - - case 'calendar_delete_event': { - return { success: true, deleted: true, eventId: args.eventId }; - } - - // Gmail - case 'gmail_search': { - const query = (args.query || '').toLowerCase(); - const results = fakeEmails.filter(e => - e.subject.toLowerCase().includes(query) || - e.from.toLowerCase().includes(query) || - e.snippet.toLowerCase().includes(query) - ); - return { success: true, messages: results.slice(0, args.maxResults || 10), resultSizeEstimate: results.length }; - } - - case 'gmail_read_message': { - const email = fakeEmails.find(e => e.id === args.messageId); - if (!email) return { success: false, error: `Message not found: ${args.messageId}` }; - return { - success: true, - message: { - ...email, - body: `Full body of email: "${email.subject}"\n\n${email.snippet}\n\n[Additional content would appear here in a real implementation]`, - }, - }; - } - - case 'gmail_send': { - return { - success: true, - id: generateId('MSG'), - threadId: generateId('THR'), - labelIds: ['SENT'], - message: `Email sent to ${(args.to || []).join(', ')}`, - }; - } - - case 'gmail_create_draft': { - return { - success: true, - id: generateId('DRF'), - message: { id: generateId('MSG'), threadId: generateId('THR') }, - }; - } - - // Jira - case 'jira_search_issues': { - const issues = [ - { key: 'PROJ-101', summary: 'Implement user authentication', status: 'In Progress', priority: 'High', assignee: 'alice' }, - { key: 'PROJ-102', summary: 'Fix login page CSS', status: 'Open', priority: 'Medium', assignee: 'emma' }, - { key: 'PROJ-103', summary: 'Add API rate limiting', status: 'Done', priority: 'High', assignee: 'alice' }, - ]; - return { success: true, issues, total: issues.length, maxResults: args.maxResults || 50 }; - } - - case 'jira_get_issue': { - return { - success: true, - key: args.issueKey, - fields: { - summary: `Issue ${args.issueKey}`, - status: { name: 'In Progress' }, - priority: { name: 'High' }, - assignee: { displayName: 'Alice Johnson' }, - description: 'Detailed description of the issue...', - created: '2026-01-15T10:00:00Z', - updated: timestamp, - }, - }; - } - - case 'jira_create_issue': { - const issueKey = `${args.projectKey}-${Math.floor(Math.random() * 900) + 100}`; - return { - success: true, - id: generateId(''), - key: issueKey, - self: `https://your-domain.atlassian.net/rest/api/2/issue/${issueKey}`, - }; - } - - case 'jira_update_issue': { - return { success: true, key: args.issueKey, updated: true }; - } - - case 'jira_add_comment': { - return { - success: true, - id: generateId('CMT'), - issueKey: args.issueKey, - body: args.body, - created: timestamp, - }; - } - - // GitHub - case 'github_search_repos': { - const repos = [ - { fullName: 'facebook/react', description: 'A declarative UI library', stars: 220000, language: 'JavaScript' }, - { fullName: 'microsoft/vscode', description: 'Visual Studio Code', stars: 155000, language: 'TypeScript' }, - { fullName: 'torvalds/linux', description: 'Linux kernel source tree', stars: 165000, language: 'C' }, - ]; - return { success: true, totalCount: repos.length, items: repos.slice(0, args.limit || 10) }; - } - - case 'github_list_issues': { - const issues = [ - { number: 1234, title: 'Bug in component rendering', state: 'open', labels: ['bug'], user: 'contributor1' }, - { number: 1235, title: 'Feature request: dark mode', state: 'open', labels: ['enhancement'], user: 'contributor2' }, - ]; - return { success: true, issues }; - } - - case 'github_create_issue': { - return { - success: true, - number: Math.floor(Math.random() * 9000) + 1000, - title: args.title, - htmlUrl: `https://github.com/${args.owner}/${args.repo}/issues/${Math.floor(Math.random() * 9000) + 1000}`, - }; - } - - case 'github_list_prs': { - const prs = [ - { number: 567, title: 'Fix memory leak in worker', state: 'open', user: 'dev1', draft: false }, - { number: 568, title: 'Add TypeScript support', state: 'open', user: 'dev2', draft: true }, - ]; - return { success: true, pullRequests: prs }; - } - - default: - return { error: `Unknown tool: ${name}` }; - } -} - -// Server setup -const server = new Server( - { name: 'mcp-harness', version: '1.0.0' }, - { capabilities: { tools: {} } } -); - -server.setRequestHandler(ListToolsRequestSchema, async () => { - return { tools }; -}); - -server.setRequestHandler(CallToolRequestSchema, async (request) => { - const toolName = request.params.name; - const args = (request.params.arguments || {}) as Record; - - const result = await handleTool(toolName, args); - - // Log the tool call - logToolCall(toolName, args, result); - - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; -}); - -const transport = new StdioServerTransport(); -await server.connect(transport); diff --git a/evals/open-model-gym/mcp-harness/tsconfig.json b/evals/open-model-gym/mcp-harness/tsconfig.json deleted file mode 100644 index 486fed81ccce..000000000000 --- a/evals/open-model-gym/mcp-harness/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "declaration": true - }, - "include": ["src/**/*"] -} diff --git a/evals/open-model-gym/suite/.gitignore b/evals/open-model-gym/suite/.gitignore deleted file mode 100644 index 5dc418c7c199..000000000000 --- a/evals/open-model-gym/suite/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules/ -.workdir/ -.goose-root/ -.cache/ diff --git a/evals/open-model-gym/suite/package-lock.json b/evals/open-model-gym/suite/package-lock.json deleted file mode 100644 index 90443a050db8..000000000000 --- a/evals/open-model-gym/suite/package-lock.json +++ /dev/null @@ -1,1059 +0,0 @@ -{ - "name": "agent-runner", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "agent-runner", - "version": "0.1.0", - "dependencies": { - "glob": "^11.0.0", - "yaml": "^2.8.3" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "tsx": "^4.22.4", - "typescript": "^5.5.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@types/node": { - "version": "22.19.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", - "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/glob": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", - "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", - "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/minimatch": { - "version": "10.2.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", - "integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - } - } -} diff --git a/evals/open-model-gym/suite/package.json b/evals/open-model-gym/suite/package.json deleted file mode 100644 index f6771dc4c448..000000000000 --- a/evals/open-model-gym/suite/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "agent-runner", - "version": "0.1.0", - "type": "module", - "scripts": { - "build": "tsc", - "test": "tsx src/runner.ts", - "test:scenario": "tsx src/runner.ts --scenario" - }, - "dependencies": { - "glob": "^11.0.0", - "yaml": "^2.8.3" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "tsx": "^4.22.4", - "typescript": "^5.5.0" - } -} diff --git a/evals/open-model-gym/suite/scenarios/everyday-app-automation.yaml b/evals/open-model-gym/suite/scenarios/everyday-app-automation.yaml deleted file mode 100644 index 8e919a5e8b70..000000000000 --- a/evals/open-model-gym/suite/scenarios/everyday-app-automation.yaml +++ /dev/null @@ -1,47 +0,0 @@ -name: everyday-app-automation -description: Multi-step workflow using everyday app tools (Slack, Jira, Calendar) with data dependencies -prompt: | - Using the available tools, complete these tasks: - 1. Search Slack for messages mentioning "quarterly review" - 2. Look up the user who posted the message about quarterly review to get their full name - 3. Create a Jira issue titled "Q1 Review Follow-ups" with a description that includes the name of the person who posted the Slack message - 4. Create a calendar event for next Monday at 2pm called "Review Discussion" - 5. Write a summary of what you did to a file called workflow-log.md - -tags: - - complex - - multi-step - - mcp-harness - - data-flow - -validate: - # Check workflow summary was written - - type: file_exists - path: workflow-log.md - - type: file_not_empty - path: workflow-log.md - - # Check Slack search was called with the right query - - type: tool_called - tool: slack_search_messages - args: - query: /quarterly.?review/ - - # Check that the agent looked up the user info (data dependency: requires reading user ID from search results) - - type: tool_called - tool: slack_get_user_info - args: - userId: /U004/ - - # Check Jira issue was created with expected title and includes the user's name (data dependency: requires reading name from user info) - - type: tool_called - tool: jira_create_issue - args: - summary: /q1.?review|follow.?up/ - description: /David.?Brown/ - - # Check calendar event was created with expected title - - type: tool_called - tool: calendar_create_event - args: - summary: /review.?discussion/ diff --git a/evals/open-model-gym/suite/scenarios/file-editing.yaml b/evals/open-model-gym/suite/scenarios/file-editing.yaml deleted file mode 100644 index 5eb1d818ebef..000000000000 --- a/evals/open-model-gym/suite/scenarios/file-editing.yaml +++ /dev/null @@ -1,110 +0,0 @@ -name: file-editing -description: Navigate a small codebase and make a targeted edit -prompt: | - The User struct in user.rs is missing a display_name() method. - Add a method that returns the full name formatted as "first_name last_name". - -tags: - - file-editing - - code-navigation - -setup: - Cargo.toml: | - [package] - name = "user-service" - version = "0.1.0" - edition = "2021" - - [workspace] - - src/main.rs: | - mod models; - mod utils; - - use models::user::User; - - fn main() { - let user = User::new("Alice", "Smith", "alice@example.com"); - println!("Created user: {}", user.email()); - } - - src/models/mod.rs: | - pub mod user; - - src/models/user.rs: | - pub struct User { - first_name: String, - last_name: String, - email: String, - } - - impl User { - pub fn new(first_name: &str, last_name: &str, email: &str) -> Self { - Self { - first_name: first_name.to_string(), - last_name: last_name.to_string(), - email: email.to_string(), - } - } - - pub fn email(&self) -> &str { - &self.email - } - - pub fn first_name(&self) -> &str { - &self.first_name - } - - pub fn last_name(&self) -> &str { - &self.last_name - } - } - - src/utils/mod.rs: | - pub mod formatting; - - src/utils/formatting.rs: | - pub fn capitalize(s: &str) -> String { - let mut chars = s.chars(); - match chars.next() { - None => String::new(), - Some(c) => c.to_uppercase().collect::() + chars.as_str(), - } - } - -validate: - # The edit was made to the correct file - - type: file_exists - path: src/models/user.rs - name: user.rs exists - # Method was added - - type: file_matches - path: src/models/user.rs - regex: "fn\\s+display_name" - name: display_name() added - # Method returns a String or &str - - type: file_matches - path: src/models/user.rs - regex: "display_name.*->.*String|display_name.*->.*str" - name: has return type - # Original code preserved - - type: file_contains - path: src/models/user.rs - pattern: "pub fn email" - name: email() preserved - - type: file_contains - path: src/models/user.rs - pattern: "pub fn first_name" - name: first_name() preserved - # Other files untouched - - type: file_exists - path: src/main.rs - name: main.rs exists - - type: file_contains - path: src/main.rs - pattern: "mod models" - name: main.rs unchanged - # Code compiles - - type: command_succeeds - command: "cargo build" - name: cargo build diff --git a/evals/open-model-gym/suite/scenarios/multi-turn-edit.yaml b/evals/open-model-gym/suite/scenarios/multi-turn-edit.yaml deleted file mode 100644 index 03798a85ca6b..000000000000 --- a/evals/open-model-gym/suite/scenarios/multi-turn-edit.yaml +++ /dev/null @@ -1,80 +0,0 @@ -name: multi-turn-edit -description: Multi-turn conversation - add a method, then rename it - -tags: - - file-editing - - multi-turn - -setup: - Cargo.toml: | - [package] - name = "user-service" - version = "0.1.0" - edition = "2021" - - [workspace] - - src/main.rs: | - mod models; - - use models::user::User; - - fn main() { - let user = User::new("Alice", "Smith"); - println!("User: {} {}", user.first_name(), user.last_name()); - } - - src/models/mod.rs: | - pub mod user; - - src/models/user.rs: | - pub struct User { - first_name: String, - last_name: String, - } - - impl User { - pub fn new(first_name: &str, last_name: &str) -> Self { - Self { - first_name: first_name.to_string(), - last_name: last_name.to_string(), - } - } - - pub fn first_name(&self) -> &str { - &self.first_name - } - - pub fn last_name(&self) -> &str { - &self.last_name - } - } - -turns: - - prompt: | - Add an email() method to the User struct in src/models/user.rs. - It should return a generated email in the format "first_name.last_name@example.com" (lowercase). - validate: - - type: file_matches - path: src/models/user.rs - regex: "fn\\s+email" - name: email() added - - type: command_succeeds - command: "cargo build" - name: compiles after turn 1 - - - prompt: | - Actually, can you rename the email() method to generated_email() instead? - Make sure to update any references. - validate: - - type: file_matches - path: src/models/user.rs - regex: "fn\\s+generated_email" - name: renamed to generated_email() - - type: file_not_matches - path: src/models/user.rs - regex: "fn\\s+email\\s*\\(" - name: old email() removed - - type: command_succeeds - command: "cargo build" - name: compiles after turn 2 diff --git a/evals/open-model-gym/suite/scenarios/remove-feature.yaml b/evals/open-model-gym/suite/scenarios/remove-feature.yaml deleted file mode 100644 index 292815a98750..000000000000 --- a/evals/open-model-gym/suite/scenarios/remove-feature.yaml +++ /dev/null @@ -1,926 +0,0 @@ -name: remove-feature -description: Remove a feature (insert_image) from a document editor tool module -prompt: | - Remove the `insert_image` feature entirely from this codebase. - It should be removed from docs.py and any references in doc_utils.py. - Make sure no traces remain: the operation literal, the ImageParams class, - the image_params parameter, the insert_image handler function, and any - validation/error handling specific to insert_image should all be removed. - -tags: - - file-editing - - feature-removal - - python - -setup: - docs.py: | - """Tools for interacting with documents.""" - - import json - from dataclasses import dataclass - from functools import partial - from typing import Annotated, Any, Literal - - from pydantic import Field - - from doc_utils import execute_request, execute_requests - - DOC_OPERATIONS = Literal[ - "get_document", - "insert_text", - "append_text", - "replace_text", - "delete_content", - "insert_table", - "update_table_cell", - "insert_table_row", - "insert_table_column", - "delete_table_row", - "delete_table_column", - "insert_image", - "format_existing_text", - ] - - RESPONSE_CHAR_LIMIT = 400000 - - - @dataclass - class FormatTextParams: - """Parameters for format_existing_text operation.""" - - search_text: str - foreground_color: str | None = None - background_color: str | None = None - bold: bool | None = None - italic: bool | None = None - underline: bool | None = None - strikethrough: bool | None = None - font_size: int | None = None - font_family: str | None = None - heading_level: int | None = None - link_url: str | None = None - list_type: str | None = None - - - @dataclass - class TableParams: - """Parameters for table operations. - - Used by operations: insert_table, update_table_cell, insert_table_row, - insert_table_column, delete_table_row, delete_table_column. - """ - - rows: int | None = Field(None, description="Number of rows for insert_table operation") - columns: int | None = Field(None, description="Number of columns for insert_table operation") - row_index: int | None = Field( - None, description="Row index (0-based) for update_table_cell, insert_table_row, and delete_table_row" - ) - column_index: int | None = Field( - None, description="Column index (0-based) for update_table_cell, insert_table_column, and delete_table_column" - ) - insert_below: bool = Field( - False, description="For insert_table_row: True to insert below the specified row, False to insert above" - ) - insert_right: bool = Field( - False, description="For insert_table_column: True to insert right of column, False to insert left" - ) - - - @dataclass - class ImageParams: - """Parameters for image insertion.""" - - image_url: str = Field(..., description="URL of the image to insert (must be publicly accessible)") - width: int | None = Field(None, description="Width of the image in points (PT)") - height: int | None = Field(None, description="Height of the image in points (PT)") - - - async def doc_tool( - document_id: str, - operation: DOC_OPERATIONS = "get_document", - text: Annotated[ - str, - Field( - description=( - "Text content to insert, append, or match for replacement. " - "For insert_text and append_text, Markdown formatting is supported. " - "For replace_text, this should be unformatted plain text." - ) - ), - ] = "", - replace_text: Annotated[ - str, - Field( - description=( - "New plain text that will replace all occurrences of the original text. " - "Only used for the replace_text operation." - ) - ), - ] = "", - start_position: Annotated[ - int | None, - Field( - description="Document index (1-based) for insert or delete operations.", - ge=1, - ), - ] = None, - end_position: Annotated[ - int | None, - Field( - description="Document index (1-based, exclusive) for delete_content", - ge=1, - ), - ] = None, - table_params: Annotated[ - TableParams | None, - Field( - None, - description="Parameters for table operations", - ), - ] = None, - image_params: ImageParams | None = None, - format_params: FormatTextParams | None = None, - ) -> str: - """Perform operations on an existing document. - - Supported operations: - - get_document: Returns document content - - insert_text: Inserts text at a specific position - - append_text: Appends text at the end of the document - - replace_text: Replaces all instances of text with replace_text - - delete_content: Deletes content between two positions - - insert_table: Creates a table with specified rows and columns - - update_table_cell: Updates content in a specific table cell - - insert_table_row: Inserts a row above or below the specified row - - insert_table_column: Inserts a column left or right of the specified column - - delete_table_row: Deletes the specified row from a table - - delete_table_column: Deletes the specified column from a table - - insert_image: Inserts an image from a URL at the specified position - - format_existing_text: Finds and applies formatting to text - """ - if not text and operation in ["insert_text", "append_text", "replace_text"]: - raise ValueError(f"text is required for {operation} operation") - - table_operations = [ - "insert_table", - "update_table_cell", - "insert_table_row", - "insert_table_column", - "delete_table_row", - "delete_table_column", - ] - if operation in table_operations and not table_params: - raise ValueError(f"table_params is required for {operation} operation") - - if operation == "insert_image" and not image_params: - raise ValueError("image_params is required for insert_image operation") - - if operation == "format_existing_text" and not format_params: - raise ValueError("format_params is required for format_existing_text operation") - - operation_handlers = { - "get_document": partial(read_document, document_id), - "insert_text": partial(insert_text, document_id, text, start_position), - "append_text": partial(append_text, document_id, text), - "replace_text": partial(replace_all_text, document_id, text, replace_text), - "delete_content": partial(delete_content, document_id, start_position, end_position), - "insert_table": partial( - insert_table, - document_id, - table_params.rows if table_params else None, - table_params.columns if table_params else None, - start_position, - ), - "update_table_cell": partial( - update_table_cell, - document_id, - table_params.row_index if table_params else None, - table_params.column_index if table_params else None, - text, - start_position, - ), - "insert_table_row": partial( - modify_table_structure, - document_id, - "insert_row", - table_params.row_index if table_params else None, - None, - table_params.insert_below if table_params else False, - False, - start_position, - ), - "insert_table_column": partial( - modify_table_structure, - document_id, - "insert_column", - None, - table_params.column_index if table_params else None, - False, - table_params.insert_right if table_params else False, - start_position, - ), - "delete_table_row": partial( - modify_table_structure, - document_id, - "delete_row", - table_params.row_index if table_params else None, - None, - False, - False, - start_position, - ), - "delete_table_column": partial( - modify_table_structure, - document_id, - "delete_column", - None, - table_params.column_index if table_params else None, - False, - False, - start_position, - ), - "insert_image": partial( - insert_image, - document_id, - image_params.image_url if image_params else None, - start_position, - image_params.width if image_params else None, - image_params.height if image_params else None, - ), - "format_existing_text": partial( - format_existing_text, - document_id, - format_params.search_text if format_params else None, - format_params.foreground_color if format_params else None, - format_params.background_color if format_params else None, - format_params.font_size if format_params else None, - format_params.font_family if format_params else None, - format_params.bold if format_params else None, - format_params.italic if format_params else None, - format_params.underline if format_params else None, - format_params.strikethrough if format_params else None, - format_params.heading_level if format_params else None, - format_params.link_url if format_params else None, - format_params.list_type if format_params else None, - ), - } - - if operation not in operation_handlers: - raise ValueError(f"Invalid operation: {operation}") - - response = await operation_handlers[operation]() - return json.dumps(response, indent=2) - - - def _extract_text_from_element(element: dict) -> str: - """Recursively pull text from paragraphs, tables, etc.""" - text_parts = "" - if "paragraph" in element: - paragraph_elements = element["paragraph"].get("elements", []) - for el in paragraph_elements: - if "textRun" in el: - content = el["textRun"]["content"] - url = el["textRun"].get("textStyle", {}).get("link", {}).get("url") - if url: - text_parts += f"[{content}]({url})" - else: - text_parts += content - elif "table" in element: - table_rows = element["table"].get("tableRows", []) - for row in table_rows: - for cell in row["tableCells"]: - for cell_content in cell["content"]: - text_parts += _extract_text_from_element(cell_content) - text_parts += "\n" - return text_parts - - - async def read_document(document_id: str) -> dict[str, Any]: - """Returns document content.""" - document = await execute_request("get", document_id, {}) - content = document.get("body", {}).get("content", []) - text = "".join(_extract_text_from_element(e) for e in content) - - result = {"content": text, "document_id": document_id} - - if len(json.dumps(result)) > RESPONSE_CHAR_LIMIT: - raise ValueError(f"Document {document_id} is too large to read.") - - return result - - - async def insert_text( - document_id: str, text: str, start_position: int | None - ) -> dict[str, Any]: - """Insert text at a specific index in a document.""" - if start_position is None: - raise ValueError("start_position is required for insert_text operation") - - request = {"insertText": {"location": {"index": start_position}, "text": text}} - await execute_request("update", document_id, request) - - from doc_utils import calculate_utf16_length - - inserted_length = calculate_utf16_length(text) - - return { - "message": f"Inserted text at position {start_position}", - "text": text, - "start_position": start_position, - "end_position": start_position + inserted_length, - } - - - async def append_text(document_id: str, text: str) -> dict[str, Any]: - """Append text to the end of a document.""" - end_index = await get_document_last_index(document_id) - if text[0] != "\n": - text = "\n" + text - response = await insert_text(document_id, text, end_index - 1) - response["message"] = "Appended text to the end of the document" - return response - - - async def replace_all_text( - document_id: str, text: str, replace_text: str - ) -> dict[str, str]: - """Replace all instances of a string in a document.""" - if not replace_text: - raise ValueError("replace_text parameter is required for replace_text operation") - - request = { - "replaceAllText": { - "containsText": {"text": text, "matchCase": True}, - "replaceText": replace_text, - } - } - - response = await execute_request("update", document_id, request) - occurrences = response.get("occurrencesChanged", 0) - if occurrences == 0: - return { - "message": f"No occurrences of text '{text}' found in the document.", - "text": text, - "replace_text": replace_text, - } - - return { - "message": f"Replaced {occurrences} occurrences of '{text}' with '{replace_text}'", - "text": text, - "replace_text": replace_text, - } - - - async def delete_content( - document_id: str, start_index: int | None, end_index: int | None - ) -> dict[str, Any]: - """Delete content between two positions.""" - if start_index is None or end_index is None: - raise ValueError("Both start_index and end_index are required for delete_content") - - request = { - "deleteContentRange": { - "range": {"startIndex": start_index, "endIndex": end_index} - } - } - await execute_request("update", document_id, request) - return { - "message": f"Deleted content between index {start_index} and {end_index}", - "start_index": start_index, - "end_index": end_index, - } - - - async def get_document_last_index(document_id: str) -> int: - """Get the last index of the document.""" - document = await execute_request("get", document_id, {}) - return document.get("body", {}).get("content", [])[-1].get("endIndex", 1) - - - async def insert_table( - document_id: str, rows: int | None, columns: int | None, start_position: int | None - ) -> str: - """Insert a table at a specific position.""" - if not rows or not columns: - raise ValueError("rows and columns are required for insert_table operation") - if start_position is None: - raise ValueError("start_position is required for insert_table operation") - - request = { - "insertTable": { - "rows": rows, - "columns": columns, - "location": {"index": start_position}, - } - } - await execute_request("update", document_id, request) - return f"Inserted {rows}x{columns} table at position {start_position}" - - - def _table_matches_position(element: dict, start_position: int | None) -> bool: - """Check if a table element contains the specified position.""" - if start_position is None: - return True - table_start = element.get("startIndex") - table_end = element.get("endIndex") - return table_start <= start_position < table_end - - - def _find_table_cell_range( - document: dict, row_index: int, column_index: int, start_position: int | None = None - ) -> tuple[int, int] | None: - """Find the content range within a table cell.""" - content = document.get("body", {}).get("content", []) - for element in content: - if "table" in element: - if not _table_matches_position(element, start_position): - continue - table = element["table"] - if row_index < len(table.get("tableRows", [])): - row = table["tableRows"][row_index] - if column_index < len(row.get("tableCells", [])): - cell = row["tableCells"][column_index] - cell_content = cell.get("content", []) - for cell_element in cell_content: - if "paragraph" in cell_element: - para_start = cell_element.get("startIndex") - para_end = cell_element.get("endIndex") - return (para_start, para_end) - cell_start = cell.get("startIndex") - cell_end = cell.get("endIndex") - if cell_start is not None and cell_end is not None: - return (cell_start + 1, cell_end - 1) - return None - - - def _find_table_start_index( - document: dict, row_index: int, column_index: int, start_position: int | None = None - ) -> int | None: - """Find the start index of a table element.""" - content = document.get("body", {}).get("content", []) - for element in content: - if "table" in element: - if not _table_matches_position(element, start_position): - continue - table = element["table"] - if row_index < len(table.get("tableRows", [])): - row = table["tableRows"][row_index] - if column_index < len(row.get("tableCells", [])): - return element.get("startIndex") - return None - - - async def update_table_cell( - document_id: str, - row_index: int | None, - column_index: int | None, - text: str, - start_position: int | None = None, - ) -> str: - """Update content in a specific table cell.""" - if row_index is None or column_index is None: - raise ValueError("row_index and column_index are required for update_table_cell") - - document = await execute_request("get", document_id, {}) - cell_range = _find_table_cell_range(document, row_index, column_index, start_position) - if not cell_range: - raise ValueError(f"Table cell at row {row_index}, col {column_index} not found") - - cell_start, cell_end = cell_range - - requests = [] - if cell_end > cell_start + 1: - requests.append( - {"deleteContentRange": {"range": {"startIndex": cell_start, "endIndex": cell_end - 1}}} - ) - - requests.append({"insertText": {"location": {"index": cell_start}, "text": text}}) - - await execute_requests(document_id, requests) - return f"Updated cell at row {row_index}, column {column_index}" - - - async def modify_table_structure( - document_id: str, - operation: str, - row_index: int | None = None, - column_index: int | None = None, - insert_below: bool = False, - insert_right: bool = False, - start_position: int | None = None, - ) -> str: - """Modify table structure by inserting or deleting rows/columns.""" - if operation in ["insert_row", "delete_row"] and row_index is None: - raise ValueError(f"row_index is required for {operation} operation") - if operation in ["insert_column", "delete_column"] and column_index is None: - raise ValueError(f"column_index is required for {operation} operation") - - document = await execute_request("get", document_id, {}) - - if operation in ["insert_row", "delete_row"]: - table_start = _find_table_start_index(document, row_index, 0, start_position) - if not table_start: - raise ValueError(f"Table row {row_index} not found") - location = {"tableStartLocation": {"index": table_start}, "rowIndex": row_index} - else: - table_start = _find_table_start_index(document, 0, column_index, start_position) - if not table_start: - raise ValueError(f"Table column {column_index} not found") - location = {"tableStartLocation": {"index": table_start}, "columnIndex": column_index} - - operation_map = { - "insert_row": "insertTableRow", - "insert_column": "insertTableColumn", - "delete_row": "deleteTableRow", - "delete_column": "deleteTableColumn", - } - request_key = operation_map[operation] - - if operation == "insert_row": - request = {request_key: {"tableCellLocation": location, "insertBelow": insert_below}} - message = f"Inserted row {'below' if insert_below else 'above'} row {row_index}" - elif operation == "insert_column": - request = {request_key: {"tableCellLocation": location, "insertRight": insert_right}} - message = f"Inserted column {'right of' if insert_right else 'left of'} column {column_index}" - elif operation == "delete_row": - request = {request_key: {"tableCellLocation": location}} - message = f"Deleted row {row_index}" - else: - request = {request_key: {"tableCellLocation": location}} - message = f"Deleted column {column_index}" - - await execute_request("update", document_id, request) - return message - - - async def insert_image( - document_id: str, - image_url: str | None, - start_position: int | None, - width: int | None, - height: int | None, - ) -> str: - """Insert an image from a URL at the specified position.""" - if not image_url: - raise ValueError("image_url is required for insert_image operation") - if start_position is None: - raise ValueError("start_position is required for insert_image operation") - - request = { - "insertInlineImage": { - "uri": image_url, - "location": {"index": start_position}, - } - } - - if width or height: - object_size = {} - if width: - object_size["width"] = {"magnitude": width, "unit": "PT"} - if height: - object_size["height"] = {"magnitude": height, "unit": "PT"} - request["insertInlineImage"]["objectSize"] = object_size - - await execute_request("update", document_id, request) - return f"Inserted image from {image_url} at position {start_position}" - - - async def format_existing_text( - document_id: str, - search_text: str | None, - foreground_color: str | None, - background_color: str | None, - font_size: int | None, - font_family: str | None, - bold: bool | None, - italic: bool | None, - underline: bool | None, - strikethrough: bool | None, - heading_level: int | None, - link_url: str | None, - list_type: str | None, - ) -> str: - """Find and apply formatting to all occurrences of text.""" - if not search_text: - raise ValueError("search_text is required for format_existing_text operation") - - document = await execute_request("get", document_id, {}) - - from doc_utils import build_text_style, find_text_positions - - positions = find_text_positions(document, search_text) - - if not positions: - return f"No occurrences of '{search_text}' found" - - text_style, fields = build_text_style( - foreground_color=foreground_color, - background_color=background_color, - font_size=font_size, - font_family=font_family, - bold=bold, - italic=italic, - underline=underline, - strikethrough=strikethrough, - link_url=link_url, - ) - - if not fields and not heading_level and not list_type: - raise ValueError("At least one formatting option must be specified") - - positions.sort(reverse=True) - - requests = [] - for start, end in positions: - range_dict = {"startIndex": start, "endIndex": end} - - if fields: - requests.append( - {"updateTextStyle": {"range": range_dict, "textStyle": text_style, "fields": fields}} - ) - - if heading_level: - if not (1 <= heading_level <= 6): - raise ValueError("heading_level must be between 1 and 6") - requests.append( - { - "updateParagraphStyle": { - "range": range_dict, - "paragraphStyle": {"namedStyleType": f"HEADING_{heading_level}"}, - "fields": "namedStyleType", - } - } - ) - - if list_type: - if list_type == "bullet": - preset = "BULLET_DISC_CIRCLE_SQUARE" - elif list_type == "numbered": - preset = "NUMBERED_DECIMAL_ALPHA_ROMAN" - else: - raise ValueError("list_type must be 'bullet' or 'numbered'") - requests.append({"createParagraphBullets": {"range": range_dict, "bulletPreset": preset}}) - - if not requests: - raise ValueError(f"No formatting requests generated for '{search_text}'.") - - await execute_requests(document_id, requests) - return f"Formatted {len(positions)} occurrences of '{search_text}'" - - doc_utils.py: | - """Utility functions for document operations.""" - - import re - from typing import Any - - - async def execute_request( - method: str, document_id: str, request: dict, **kwargs - ) -> dict[str, Any]: - """Execute a single document API request.""" - raise NotImplementedError("Stub: would call document backend") - - - async def execute_requests( - document_id: str, requests: list[dict], **kwargs - ) -> dict[str, Any]: - """Execute a batch of document API requests.""" - raise NotImplementedError("Stub: would call document backend") - - - def calculate_utf16_length(text: str) -> int: - """Calculate text length in UTF-16 code units.""" - return len(text.encode("utf-16-le")) // 2 - - - def hex_to_rgb(hex_color: str) -> dict: - """Convert hex color to RGB format (0.0-1.0 range).""" - hex_color = hex_color.lstrip("#") - - if len(hex_color) == 3: - hex_color = "".join([c * 2 for c in hex_color]) - - if not re.match(r"^[0-9A-Fa-f]{6}$", hex_color): - raise ValueError(f"Invalid hex color: {hex_color}") - - return { - "color": { - "rgbColor": { - "red": int(hex_color[0:2], 16) / 255.0, - "green": int(hex_color[2:4], 16) / 255.0, - "blue": int(hex_color[4:6], 16) / 255.0, - } - } - } - - - def build_text_style( - foreground_color: str | None = None, - background_color: str | None = None, - font_size: int | None = None, - font_family: str | None = None, - bold: bool | None = None, - italic: bool | None = None, - underline: bool | None = None, - strikethrough: bool | None = None, - link_url: str | None = None, - ) -> tuple[dict, str]: - """Build text style object and field mask from provided formatting options.""" - style = {} - fields = [] - - if foreground_color: - style["foregroundColor"] = hex_to_rgb(foreground_color) - fields.append("foregroundColor") - - if background_color: - style["backgroundColor"] = hex_to_rgb(background_color) - fields.append("backgroundColor") - - if font_size is not None: - style["fontSize"] = {"magnitude": font_size, "unit": "PT"} - fields.append("fontSize") - - if font_family: - style["weightedFontFamily"] = {"fontFamily": font_family} - fields.append("weightedFontFamily") - - if bold is not None: - style["bold"] = bold - fields.append("bold") - - if italic is not None: - style["italic"] = italic - fields.append("italic") - - if underline is not None: - style["underline"] = underline - fields.append("underline") - - if strikethrough is not None: - style["strikethrough"] = strikethrough - fields.append("strikethrough") - - if link_url: - style["link"] = {"url": link_url} - fields.append("link") - - return style, ",".join(fields) - - - def find_text_positions( - document: dict, search_text: str - ) -> list[tuple[int, int]]: - """Find all occurrences of text in a document with UTF-16 positions.""" - positions = [] - search_len = calculate_utf16_length(search_text) - - content = document.get("body", {}).get("content", []) - _find_text_in_elements(content, search_text, search_len, positions) - - return positions - - - def _find_text_in_elements( - elements: list[dict], search_text: str, search_len: int, positions: list - ): - """Recursively find text in document elements.""" - for element in elements: - if "paragraph" in element: - para_elements = element["paragraph"].get("elements", []) - for elem in para_elements: - if "textRun" in elem: - text_run = elem["textRun"] - content = text_run.get("content", "") - start_index = elem.get("startIndex", 0) - - idx = 0 - while True: - pos = content.find(search_text, idx) - if pos == -1: - break - prefix_len = calculate_utf16_length(content[:pos]) - match_start = start_index + prefix_len - match_end = match_start + search_len - positions.append((match_start, match_end)) - idx = pos + len(search_text) - - elif "table" in element: - table_rows = element["table"].get("tableRows", []) - for row in table_rows: - for cell in row.get("tableCells", []): - cell_content = cell.get("content", []) - _find_text_in_elements(cell_content, search_text, search_len, positions) - -validate: - # docs.py must still exist - - type: file_exists - path: docs.py - name: docs.py exists - - # doc_utils.py must still exist - - type: file_exists - path: doc_utils.py - name: doc_utils.py exists - - # insert_image removed from the operations literal - - type: file_not_matches - path: docs.py - regex: "insert_image" - name: insert_image removed from operations literal - - # ImageParams class removed - - type: file_not_matches - path: docs.py - regex: "ImageParams" - name: ImageParams class removed - - # image_params parameter removed - - type: file_not_matches - path: docs.py - regex: "image_params" - name: image_params parameter removed - - # insert_image function removed - - type: file_not_matches - path: docs.py - regex: "async def insert_image" - name: insert_image function removed - - # image_url reference removed - - type: file_not_matches - path: docs.py - regex: "image_url" - name: image_url references removed - - # insertInlineImage reference removed - - type: file_not_matches - path: docs.py - regex: "insertInlineImage" - name: insertInlineImage reference removed - - # Other operations still present - - type: file_contains - path: docs.py - pattern: "get_document" - name: get_document preserved - - - type: file_contains - path: docs.py - pattern: "insert_text" - name: insert_text preserved - - - type: file_contains - path: docs.py - pattern: "replace_text" - name: replace_text preserved - - - type: file_contains - path: docs.py - pattern: "insert_table" - name: insert_table preserved - - - type: file_contains - path: docs.py - pattern: "format_existing_text" - name: format_existing_text preserved - - - type: file_contains - path: docs.py - pattern: "async def doc_tool" - name: doc_tool function preserved - - - type: file_contains - path: docs.py - pattern: "FormatTextParams" - name: FormatTextParams preserved - - - type: file_contains - path: docs.py - pattern: "TableParams" - name: TableParams preserved - - # doc_utils.py should be untouched (no image references existed there) - - type: file_contains - path: doc_utils.py - pattern: "def build_text_style" - name: doc_utils build_text_style preserved - - - type: file_contains - path: doc_utils.py - pattern: "def find_text_positions" - name: doc_utils find_text_positions preserved - - - type: file_contains - path: doc_utils.py - pattern: "def calculate_utf16_length" - name: doc_utils calculate_utf16_length preserved - - # Python syntax check - - type: command_succeeds - command: "python3 -c \"import ast; ast.parse(open('docs.py').read())\"" - name: docs.py valid python syntax - - - type: command_succeeds - command: "python3 -c \"import ast; ast.parse(open('doc_utils.py').read())\"" - name: doc_utils.py valid python syntax diff --git a/evals/open-model-gym/suite/src/gym.png b/evals/open-model-gym/suite/src/gym.png deleted file mode 100644 index 242cf3be7e8e..000000000000 Binary files a/evals/open-model-gym/suite/src/gym.png and /dev/null differ diff --git a/evals/open-model-gym/suite/src/runner.ts b/evals/open-model-gym/suite/src/runner.ts deleted file mode 100644 index 62ebff6f9b5d..000000000000 --- a/evals/open-model-gym/suite/src/runner.ts +++ /dev/null @@ -1,1565 +0,0 @@ -#!/usr/bin/env node -import { mkdirSync, writeFileSync, rmSync, readdirSync, existsSync, copyFileSync } from "node:fs"; -import { join, basename, dirname, resolve } from "node:path"; -import { homedir } from "node:os"; -import { execSync, execFileSync } from "node:child_process"; -import { parse, stringify } from "yaml"; -import { readFileSync } from "node:fs"; -import { createHash } from "node:crypto"; -import type { Scenario, TestResult, TestRun, Turn } from "./types.js"; -import { validateAll } from "./validator.js"; - -// ============================================================================= -// Types -// ============================================================================= - -type RunnerType = "goose" | "opencode" | "pi"; - -interface ModelConfig { - name: string; - provider: string; - model: string; -} - -interface RunnerConfig { - name: string; - type: RunnerType; - bin: string; - extensions?: string[]; // goose-specific - stdio?: string[]; // MCP servers -} - -interface MatrixEntry { - scenario: string; - models?: string[]; // omit = all models - runners?: string[]; // omit = all runners -} - -interface SuiteConfig { - models: ModelConfig[]; - runners: RunnerConfig[]; - matrix?: MatrixEntry[]; -} - -// A test pair: scenario × model × runner -interface TestPair { - scenario: Scenario; - model: ModelConfig; - runner: RunnerConfig; -} - -interface TestResultWithLog extends TestResult { - logFile: string; - runnerName: string; - toolCalls: number; - turns: number; - cached?: boolean; -} - -// ============================================================================= -// Cache Types -// ============================================================================= - -interface CacheInputs { - scenarioHash: string; - modelKey: string; - runnerHash: string; - binaryHash: string; - mcpHarnessHash: string; - timeoutMs: number; -} - -interface CacheEntry { - timestamp: string; - inputs: CacheInputs; - result: { - status: "passed" | "failed"; - validations: Array<{ rule: any; passed: boolean; message?: string }>; - duration: number; - toolCalls: number; - turns: number; - errors?: string[]; - }; - logFile: string; -} - -interface CacheIndex { - version: number; - entries: Record; -} - -// ============================================================================= -// Output directory resolution -// ============================================================================= -// All run artifacts (cache, isolated agent config roots, scratch workdir, logs, -// and the HTML report) live under a single base directory. By default this is -// the in-repo gym directory, so existing behavior is unchanged. Set the -// GYM_OUTPUT_DIR env var or pass --output-dir= to redirect everything -// outside the repo and keep your checkout clean, e.g.: -// -// GYM_OUTPUT_DIR=~/.goose/gym-runs/$(date +%Y%d%m%H%M%S) just run -// -// config.yaml and scenarios/ are inputs and always read from the repo. - -const SUITE_DIR = join(import.meta.dirname, ".."); // .../open-model-gym/suite -const GYM_DIR = join(import.meta.dirname, "../.."); // .../open-model-gym - -function expandHome(p: string): string { - return p === "~" || p.startsWith("~/") ? join(homedir(), p.slice(1)) : p; -} - -// Resolved output base, or null to fall back to the legacy in-repo locations. -const OUTPUT_DIR: string | null = (() => { - const flag = process.argv - .find((a) => a.startsWith("--output-dir=")) - ?.split("=")[1]; - const base = flag ?? process.env.GYM_OUTPUT_DIR; - // Resolve to an absolute path: runners exec with cwd set to the workdir, so a - // relative base would make prompt/log paths resolve against the wrong dir. - return base ? resolve(expandHome(base)) : null; -})(); - -// Resolve an artifact path under OUTPUT_DIR when set, else its legacy anchor. -function artifactPath(name: string, legacyAnchor: string): string { - return join(OUTPUT_DIR ?? legacyAnchor, name); -} - -// Agent timeout -// ============================================================================= -// Per-invocation timeout for an agent run, in milliseconds. Larger local models -// can exceed the old fixed 5-minute cap on heavier scenarios and get killed -// mid-task (recorded as a failure rather than a timeout). Override the cap with -// GYM_AGENT_TIMEOUT (seconds) or --agent-timeout=; default 300s. -const AGENT_TIMEOUT_MS = (() => { - const flag = process.argv - .find((a) => a.startsWith("--agent-timeout=")) - ?.split("=")[1]; - const secs = parseInt(flag ?? process.env.GYM_AGENT_TIMEOUT ?? "", 10); - return (Number.isFinite(secs) && secs > 0 ? secs : 300) * 1000; -})(); - -// ============================================================================= -// Cache Utilities -// ============================================================================= - -const CACHE_DIR = artifactPath(".cache", SUITE_DIR); -const CACHE_INDEX_PATH = join(CACHE_DIR, "index.json"); -const CACHE_LOGS_DIR = join(CACHE_DIR, "logs"); -const CACHE_VERSION = 1; - -function sha256(data: string | Buffer): string { - return createHash("sha256").update(data).digest("hex").slice(0, 16); -} - -function loadCache(): CacheIndex { - try { - if (existsSync(CACHE_INDEX_PATH)) { - const data = JSON.parse(readFileSync(CACHE_INDEX_PATH, "utf-8")); - if (data.version === CACHE_VERSION) { - return data; - } - console.log("Cache version mismatch, starting fresh"); - } - } catch (e) { - console.log("Cache corrupted, starting fresh"); - } - return { version: CACHE_VERSION, entries: {} }; -} - -function saveCache(cache: CacheIndex): void { - mkdirSync(CACHE_DIR, { recursive: true }); - writeFileSync(CACHE_INDEX_PATH, JSON.stringify(cache, null, 2)); -} - -function getBinaryHash(binName: string): string { - try { - const binaryPath = execSync(`which ${binName}`, { encoding: "utf-8" }).trim(); - const binaryContent = readFileSync(binaryPath); - return sha256(binaryContent); - } catch (e) { - // Fallback to version string if we can't read the binary - try { - const version = execSync(`${binName} --version 2>/dev/null || echo "unknown"`, { encoding: "utf-8" }).trim(); - return sha256(version); - } catch { - return "unknown"; - } - } -} - -function getMcpHarnessHash(): string { - const mcpHarnessPath = join(import.meta.dirname, "../../mcp-harness/dist/index.js"); - try { - if (existsSync(mcpHarnessPath)) { - return sha256(readFileSync(mcpHarnessPath)); - } - } catch (e) { - // Ignore - } - return "no-mcp-harness"; -} - -function computeCacheKey(pair: TestPair, binaryHashes: Map, mcpHarnessHash: string): { key: string; inputs: CacheInputs } { - // Hash scenario content (name + prompt/turns + setup + validate) - const scenarioContent = stringify({ - name: pair.scenario.name, - prompt: pair.scenario.prompt, - turns: pair.scenario.turns, - setup: pair.scenario.setup, - validate: pair.scenario.validate, - }); - const scenarioHash = sha256(scenarioContent); - - // Model key - const modelKey = `${pair.model.provider}/${pair.model.model}`; - - // Hash runner config - const runnerContent = JSON.stringify({ - name: pair.runner.name, - type: pair.runner.type, - extensions: pair.runner.extensions ?? [], - stdio: pair.runner.stdio ?? [], - }); - const runnerHash = sha256(runnerContent); - - // Binary hash (cached per binary name) - const binaryHash = binaryHashes.get(pair.runner.bin) ?? "unknown"; - - const inputs: CacheInputs = { - scenarioHash, - modelKey, - runnerHash, - binaryHash, - mcpHarnessHash, - timeoutMs: AGENT_TIMEOUT_MS, - }; - - // Combine all into single key. The timeout is included so a result cached - // under a short timeout (e.g. a 300s ETIMEDOUT) isn't reused when the run is - // retried with a larger GYM_AGENT_TIMEOUT. - const key = sha256( - scenarioHash + modelKey + runnerHash + binaryHash + mcpHarnessHash + AGENT_TIMEOUT_MS, - ); - - return { key, inputs }; -} - -function getCachedResult( - cache: CacheIndex, - cacheKey: string, - pair: TestPair, - logsDir: string -): TestResultWithLog | null { - const entry = cache.entries[cacheKey]; - if (!entry) return null; - - // Verify the cached log file exists - const cachedLogPath = join(CACHE_LOGS_DIR, entry.logFile); - if (!existsSync(cachedLogPath)) { - console.log(` Cache log missing, will re-run`); - delete cache.entries[cacheKey]; - return null; - } - - // Copy cached log to current logs directory - const testId = `${pair.scenario.name}_${pair.model.name}_${pair.runner.name}`.replace(/[\/\\:]/g, "_"); - const logFile = join(logsDir, `${testId}_cached.log`); - mkdirSync(logsDir, { recursive: true }); - copyFileSync(cachedLogPath, logFile); - - // Reconstruct result - const config = { - provider: pair.model.provider, - model: pair.model.model, - extensions: pair.runner.extensions, - stdio: pair.runner.stdio, - }; - - const run: TestRun = { - scenario: pair.scenario, - config, - workdir: "", // Not relevant for cached results - startTime: new Date(entry.timestamp), - endTime: new Date(new Date(entry.timestamp).getTime() + entry.result.duration), - status: entry.result.status, - errors: entry.result.errors, - }; - - return { - run, - validations: entry.result.validations, - logFile, - runnerName: pair.runner.name, - toolCalls: entry.result.toolCalls, - turns: entry.result.turns, - cached: true, - }; -} - -function storeCacheResult( - cache: CacheIndex, - cacheKey: string, - inputs: CacheInputs, - result: TestResultWithLog -): void { - // Copy log to cache directory - const logFileName = `${cacheKey}.log`; - const cachedLogPath = join(CACHE_LOGS_DIR, logFileName); - mkdirSync(CACHE_LOGS_DIR, { recursive: true }); - - try { - copyFileSync(result.logFile, cachedLogPath); - } catch (e) { - console.log(` Warning: Could not cache log file`); - return; - } - - cache.entries[cacheKey] = { - timestamp: new Date().toISOString(), - inputs, - result: { - status: result.run.status as "passed" | "failed", - validations: result.validations, - duration: result.run.endTime && result.run.startTime - ? result.run.endTime.getTime() - result.run.startTime.getTime() - : 0, - toolCalls: result.toolCalls, - turns: result.turns, - errors: result.run.errors, - }, - logFile: logFileName, - }; - - saveCache(cache); -} - -function clearCache(): void { - if (existsSync(CACHE_DIR)) { - rmSync(CACHE_DIR, { recursive: true, force: true }); - console.log("Cache cleared"); - } else { - console.log("No cache to clear"); - } -} - -// ============================================================================= -// Goose Runner -// ============================================================================= - -const PLATFORM_EXTENSIONS = new Set([ - "todo", "skills", "code_execution", "extensionmanager", - "chatrecall", "apps", "imagegenerator" -]); - -// Isolated goose config directory -const GOOSE_ROOT = artifactPath(".goose-root", SUITE_DIR); -const GOOSE_CONFIG_DIR = join(GOOSE_ROOT, "config"); - -function generateGooseConfig(model: ModelConfig, runner: RunnerConfig): object { - const extensions: Record = {}; - - // Add extensions (detect platform vs builtin) - for (const ext of runner.extensions ?? []) { - if (PLATFORM_EXTENSIONS.has(ext)) { - extensions[ext] = { - enabled: true, - type: "platform", - name: ext, - bundled: true, - }; - } else { - extensions[ext] = { - enabled: true, - type: "builtin", - name: ext, - timeout: 300, - bundled: true, - }; - } - } - - // Add stdio MCP servers - for (const extCmd of runner.stdio ?? []) { - const parts = extCmd.split(" "); - const cmd = parts[0]; - const args = parts.slice(1); - const name = basename(args[args.length - 1] || cmd).replace(/\.[^.]+$/, ""); - - extensions[name] = { - enabled: true, - type: "stdio", - name, - cmd, - args, - timeout: 300, - }; - } - - return { - extensions, - GOOSE_PROVIDER: model.provider, - GOOSE_MODEL: model.model, - GOOSE_TELEMETRY_ENABLED: false, - }; -} - -async function runGooseAgent( - model: ModelConfig, - runner: RunnerConfig, - prompt: string, - workdir: string, - sessionName?: string, // If provided, use/continue this session - resume: boolean = false // If true, resume existing session (for turn 2+) -): Promise { - const promptFile = join(workdir, ".goose-prompt.txt"); - writeFileSync(promptFile, prompt); - - // Write goose config - mkdirSync(GOOSE_CONFIG_DIR, { recursive: true }); - const gooseConfig = generateGooseConfig(model, runner); - writeFileSync(join(GOOSE_CONFIG_DIR, "config.yaml"), stringify(gooseConfig)); - - let cmd: string; - if (sessionName) { - if (resume) { - cmd = `${runner.bin} run -i "${promptFile}" --name "${sessionName}" --resume`; - console.log(` Running: ${runner.bin} run -i --name "${sessionName}" --resume`); - } else { - // First turn: create new session with this name - cmd = `${runner.bin} run -i "${promptFile}" --name "${sessionName}"`; - console.log(` Running: ${runner.bin} run -i --name "${sessionName}"`); - } - } else { - cmd = `${runner.bin} run -i "${promptFile}" --no-session`; - console.log(` Running: ${runner.bin} run -i --no-session`); - } - - const output = execSync(cmd, { - cwd: workdir, - env: { - ...process.env, - GOOSE_PATH_ROOT: GOOSE_ROOT, - MCP_HARNESS_LOG: join(workdir, "tool-calls.log"), - }, - timeout: AGENT_TIMEOUT_MS, - encoding: "utf-8", - }); - - return output; -} - -// ============================================================================= -// OpenCode Runner -// ============================================================================= - -// Isolated opencode config directory -const OPENCODE_ROOT = artifactPath(".opencode-root", SUITE_DIR); - -function generateOpenCodeConfig(model: ModelConfig, runner: RunnerConfig, workdir: string): object { - const mcp: Record = {}; - - // Add stdio MCP servers - for (const extCmd of runner.stdio ?? []) { - const parts = extCmd.split(" "); - const cmd = parts[0]; - const args = parts.slice(1); - const name = basename(args[args.length - 1] || cmd).replace(/\.[^.]+$/, ""); - - mcp[name] = { - type: "local", - command: [cmd, ...args], - enabled: true, - environment: { - MCP_HARNESS_LOG: join(workdir, "tool-calls.log"), - }, - }; - } - - const config: Record = { - $schema: "https://opencode.ai/config.json", - mcp, - }; - - // Handle ollama as a custom provider (OpenCode doesn't have built-in ollama support) - if (model.provider === "ollama") { - config.model = `ollama/${model.model}`; - config.provider = { - ollama: { - npm: "@ai-sdk/openai-compatible", - name: "Ollama (local)", - options: { - baseURL: "http://localhost:11434/v1", - }, - models: { - [model.model]: { - name: model.name, - }, - }, - }, - }; - } else { - // Standard providers (anthropic, openai, etc.) - config.model = `${model.provider}/${model.model}`; - } - - return config; -} - -async function runOpenCodeAgent( - model: ModelConfig, - runner: RunnerConfig, - prompt: string, - workdir: string, - resume: boolean = false -): Promise { - // Write opencode.json config to workdir - const openCodeConfig = generateOpenCodeConfig(model, runner, workdir); - writeFileSync(join(workdir, "opencode.json"), JSON.stringify(openCodeConfig, null, 2)); - - // Write prompt to file (use cat to avoid shell escaping issues) - const promptFile = join(workdir, ".opencode-prompt.txt"); - writeFileSync(promptFile, prompt); - - // Ensure isolated config directory exists - mkdirSync(OPENCODE_ROOT, { recursive: true }); - - // Use --continue on turn 2+ to continue last session - const continueFlag = resume ? "--continue " : ""; - const cmd = `${runner.bin} run ${continueFlag}"$(cat "${promptFile}")"`; - console.log(` Running: ${runner.bin} run ${continueFlag}""`); - - const output = execSync(cmd, { - cwd: workdir, - env: { - ...process.env, - XDG_CONFIG_HOME: OPENCODE_ROOT, - XDG_DATA_HOME: OPENCODE_ROOT, - }, - timeout: AGENT_TIMEOUT_MS, - encoding: "utf-8", - shell: "/bin/bash", - }); - - return output; -} - - -// ============================================================================= -// Pi Runner -// ============================================================================= - -// Pi takes --provider and --model as CLI arguments -// MCP support via pi-mcp-adapter: `pi install npm:pi-mcp-adapter` - -// Isolated Pi config directory (like Goose/OpenCode) -const PI_CONFIG_DIR = artifactPath(".pi-root", SUITE_DIR); - -// User's real Pi config (for copying auth.json) -const PI_USER_CONFIG = join(homedir(), ".pi", "agent"); - -/** - * Generate models.json for Pi with the test model. - * For ollama models, we need to define them since Pi doesn't have built-in ollama support. - */ -function generatePiModelsConfig(model: ModelConfig): object { - // Only generate config for ollama provider (others are built-in) - if (model.provider !== "ollama") { - return { providers: {} }; - } - - return { - providers: { - ollama: { - baseUrl: "http://localhost:11434/v1", - api: "openai-completions", - apiKey: "ollama", // Ollama doesn't need a real key - models: [ - { - id: model.model, - name: model.name, - reasoning: false, - input: ["text"], - contextWindow: 128000, - maxTokens: 32768, - compat: { - supportsUsageInStreaming: false, - maxTokensField: "max_tokens", - supportsDeveloperRole: false - } - } - ] - } - } - }; -} - -async function runPiAgent( - model: ModelConfig, - runner: RunnerConfig, - prompt: string, - workdir: string, - sessionName?: string, // If provided, use/continue this session (for multi-turn) - resume: boolean = false // If true, continue existing session (for turn 2+) -): Promise { - // Write prompt to file (use cat to avoid shell escaping issues) - const promptFile = join(workdir, ".pi-prompt.txt"); - writeFileSync(promptFile, prompt); - - // Set up isolated Pi config directory - mkdirSync(PI_CONFIG_DIR, { recursive: true }); - - // Generate models.json with the test model (for ollama) - const modelsConfig = generatePiModelsConfig(model); - writeFileSync(join(PI_CONFIG_DIR, "models.json"), JSON.stringify(modelsConfig, null, 2)); - - // Copy auth.json from user's config (for API keys) - const userAuthPath = join(PI_USER_CONFIG, "auth.json"); - if (existsSync(userAuthPath)) { - copyFileSync(userAuthPath, join(PI_CONFIG_DIR, "auth.json")); - } - - // Copy settings.json from user's config (for installed packages like pi-mcp-adapter) - const userSettingsPath = join(PI_USER_CONFIG, "settings.json"); - if (existsSync(userSettingsPath)) { - copyFileSync(userSettingsPath, join(PI_CONFIG_DIR, "settings.json")); - } - - // If runner has stdio MCP servers, write .pi/mcp.json to the workdir (project config) - // pi-mcp-adapter checks for .pi/mcp.json in cwd, which overrides global config - let hasMcp = false; - if (runner.stdio?.length) { - const mcpConfig: { - mcpServers: Record; - }>; - settings: { toolPrefix: string }; - } = { - mcpServers: {}, - settings: { - toolPrefix: "none" // No prefix - use raw tool names - } - // Proxy mode: LLM uses mcp({ search: "..." }) to discover tools on-demand - // This scales better with many MCP tools vs directTools which burns context - }; - - // Add each stdio server from runner config - runner.stdio.forEach((extCmd, i) => { - const parts = extCmd.split(" "); - const serverName = `harness${i > 0 ? i : ''}`; - mcpConfig.mcpServers[serverName] = { - command: parts[0], - args: parts.slice(1), - lifecycle: "eager", // Connect at startup for tests - env: { - MCP_HARNESS_LOG: join(workdir, "tool-calls.log") - } - }; - }); - - // Write .pi/mcp.json to workdir (project-local config that pi-mcp-adapter finds) - const piConfigDir = join(workdir, ".pi"); - mkdirSync(piConfigDir, { recursive: true }); - writeFileSync(join(piConfigDir, "mcp.json"), JSON.stringify(mcpConfig, null, 2)); - hasMcp = true; - } - - // Build base command with provider/model - // -p = non-interactive (print mode) - let cmd = `${runner.bin} -p --provider ${model.provider} --model "${model.model}"`; - - // Session handling for multi-turn - if (sessionName) { - const sessionPath = join(workdir, `.pi-session-${sessionName}.jsonl`); - if (resume) { - // Turn 2+: continue the existing session - cmd += ` --continue --session "${sessionPath}"`; - } else { - // Turn 1: create a new session file - cmd += ` --session "${sessionPath}"`; - } - } else { - // Single-turn: don't save session - cmd += ` --no-session`; - } - - cmd += ` "$(cat "${promptFile}")"`; - - // Build log message - const sessionInfo = sessionName - ? (resume ? ` --continue --session ` : ` --session `) - : ` --no-session`; - console.log(` Running: ${runner.bin} -p${sessionInfo} --provider ${model.provider} --model "${model.model}"${hasMcp ? ' (mcp)' : ''} ""`); - - const output = execSync(cmd, { - cwd: workdir, - env: { - ...process.env, - PI_CODING_AGENT_DIR: PI_CONFIG_DIR, // Use isolated config dir - MCP_HARNESS_LOG: join(workdir, "tool-calls.log"), - }, - timeout: AGENT_TIMEOUT_MS, - encoding: "utf-8", - shell: "/bin/bash", - }); - - return output; -} - -// ============================================================================= -// Unified Runner -// ============================================================================= - -interface AgentResult { - output: string; - sessionId?: string; // For multi-turn (goose, pi) -} - -async function runAgent( - model: ModelConfig, - runner: RunnerConfig, - prompt: string, - workdir: string, - sessionId?: string, // For multi-turn (goose, pi) - resume: boolean = false // For multi-turn: true on turn 2+ -): Promise { - if (runner.type === "opencode") { - const output = await runOpenCodeAgent(model, runner, prompt, workdir, resume); - return { output }; - } - if (runner.type === "pi") { - const output = await runPiAgent(model, runner, prompt, workdir, sessionId, resume); - return { output, sessionId }; - } - const output = await runGooseAgent(model, runner, prompt, workdir, sessionId, resume); - return { output, sessionId }; -} - -// ============================================================================= -// Scenario & Config Loading -// ============================================================================= - -function loadScenario(path: string): Scenario { - const content = readFileSync(path, "utf-8"); - return parse(content) as Scenario; -} - -function loadAllScenarios(dir: string): Scenario[] { - const files = readdirSync(dir).filter((f) => f.endsWith(".yaml")); - return files.map((f) => loadScenario(join(dir, f))); -} - -function loadConfig(configPath: string): SuiteConfig { - const content = readFileSync(configPath, "utf-8"); - const config = parse(content) as SuiteConfig; - const configDir = join(configPath, ".."); - - // Resolve relative paths in stdio for all runners - for (const runner of config.runners) { - if (runner.stdio) { - runner.stdio = runner.stdio.map((ext) => { - const parts = ext.split(" "); - const cmd = parts[0]; - const args = parts.slice(1).map((arg) => { - if (!arg.startsWith("/") && (arg.includes("/") || arg.startsWith("."))) { - return join(configDir, arg); - } - return arg; - }); - return [cmd, ...args].join(" "); - }); - } - } - - return config; -} - -function setupWorkdir(scenario: Scenario, workdir: string): void { - rmSync(workdir, { recursive: true, force: true }); - mkdirSync(workdir, { recursive: true }); - - if (scenario.setup) { - for (const [path, content] of Object.entries(scenario.setup)) { - const fullPath = join(workdir, path); - mkdirSync(join(fullPath, ".."), { recursive: true }); - writeFileSync(fullPath, content); - } - } -} - -// ============================================================================= -// Log Metrics Parsing -// ============================================================================= - -function parseLogMetrics(logContent: string, workdir?: string): { toolCalls: number; turns: number } { - // First, try to read tool-calls.log from MCP harness (most accurate) - let mcpToolCalls = 0; - if (workdir) { - try { - const toolCallsLog = readFileSync(join(workdir, "tool-calls.log"), "utf-8"); - // Each line is a JSON object representing one tool call - mcpToolCalls = toolCallsLog.trim().split("\n").filter(line => line.trim()).length; - } catch (e) { - // tool-calls.log doesn't exist, fall back to log parsing - } - } - - // Goose format: ─── tool_name | extension ─── - const gooseToolCalls = (logContent.match(/─── .+ \| .+ ───/g) || []).length; - - // OpenCode format: TURN N - const opencodeTurns = (logContent.match(/^TURN \d+$/gm) || []).length; - - // Total tool calls = MCP harness calls + Goose built-in tool calls - const toolCalls = mcpToolCalls + gooseToolCalls; - - // For OpenCode, use explicit TURN markers - const turns = opencodeTurns > 0 ? opencodeTurns : Math.ceil(toolCalls / 3); // Estimate ~3 tool calls per turn - - return { toolCalls, turns }; -} - -// ============================================================================= -// Test Execution -// ============================================================================= - -function buildTestPairs(config: SuiteConfig, scenarios: Scenario[]): TestPair[] { - const modelsByName = new Map(config.models.map((m) => [m.name, m])); - const runnersByName = new Map(config.runners.map((r) => [r.name, r])); - const scenariosByName = new Map(scenarios.map((s) => [s.name, s])); - - const pairs: TestPair[] = []; - - if (config.matrix?.length) { - for (const entry of config.matrix) { - // Validate scenario name - const scenario = scenariosByName.get(entry.scenario); - if (!scenario) { - throw new Error(`Unknown scenario "${entry.scenario}" in matrix. Available: ${[...scenariosByName.keys()].join(", ")}`); - } - - // Validate model names - if (entry.models) { - for (const name of entry.models) { - if (!modelsByName.has(name)) { - throw new Error(`Unknown model "${name}" in matrix entry for scenario "${entry.scenario}". Available: ${[...modelsByName.keys()].join(", ")}`); - } - } - } - - // Validate runner names - if (entry.runners) { - for (const name of entry.runners) { - if (!runnersByName.has(name)) { - throw new Error(`Unknown runner "${name}" in matrix entry for scenario "${entry.scenario}". Available: ${[...runnersByName.keys()].join(", ")}`); - } - } - } - - const models = entry.models - ? entry.models.map((n) => modelsByName.get(n)).filter(Boolean) as ModelConfig[] - : config.models; - - const runners = entry.runners - ? entry.runners.map((n) => runnersByName.get(n)).filter(Boolean) as RunnerConfig[] - : config.runners; - - for (const model of models) { - for (const runner of runners) { - pairs.push({ scenario, model, runner }); - } - } - } - return pairs; - } - - // No matrix: all scenarios × all models × all runners - for (const scenario of scenarios) { - for (const model of config.models) { - for (const runner of config.runners) { - pairs.push({ scenario, model, runner }); - } - } - } - return pairs; -} - -function scoreResult(result: TestResultWithLog): number { - if (result.run.status === "failed" && result.run.errors?.length) { - return -1; - } - const passedCount = result.validations.filter((v) => v.passed).length; - const statusBonus = result.run.status === "passed" ? 1000 : 0; - return statusBonus + passedCount; -} - -async function runScenario( - pair: TestPair, - baseWorkdir: string, - logsDir: string, - attempt: number = 1 -): Promise { - const { scenario, model, runner } = pair; - const testId = `${scenario.name}_${model.name}_${runner.name}`.replace(/[\/\\:]/g, "_"); - const workdir = join(baseWorkdir, testId); - const logFile = join(logsDir, `${testId}_attempt${attempt}.log`); - - console.log(`\n▶ ${scenario.name} [${model.provider}/${model.model}] (${runner.name})`); - - setupWorkdir(scenario, workdir); - mkdirSync(logsDir, { recursive: true }); - - // Create a minimal config for TestRun compatibility - const config = { - provider: model.provider, - model: model.model, - extensions: runner.extensions, - stdio: runner.stdio, - }; - - const run: TestRun = { - scenario, - config, - workdir, - startTime: new Date(), - status: "running", - }; - - // Determine if this is a multi-turn or single-turn scenario - const turns = scenario.turns ?? [ - { prompt: scenario.prompt!, validate: scenario.validate ?? [] } - ]; - const isMultiTurn = turns.length > 1; - - // For goose/pi: generate session ID upfront - // For opencode: capture session ID from first turn's output - let sessionId: string | undefined = isMultiTurn && (runner.type === "goose" || runner.type === "pi") - ? `test_${testId}_${Date.now()}` - : undefined; - - let output = ""; - const allValidations: Array<{ rule: any; passed: boolean; message?: string }> = []; - - try { - for (let turnIndex = 0; turnIndex < turns.length; turnIndex++) { - const turn = turns[turnIndex]; - const turnLabel = isMultiTurn ? ` [turn ${turnIndex + 1}/${turns.length}]` : ""; - console.log(` Running${turnLabel}...`); - - // Run the agent (with session for multi-turn) - const resume = turnIndex > 0; // Resume session on turn 2+ - const result = await runAgent(model, runner, turn.prompt, workdir, sessionId, resume); - - // Capture session ID from first turn (for opencode) - if (turnIndex === 0 && result.sessionId) { - sessionId = result.sessionId; - } - - output += `\n${'='.repeat(60)}\nTURN ${turnIndex + 1}\n${'='.repeat(60)}\n${result.output}`; - - // Validate this turn - const turnValidations = validateAll(turn.validate, workdir); - for (const v of turnValidations) { - allValidations.push({ - rule: v.rule, - passed: v.result.passed, - message: v.result.message, - }); - } - - // If any validation failed, stop early - const turnPassed = turnValidations.every((v) => v.result.passed); - if (!turnPassed) { - console.log(` Turn ${turnIndex + 1} failed validation`); - break; - } - } - - run.endTime = new Date(); - const allPassed = allValidations.every((v) => v.passed); - - writeFileSync(logFile, output); - - const metrics = parseLogMetrics(output, workdir); - return { - run: { ...run, status: allPassed ? "passed" : "failed" }, - validations: allValidations, - logFile, - runnerName: runner.name, - toolCalls: metrics.toolCalls, - turns: metrics.turns, - }; - } catch (err) { - const errorOutput = output + "\n\nERROR:\n" + String(err); - writeFileSync(logFile, errorOutput); - - return { - run: { - ...run, - status: "failed", - endTime: new Date(), - errors: [String(err)], - }, - validations: allValidations, - logFile, - runnerName: runner.name, - toolCalls: parseLogMetrics(errorOutput, workdir).toolCalls, - turns: parseLogMetrics(errorOutput, workdir).turns, - }; - } -} - -// ============================================================================= -// Reporting -// ============================================================================= - -function pairKey(pair: TestPair): string { - return `${pair.model.name}::${pair.runner.name}`; -} - -function resultKey(result: TestResultWithLog): string { - return `${result.run.config.provider}/${result.run.config.model}::${result.runnerName}`; -} - -interface ReportOptions { - isRunning?: boolean; - allPairs?: TestPair[]; -} - -function generateHtmlReport( - results: TestResultWithLog[], - outputPath: string, - options: ReportOptions = {} -): void { - const { isRunning = false, allPairs = [] } = options; - - // Read and embed gym.png as base64. Prefer one sitting next to the report - // (legacy in-repo layout); otherwise fall back to the copy in the source tree - // so the image still embeds when output is redirected via GYM_OUTPUT_DIR. - let gymBase64 = ""; - try { - const adjacent = join(outputPath, "..", "gym.png"); - const gymPath = existsSync(adjacent) - ? adjacent - : join(import.meta.dirname, "gym.png"); - gymBase64 = readFileSync(gymPath).toString("base64"); - } catch (e) { - // gym.png not found, will use external reference - } - - // Collect all logs for embedding - const logsData: Record = {}; - for (const r of results) { - if (r.logFile) { - try { - logsData[basename(r.logFile)] = readFileSync(r.logFile, "utf-8"); - } catch (e) { /* ignore missing logs */ } - } - } - - // Calculate max duration for scaling bars - const maxDuration = Math.max(...results.map(r => { - if (!r.run.endTime || !r.run.startTime) return 0; - return (r.run.endTime.getTime() - r.run.startTime.getTime()) / 1000; - }), 1); - - const maxToolCalls = Math.max(...results.map(r => r.toolCalls || 0), 1); - - // Get all scenarios (columns) - const scenarios = allPairs.length - ? [...new Set(allPairs.map((p) => p.scenario.name))] - : [...new Set(results.map((r) => r.run.scenario.name))]; - - // Rows are model × runner combinations - const rowKeys = allPairs.length - ? [...new Set(allPairs.map(pairKey))] - : [...new Set(results.map((r) => `${r.run.config.provider}/${r.run.config.model}::${r.runnerName}`))]; - - // Map row key -> pair info - const rowsByKey = new Map(); - for (const pair of allPairs) { - rowsByKey.set(pairKey(pair), { model: pair.model, runner: pair.runner }); - } - - // Group rows by model for rowspan display - const modelKey = (m: ModelConfig) => `${m.provider}/${m.model}`; - const modelGroups = new Map(); // modelKey -> rowKeys[] - for (const key of rowKeys) { - const row = rowsByKey.get(key); - if (!row) continue; - const mk = modelKey(row.model); - if (!modelGroups.has(mk)) modelGroups.set(mk, []); - modelGroups.get(mk)!.push(key); - } - - // Build set of valid (scenario, rowKey) combinations from the matrix - const validCells = new Set(); - for (const pair of allPairs) { - validCells.add(`${pair.scenario.name}::${pairKey(pair)}`); - } - - const getResult = (scenario: string, rowKey: string) => { - const [modelPart, runnerName] = rowKey.split("::"); - return results.find( - (r) => - r.run.scenario.name === scenario && - `${r.run.config.provider}/${r.run.config.model}` === `${rowsByKey.get(rowKey)?.model.provider}/${rowsByKey.get(rowKey)?.model.model}` && - r.runnerName === runnerName - ); - }; - - const passed = results.filter((r) => r.run.status === "passed").length; - const failed = results.filter((r) => r.run.status === "failed").length; - const total = allPairs.length || results.length; - const pending = total - results.length; - - const runnerNames = [...new Set(allPairs.map((p) => p.runner.name))]; - - const html = ` - - - - - - ${isRunning ? "Running..." : "Results"} - Agent Gym Workout - - - -
Agent Gym

Agent Gym Workout${isRunning ? " (Running...)" : ""}

${!isRunning ? '' : ''}
-

- ${passed} passed / - ${failed} failed${pending > 0 ? ` / ${pending} pending` : ""} / - ${total} total -

-

Agent Configurations: ${runnerNames.map(n => `${n}`).join(", ")}

- - - - - - - ${scenarios.map((s) => ``).join("")} - - - - ${[...modelGroups.entries()].map(([mk, keys]) => { - return keys.map((key, idx) => { - const row = rowsByKey.get(key); - if (!row) return ""; - const { model, runner } = row; - const isFirst = idx === 0; - const rowspan = keys.length; - return ` - - ${isFirst ? `` : ''} - - ${scenarios.map((scenario) => { - const r = getResult(scenario, key); - if (!r) { - // Check if this combination is in the matrix - const cellKey = `${scenario}::${key}`; - const isInMatrix = validCells.has(cellKey); - if (!isInMatrix) return ``; - return ``; - } - if (r.run.status === "running") { - return ``; - } - const duration = r.run.endTime - ? ((r.run.endTime.getTime() - r.run.startTime.getTime()) / 1000).toFixed(1) - : "-"; - const logPath = r.logFile ? `logs/${basename(r.logFile)}` : ""; - const validationHtml = r.validations.map((v) => { - const icon = v.passed ? "✓" : "✗"; - const cls = v.passed ? "pass" : "fail"; - const ruleLabel = (v.rule as any).name - ? (v.rule as any).name - : v.rule.type === "tool_called" - ? `tool_called: ${(v.rule as any).tool}` - : v.rule.type + (("path" in v.rule) ? `: ${(v.rule as any).path}` : ""); - return `
${icon} ${ruleLabel}
`; - }).join(""); - return ``; - }).join("")} - `; - }).join(""); - }).join("")} - -
ModelAgent Configuration${s}
- ${model.provider}/${model.model} -
- ${runner.name} - (${runner.type}) -
...
-
- ${r.run.status === "passed" ? "✓" : "✗"} - ${r.cached ? 'cached' : ''} - ${duration}s - ${logPath ? `log` : ""} -
-
-
-
- 🔧 ${r.toolCalls || 0} - ↻ ${r.turns || 0} -
-
${validationHtml}
-
- -

Generated: ${new Date().toISOString()}

- - - - - - -`; - - mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, html); - console.log(`\n📊 Report saved to: ${outputPath}`); -} - -function printResults(results: TestResultWithLog[]): void { - console.log("\n" + "=".repeat(60)); - console.log("RESULTS"); - console.log("=".repeat(60)); - - for (const result of results) { - const icon = result.run.status === "passed" ? "✓" : "✗"; - const { scenario, config } = result.run; - console.log( - `${icon} ${scenario.name} [${config.provider}/${config.model}] (${result.runnerName}) - ${result.run.status.toUpperCase()}` - ); - - for (const v of result.validations) { - if (!v.passed) { - console.log(` ✗ ${v.message}`); - } - } - } - - const passed = results.filter((r) => r.run.status === "passed").length; - console.log(`\n${passed}/${results.length} tests passed`); -} - -// ============================================================================= -// Main -// ============================================================================= - -async function main() { - // CLI --clear-cache: clear cache and exit - if (process.argv.includes("--clear-cache")) { - clearCache(); - return; - } - - const configPath = join(GYM_DIR, "config.yaml"); - const scenariosDir = join(import.meta.dirname, "../scenarios"); - const workdir = artifactPath(".workdir", SUITE_DIR); - const logsDir = artifactPath("logs", GYM_DIR); - const reportPath = artifactPath("report.html", GYM_DIR); - - const config = loadConfig(configPath); - let scenarios = loadAllScenarios(scenariosDir); - - // CLI --scenario= filter - const scenarioFilter = process.argv.find((a) => a.startsWith("--scenario="))?.split("=")[1]; - if (scenarioFilter) { - const filters = scenarioFilter.split(","); - scenarios = scenarios.filter((s) => filters.some((f) => s.name.includes(f))); - } - - // CLI --model= filter - const modelFilter = process.argv.find((a) => a.startsWith("--model="))?.split("=")[1]; - if (modelFilter) { - const filters = modelFilter.split(","); - config.models = config.models.filter((m) => filters.some((f) => m.name.includes(f))); - } - - // CLI --runner= filter - const runnerFilter = process.argv.find((a) => a.startsWith("--runner="))?.split("=")[1]; - if (runnerFilter) { - const filters = runnerFilter.split(","); - config.runners = config.runners.filter((r) => filters.some((f) => r.name.includes(f))); - } - - const pairs = buildTestPairs(config, scenarios); - - // Sort pairs by model name so same models run together (keeps model loaded in memory) - pairs.sort((a, b) => a.model.name.localeCompare(b.model.name)); - - // Show model grouping - const modelOrder = [...new Set(pairs.map(p => p.model.name))]; - console.log(`\nExecution order (grouped by model for efficiency):`); - for (const m of modelOrder) { - const count = pairs.filter(p => p.model.name === m).length; - console.log(` ${m}: ${count} tests`); - } - - // CLI --run-count=N (default 1) - const runCountArg = process.argv.find((a) => a.startsWith("--run-count="))?.split("=")[1]; - const RUN_COUNT = runCountArg ? parseInt(runCountArg, 10) : 1; - - // CLI --no-cache: skip cache lookup (still stores results) - const noCache = process.argv.includes("--no-cache"); - - // Load cache and precompute hashes - const cache = loadCache(); - const binaryHashes = new Map(); - for (const runner of config.runners) { - if (!binaryHashes.has(runner.bin)) { - console.log(`Computing hash for ${runner.bin}...`); - binaryHashes.set(runner.bin, getBinaryHash(runner.bin)); - } - } - const mcpHarnessHash = getMcpHarnessHash(); - - console.log(`Output: ${OUTPUT_DIR ?? GYM_DIR}${OUTPUT_DIR ? "" : " (in-repo; set GYM_OUTPUT_DIR to redirect)"}`); - console.log(`Models: ${config.models.map((m) => m.name).join(", ")}`); - console.log(`Runners: ${config.runners.map((r) => r.name).join(", ")}`); - console.log(`Running ${pairs.length} test pairs (${RUN_COUNT}x each, worst result kept)`); - console.log(`Cache: ${noCache ? "disabled" : "enabled"} (${Object.keys(cache.entries).length} entries)`); - - const results: TestResultWithLog[] = []; - - // CLI --no-open to skip opening browser - const noOpen = process.argv.includes("--no-open"); - - let cacheHits = 0; - let cacheMisses = 0; - let browserOpened = false; - - for (const pair of pairs) { - // Check cache first - const { key: cacheKey, inputs: cacheInputs } = computeCacheKey(pair, binaryHashes, mcpHarnessHash); - - if (!noCache) { - const cachedResult = getCachedResult(cache, cacheKey, pair, logsDir); - if (cachedResult) { - console.log(`\n${cachedResult.run.status === "passed" ? "✓" : "✗"} ${pair.scenario.name} [${pair.model.name}] (${pair.runner.name}) [CACHED]`); - results.push(cachedResult); - cacheHits++; - continue; - } - } - - // First cache miss - generate report with cached results so far and open browser - if (!browserOpened) { - generateHtmlReport(results, reportPath, { isRunning: true, allPairs: pairs }); - if (!noOpen) { - execFileSync("open", [reportPath]); - } - browserOpened = true; - } - - cacheMisses++; - let worstResult: TestResultWithLog | null = null; - - for (let attempt = 1; attempt <= RUN_COUNT; attempt++) { - console.log(` Attempt ${attempt}/${RUN_COUNT} [${pair.runner.name}]`); - const result = await runScenario(pair, workdir, logsDir, attempt); - - if (!worstResult) { - worstResult = result; - } else { - const prevScore = scoreResult(worstResult); - const currScore = scoreResult(result); - if (currScore < prevScore) { - worstResult = result; - } - } - - if (result.run.status === "failed") { - break; - } - } - - // Store in cache - storeCacheResult(cache, cacheKey, cacheInputs, worstResult!); - - results.push(worstResult!); - generateHtmlReport(results, reportPath, { isRunning: true, allPairs: pairs }); - } - - generateHtmlReport(results, reportPath, { isRunning: false, allPairs: pairs }); - - // If everything was cached, open browser now with final report - if (!browserOpened && !noOpen) { - execFileSync("open", [reportPath]); - } - - printResults(results); - - console.log(`\nCache summary: ${cacheHits} hits, ${cacheMisses} misses`); -} - -main().catch(console.error); diff --git a/evals/open-model-gym/suite/src/types.ts b/evals/open-model-gym/suite/src/types.ts deleted file mode 100644 index f8b44bc29c4d..000000000000 --- a/evals/open-model-gym/suite/src/types.ts +++ /dev/null @@ -1,74 +0,0 @@ -export interface AgentConfig { - model: string; - provider: string; - /** Extensions (runner knows which are platform vs builtin) */ - extensions?: string[]; - /** Stdio extension commands (for custom MCP servers) */ - stdio?: string[]; - /** Path to goose binary (default: "goose") */ - "goose-bin"?: string; - temperature?: number; - maxTokens?: number; -} - -export interface Scenario { - name: string; - description: string; - prompt?: string; - /** Files to create before running (relative paths) */ - setup?: Record; - /** Validation rules to check after agent completes (single-turn) */ - validate?: ValidationRule[]; - /** Multi-turn conversation (alternative to single prompt+validate) */ - turns?: Turn[]; - /** Tags for filtering scenarios */ - tags?: string[]; -} - -/** A single turn in a multi-turn conversation */ -export interface Turn { - /** The prompt for this turn */ - prompt: string; - /** Validation rules to check after this turn completes */ - validate: ValidationRule[]; -} - -export type ValidationRule = - | { type: "file_exists"; path: string; name?: string } - | { type: "file_contains"; path: string; pattern: string; name?: string } - | { type: "file_matches"; path: string; regex: string; name?: string } - | { type: "file_not_matches"; path: string; regex: string; name?: string } - | { type: "file_not_empty"; path: string; name?: string } - | { type: "command_succeeds"; command: string; name?: string } - | { type: "tool_called"; tool: string; args?: Record; name?: string } - | { type: "custom"; fn: string; name?: string }; - -export interface TestRun { - scenario: Scenario; - config: AgentConfig; - workdir: string; - startTime: Date; - endTime?: Date; - status: "pending" | "running" | "passed" | "failed"; - errors?: string[]; -} - -export interface TestResult { - run: TestRun; - validations: Array<{ - rule: ValidationRule; - passed: boolean; - message?: string; - }>; -} - -export interface SuiteConfig { - /** Agent configurations to permute */ - agents: AgentConfig[]; - /** Scenarios to run */ - scenarios: string[]; - /** Base directory for test workspaces */ - workdir: string; - /** Parallel execution count */ - parallel?: number; -} diff --git a/evals/open-model-gym/suite/src/validator.ts b/evals/open-model-gym/suite/src/validator.ts deleted file mode 100644 index 399a5ca2dab5..000000000000 --- a/evals/open-model-gym/suite/src/validator.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { existsSync, readFileSync, statSync } from "node:fs"; -import { execSync } from "node:child_process"; -import { join } from "node:path"; -import type { ValidationRule } from "./types.js"; - -export interface ValidationResult { - passed: boolean; - message?: string; -} - -export function validateRule( - rule: ValidationRule, - workdir: string -): ValidationResult { - switch (rule.type) { - case "file_exists": { - const fullPath = join(workdir, rule.path); - const exists = existsSync(fullPath); - return { - passed: exists, - message: exists ? undefined : `File not found: ${rule.path}`, - }; - } - - case "file_not_empty": { - const fullPath = join(workdir, rule.path); - if (!existsSync(fullPath)) { - return { passed: false, message: `File not found: ${rule.path}` }; - } - const stat = statSync(fullPath); - return { - passed: stat.size > 0, - message: stat.size > 0 ? undefined : `File is empty: ${rule.path}`, - }; - } - - case "file_contains": { - const fullPath = join(workdir, rule.path); - if (!existsSync(fullPath)) { - return { passed: false, message: `File not found: ${rule.path}` }; - } - const content = readFileSync(fullPath, "utf-8"); - const contains = content.includes(rule.pattern); - return { - passed: contains, - message: contains - ? undefined - : `File ${rule.path} does not contain: ${rule.pattern}`, - }; - } - - case "file_matches": { - const fullPath = join(workdir, rule.path); - if (!existsSync(fullPath)) { - return { passed: false, message: `File not found: ${rule.path}` }; - } - const content = readFileSync(fullPath, "utf-8"); - const regex = new RegExp(rule.regex); - const matches = regex.test(content); - return { - passed: matches, - message: matches - ? undefined - : `File ${rule.path} does not match regex: ${rule.regex}`, - }; - } - - case "file_not_matches": { - const fullPath = join(workdir, rule.path); - if (!existsSync(fullPath)) { - return { passed: false, message: `File not found: ${rule.path}` }; - } - const content = readFileSync(fullPath, "utf-8"); - const regex = new RegExp(rule.regex); - const matches = regex.test(content); - return { - passed: !matches, - message: !matches - ? undefined - : `File ${rule.path} should not match regex: ${rule.regex}`, - }; - } - - case "command_succeeds": { - try { - execSync(rule.command, { cwd: workdir, stdio: "pipe" }); - return { passed: true }; - } catch (err) { - return { - passed: false, - message: `Command failed: ${rule.command}`, - }; - } - } - - case "tool_called": { - const logPath = join(workdir, "tool-calls.log"); - if (!existsSync(logPath)) { - return { passed: false, message: "tool-calls.log not found" }; - } - - const content = readFileSync(logPath, "utf-8"); - const lines = content.trim().split("\n").filter(Boolean); - - // Find all calls to the specified tool - const matchingCalls = lines - .map((line) => { - try { - return JSON.parse(line); - } catch { - return null; - } - }) - .filter((entry) => entry?.tool === rule.tool); - - if (matchingCalls.length === 0) { - return { passed: false, message: `Tool not called: ${rule.tool}` }; - } - - // If no arg requirements, just check tool was called - if (!rule.args) { - return { passed: true }; - } - - // Check if any call matches the arg requirements - for (const call of matchingCalls) { - const args = call.arguments || {}; - let allMatch = true; - - for (const [key, expected] of Object.entries(rule.args)) { - const actual = args[key]; - if (actual === undefined) { - allMatch = false; - break; - } - - // If expected starts/ends with /, treat as regex pattern - if (typeof expected === "string" && expected.startsWith("/") && expected.endsWith("/")) { - const pattern = new RegExp(expected.slice(1, -1), "i"); - if (!pattern.test(String(actual))) { - allMatch = false; - break; - } - } else { - // Exact match (case-insensitive for strings) - const actualStr = String(actual).toLowerCase(); - const expectedStr = String(expected).toLowerCase(); - if (!actualStr.includes(expectedStr)) { - allMatch = false; - break; - } - } - } - - if (allMatch) { - return { passed: true }; - } - } - - return { - passed: false, - message: `Tool ${rule.tool} called but args didn't match: expected ${JSON.stringify(rule.args)}`, - }; - } - - case "custom": { - // Custom validators loaded dynamically - return { passed: false, message: "Custom validators not yet implemented" }; - } - - default: - return { passed: false, message: `Unknown rule type` }; - } -} - -export function validateAll( - rules: ValidationRule[], - workdir: string -): Array<{ rule: ValidationRule; result: ValidationResult }> { - return rules.map((rule) => ({ - rule, - result: validateRule(rule, workdir), - })); -} diff --git a/evals/open-model-gym/suite/tsconfig.json b/evals/open-model-gym/suite/tsconfig.json deleted file mode 100644 index 406f69d8f857..000000000000 --- a/evals/open-model-gym/suite/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "outDir": "dist" - }, - "include": ["src"] -} diff --git a/goose-self-test.yaml b/goose-self-test.yaml index 96e713201b85..cfcb042b8012 100644 --- a/goose-self-test.yaml +++ b/goose-self-test.yaml @@ -114,6 +114,12 @@ extensions: timeout: 600 bundled: true description: Core tool for file operations, shell commands, and code analysis + - type: builtin + name: todo + - type: builtin + name: summon + - type: builtin + name: extensionmanager prompt: | Execute the Goose Self-Testing Integration Suite in {{ workspace_dir }}. @@ -171,7 +177,7 @@ prompt: | {% endif %} {% if test_phases == "all" or "delegation" in test_phases %} - ## 🤖 PHASE 3: Delegate & Load Testing + ## 🤖 PHASE 3: Summon Testing ### Load Tool - Discovery Mode Call `load()` with no arguments to discover all available sources: @@ -326,7 +332,7 @@ prompt: | ### Vision Smoke Test 1. Create a small test image: ``` - python3 -c "import struct, zlib; raw=b'\x00\xff\x00\x00'; d=zlib.compress(raw); ihdr=b'\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00'; print('Created test.png')" + python3 -c "import struct, zlib; raw=b'\x00\xff\x00\x00'; d=zlib.compress(raw); ihdr=b'\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00'; print('Created test.png')" ``` Or simply create a 1-pixel PNG test image using available tools. 2. Verify the test image file exists and is valid. diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index 4807e53e72db..25438ae5555f 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -42,7 +42,7 @@ Write-Host "" # Step 1: Clone or update repo Write-Host "[2/7] Building Rust backend (release)..." -ForegroundColor Yellow Write-Host " This may take 5-15 minutes on first build..." -cargo build --release -p goose-server +cargo build --release -p goose-cli --bin goose if ($LASTEXITCODE -ne 0) { Write-Host "Rust build failed!" -ForegroundColor Red exit 1 @@ -55,10 +55,12 @@ Write-Host "[3/7] Copying binaries to desktop app..." -ForegroundColor Yellow $binDir = "ui\desktop\src\bin" if (-not (Test-Path $binDir)) { New-Item -ItemType Directory -Path $binDir -Force | Out-Null } -Copy-Item "target\release\goosed.exe" "$binDir\" -Force -if (Test-Path "target\release\goose.exe") { - Copy-Item "target\release\goose.exe" "$binDir\" -Force +$gooseBinary = "target\release\goose.exe" +if (-not (Test-Path $gooseBinary)) { + Write-Host "Backend binary not found: $gooseBinary" -ForegroundColor Red + exit 1 } +Copy-Item $gooseBinary "$binDir\" -Force # Copy required DLLs if they exist (from cross-compilation) Get-ChildItem "target\release\*.dll" -ErrorAction SilentlyContinue | ForEach-Object { Copy-Item $_.FullName "$binDir\" -Force @@ -78,20 +80,26 @@ if ($LASTEXITCODE -ne 0) { Write-Host " Dependencies installed." -ForegroundColor Green Write-Host "" -# Step 4: Generate API types -Write-Host "[5/7] Generating API types..." -ForegroundColor Yellow -pnpm run generate-api +# Step 4: Build desktop assets +Write-Host "[5/7] Building Goose SDK, clearing Vite cache, and compiling i18n messages..." -ForegroundColor Yellow +pnpm run build-goose-sdk +if ($LASTEXITCODE -ne 0) { + Write-Host "Goose SDK build or Vite cache cleanup failed!" -ForegroundColor Red + Pop-Location + exit 1 +} +pnpm run i18n:compile if ($LASTEXITCODE -ne 0) { - Write-Host "API type generation failed!" -ForegroundColor Red + Write-Host "i18n compilation failed!" -ForegroundColor Red Pop-Location exit 1 } -Write-Host " API types generated." -ForegroundColor Green +Write-Host " Desktop assets built." -ForegroundColor Green Write-Host "" # Step 5: Package Write-Host "[6/7] Packaging Goose Desktop..." -ForegroundColor Yellow -npx electron-forge package +pnpm exec electron-forge package if ($LASTEXITCODE -ne 0) { Write-Host "Packaging failed!" -ForegroundColor Red Pop-Location @@ -102,10 +110,10 @@ Write-Host "" # Step 6: Make installer Write-Host "[7/7] Creating Windows installer..." -ForegroundColor Yellow -npx electron-forge make +pnpm exec electron-forge make if ($LASTEXITCODE -ne 0) { Write-Host "Make failed! Trying with squirrel only..." -ForegroundColor Yellow - npx electron-forge make --targets=@electron-forge/maker-squirrel + pnpm exec electron-forge make --targets=@electron-forge/maker-squirrel if ($LASTEXITCODE -ne 0) { Write-Host "Fallback installer build also failed!" -ForegroundColor Red Pop-Location diff --git a/scripts/check-openapi-schema.sh b/scripts/check-openapi-schema.sh deleted file mode 100755 index d45f733f256c..000000000000 --- a/scripts/check-openapi-schema.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -set -e - -# Check if OpenAPI schema is up-to-date -# This script generates the OpenAPI schema and compares it with the committed version - -echo "🔍 Checking OpenAPI schema is up-to-date..." - -# Check if the generated schema differs from the committed version -echo "🔍 Comparing generated schema with committed version..." -if ! git diff --ignore-space-change --exit-code ui/desktop/openapi.json ui/desktop/src/api/; then - echo "" - echo "❌ OpenAPI schema is out of date!" - echo "" - echo "The generated OpenAPI schema differs from the committed version." - echo "This usually means that API types were added or modified without updating the schema." - echo "" - echo "To fix this issue:" - echo "1. Run 'just generate-openapi' locally" - echo "2. Commit the changes to ui/desktop/openapi.json and ui/desktop/src/api/" - echo "3. Push your changes" - echo "" - echo "Changes detected:" - git diff ui/desktop/openapi.json ui/desktop/src/api/ - exit 1 -fi - -echo "✅ OpenAPI schema is up-to-date" diff --git a/ui/desktop/forge.config.ts b/ui/desktop/forge.config.ts index 7f0db8ae4db4..202b5cdd31fc 100644 --- a/ui/desktop/forge.config.ts +++ b/ui/desktop/forge.config.ts @@ -7,7 +7,7 @@ const isLinuxVulkanBuild = process.env.GOOSE_DESKTOP_LINUX_VARIANT === 'vulkan'; let cfg = { asar: true, executableName: 'Goose', - extraResource: ['src/bin', 'src/images', 'default-recipes'], + extraResource: ['src/bin', 'src/images', 'default-recipes', 'src/app-update.yml'], icon: 'src/images/icon', // Windows specific configuration win32: { diff --git a/ui/desktop/openapi-ts.config.ts b/ui/desktop/openapi-ts.config.ts deleted file mode 100644 index 992c5a4a0f54..000000000000 --- a/ui/desktop/openapi-ts.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from '@hey-api/openapi-ts'; - -export default defineConfig({ - input: './openapi.json', - output: './src/api', - plugins: [ - { - name: '@hey-api/client-fetch', - // Disable SSE support to avoid requiring SSE options on all requests - sse: false, - }, - ], -}); diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index e8ce9725a3fc..306d833e48ed 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3929,7 +3929,7 @@ }, "primary": { "type": "boolean", - "description": "When true, the field is shown prominently in the UI (not collapsed).\nDefaults to the value of `required` if not specified.", + "description": "Defaults to the value of `required` if not specified.\nUIs may use this to feature this config value more prominently.", "nullable": true }, "required": { diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 616cbf427012..d10aa343f38e 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -9,24 +9,25 @@ }, "main": ".vite/build/main.js", "scripts": { - "postinstall": "pnpm --filter @aaif/goose-sdk run build", + "postinstall": "pnpm run build-goose-sdk", "typecheck": "tsc --noEmit", - "generate-api": "openapi-ts", - "start-gui": "pnpm run generate-api && pnpm run i18n:compile && electron-forge start", - "start-gui-debug": "pnpm run generate-api && pnpm run i18n:compile && electron-forge start -- --inspect=9229", + "build-goose-sdk": "pnpm --filter @aaif/goose-sdk run build && pnpm run clean-vite-cache", + "clean-vite-cache": "node scripts/clean-vite-cache.js", + "start-gui": "pnpm run build-goose-sdk && pnpm run i18n:compile && electron-forge start", + "start-gui-debug": "pnpm run build-goose-sdk && pnpm run i18n:compile && electron-forge start -- --inspect=9229", "start": "cd ../.. && just run-ui", "start:test-error": "GOOSE_TEST_ERROR=true electron-forge start", - "package": "pnpm run i18n:compile && electron-forge package", - "make": "pnpm run i18n:compile && electron-forge make", + "package": "pnpm run build-goose-sdk && pnpm run i18n:compile && electron-forge package", + "make": "pnpm run build-goose-sdk && pnpm run i18n:compile && electron-forge make", "bundle:default": "node scripts/prepare-platform-binaries.js && pnpm run make && BUNDLE_NAME=\"${GOOSE_BUNDLE_NAME:-Goose}\" && APP_DIR=\"out/${BUNDLE_NAME}-darwin-arm64\" && APP_BUNDLE=\"${APP_DIR}/${BUNDLE_NAME}.app\" && (cd \"$APP_DIR\" && ditto -c -k --sequesterRsrc --keepParent \"${BUNDLE_NAME}.app\" \"${BUNDLE_NAME}.zip\")", "bundle:intel": "node scripts/prepare-platform-binaries.js && pnpm run make --arch=x64 && BUNDLE_NAME=\"${GOOSE_BUNDLE_NAME:-Goose}\" && APP_DIR=\"out/${BUNDLE_NAME}-darwin-x64\" && APP_BUNDLE=\"${APP_DIR}/${BUNDLE_NAME}.app\" && (cd \"$APP_DIR\" && ditto -c -k --sequesterRsrc --keepParent \"${BUNDLE_NAME}.app\" \"${BUNDLE_NAME}_intel_mac.zip\")", "debug": "echo 'run --remote-debugging-port=8315' && BUNDLE_NAME=\"${GOOSE_BUNDLE_NAME:-Goose}\" && lldb \"out/${BUNDLE_NAME}-darwin-arm64/${BUNDLE_NAME}.app\"", - "test-e2e": "pnpm run generate-api && playwright test", - "test-e2e:dev": "pnpm run generate-api && playwright test --reporter=list --retries=0 --max-failures=1", - "test-e2e:ui": "pnpm run generate-api && playwright test --ui", - "test-e2e:debug": "pnpm run generate-api && playwright test --debug", + "test-e2e": "playwright test", + "test-e2e:dev": "playwright test --reporter=list --retries=0 --max-failures=1", + "test-e2e:ui": "playwright test --ui", + "test-e2e:debug": "playwright test --debug", "test-e2e:report": "playwright show-report", - "test-e2e:single": "pnpm run generate-api && playwright test -g", + "test-e2e:single": "playwright test -g", "lint": "eslint \"src/**/*.{ts,tsx}\" --fix --no-warn-ignored", "lint:check": "pnpm run typecheck && eslint \"src/**/*.{ts,tsx}\" --max-warnings 0 --no-warn-ignored && pnpm run i18n:check", "format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"", @@ -36,7 +37,6 @@ "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", - "test:integration:goosed": "vitest run --config vitest.integration.config.ts tests/integration/goosed.test.ts", "test:integration:providers": "vitest run --config vitest.integration.config.ts tests/integration/test_providers.test.ts", "test:integration:providers-code-exec": "vitest run --config vitest.integration.config.ts tests/integration/test_providers_code_exec.test.ts", "test:integration:watch": "vitest --config vitest.integration.config.ts", @@ -121,7 +121,6 @@ "@eslint/js": "^9.39.2", "@formatjs/cli": "^6.14.0", "@formatjs/icu-messageformat-parser": "3.5.3", - "@hey-api/openapi-ts": "^0.93.0", "@modelcontextprotocol/sdk": "^1.27.0", "@playwright/test": "^1.58.2", "@tailwindcss/line-clamp": "^0.4.4", diff --git a/ui/desktop/scripts/clean-vite-cache.js b/ui/desktop/scripts/clean-vite-cache.js new file mode 100644 index 000000000000..a0438396e422 --- /dev/null +++ b/ui/desktop/scripts/clean-vite-cache.js @@ -0,0 +1,19 @@ +const fs = require('fs'); +const path = require('path'); + +const desktopRoot = path.resolve(__dirname, '..'); + +const pathsToRemove = [ + path.join(desktopRoot, 'node_modules', '.vite'), + path.join(desktopRoot, 'node_modules', '.vite-temp'), + path.join(desktopRoot, '.vite'), +]; + +for (const targetPath of pathsToRemove) { + if (!fs.existsSync(targetPath)) { + continue; + } + + fs.rmSync(targetPath, { recursive: true, force: true }); + console.log(`Removed ${path.relative(desktopRoot, targetPath)}`); +} diff --git a/ui/desktop/scripts/prepare-platform-binaries.js b/ui/desktop/scripts/prepare-platform-binaries.js index 9679d908b726..5f698bcc96bb 100644 --- a/ui/desktop/scripts/prepare-platform-binaries.js +++ b/ui/desktop/scripts/prepare-platform-binaries.js @@ -23,17 +23,6 @@ const windowsFiles = [ 'goose-npm/**/*' ]; -const macosFiles = [ - 'goosed', - 'goose', - 'jbang', - 'npx', - 'uvx', - '*.db', - '*.log', - '.gitkeep' -]; - // Helper function to check if file matches patterns function matchesPattern(filename, patterns) { return patterns.some(pattern => { @@ -174,9 +163,10 @@ function cleanBinDirectory(targetPlatform) { const filePath = path.join(srcBinDir, file.name); if (targetPlatform === 'darwin' || targetPlatform === 'linux') { - // For macOS/Linux, remove Windows-specific files - if (matchesPattern(file.name, windowsFiles)) { - console.log(`Removing Windows file: ${file.name}`); + const isLegacyBackendBinary = file.name === 'goosed'; + if (isLegacyBackendBinary || matchesPattern(file.name, windowsFiles)) { + const fileType = isLegacyBackendBinary ? 'legacy backend binary' : 'Windows file'; + console.log(`Removing ${fileType}: ${file.name}`); if (file.isDirectory()) { fs.rmSync(filePath, { recursive: true, force: true }); } else { diff --git a/ui/desktop/scripts/verify-mac-update-resources.js b/ui/desktop/scripts/verify-mac-update-resources.js new file mode 100644 index 000000000000..e75f9adc3717 --- /dev/null +++ b/ui/desktop/scripts/verify-mac-update-resources.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); + +function fail(message) { + console.error(message); + process.exit(1); +} + +const appPath = process.argv[2]; +if (!appPath) { + fail('Usage: node scripts/verify-mac-update-resources.js '); +} + +const updateConfigPath = path.join(appPath, 'Contents', 'Resources', 'app-update.yml'); +if (!fs.existsSync(updateConfigPath)) { + fail(`Missing ${updateConfigPath}`); +} + +const updateConfig = fs.readFileSync(updateConfigPath, 'utf8'); +const requiredLines = [ + 'provider: github', + 'owner: aaif-goose', + 'repo: goose', + 'updaterCacheDirName: goose-updater', +]; + +for (const line of requiredLines) { + if (!updateConfig.split(/\r?\n/).includes(line)) { + fail(`${updateConfigPath} is missing "${line}"`); + } +} + +console.log(`${updateConfigPath} is present and valid`); diff --git a/ui/desktop/src/App.test.tsx b/ui/desktop/src/App.test.tsx index 998b776d9709..af8693ebe250 100644 --- a/ui/desktop/src/App.test.tsx +++ b/ui/desktop/src/App.test.tsx @@ -34,15 +34,6 @@ vi.mock('./utils/costDatabase', () => ({ initializeCostDatabase: vi.fn().mockResolvedValue(undefined), })); -vi.mock('./api', () => { - return { - initConfig: vi.fn().mockResolvedValue(undefined), - backupConfig: vi.fn().mockResolvedValue(undefined), - recoverConfig: vi.fn().mockResolvedValue(undefined), - validateConfig: vi.fn().mockResolvedValue(undefined), - }; -}); - vi.mock('./sessions', () => ({ fetchSessionDetails: vi .fn() diff --git a/ui/desktop/src/acp/__tests__/url.test.ts b/ui/desktop/src/acp/__tests__/url.test.ts new file mode 100644 index 000000000000..b131401afdec --- /dev/null +++ b/ui/desktop/src/acp/__tests__/url.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { + acpHttpUrlFromHttpBase, + acpWebSocketUrlFromHttpBase, + httpBaseFromAcpWebSocketUrl, + isLoopbackAcpWebSocketUrl, + normalizeAcpHttpBaseUrl, + statusHttpUrlFromHttpBase, +} from '../url'; + +describe('httpBaseFromAcpWebSocketUrl', () => { + it('converts ws ACP URLs to HTTP bases', () => { + expect(httpBaseFromAcpWebSocketUrl('ws://127.0.0.1:64027/acp?token=secret')).toBe( + 'http://127.0.0.1:64027' + ); + }); + + it('converts wss ACP URLs to HTTPS bases', () => { + expect(httpBaseFromAcpWebSocketUrl('wss://example.com/acp?token=secret')).toBe( + 'https://example.com' + ); + }); + + it('preserves path prefixes before the ACP endpoint', () => { + expect(httpBaseFromAcpWebSocketUrl('wss://example.com/goose/acp?token=secret')).toBe( + 'https://example.com/goose' + ); + }); + + it('rejects non-WebSocket URLs', () => { + expect(() => httpBaseFromAcpWebSocketUrl('http://127.0.0.1:64027/acp')).toThrow( + 'ACP URL must use ws: or wss:' + ); + }); +}); + +describe('isLoopbackAcpWebSocketUrl', () => { + it('accepts IPv4 loopback ACP URLs', () => { + expect(isLoopbackAcpWebSocketUrl('ws://127.0.0.1:64027/acp?token=secret')).toBe(true); + expect(isLoopbackAcpWebSocketUrl('wss://127.12.0.1:64027/acp?token=secret')).toBe(true); + }); + + it('accepts localhost ACP URLs', () => { + expect(isLoopbackAcpWebSocketUrl('ws://localhost:64027/acp?token=secret')).toBe(true); + }); + + it('accepts IPv6 loopback ACP URLs', () => { + expect(isLoopbackAcpWebSocketUrl('ws://[::1]:64027/acp?token=secret')).toBe(true); + }); + + it('rejects remote ACP URLs', () => { + expect(isLoopbackAcpWebSocketUrl('wss://example.com/acp?token=secret')).toBe(false); + expect(isLoopbackAcpWebSocketUrl('ws://192.168.1.10:3284/acp?token=secret')).toBe(false); + }); + + it('rejects DNS hostnames that start with 127', () => { + expect(isLoopbackAcpWebSocketUrl('wss://127.evil.com/acp?token=secret')).toBe(false); + expect(isLoopbackAcpWebSocketUrl('wss://127.0.0.1.example.com/acp?token=secret')).toBe(false); + }); + + it('rejects non-WebSocket URLs', () => { + expect(() => isLoopbackAcpWebSocketUrl('http://127.0.0.1:64027/acp')).toThrow( + 'ACP URL must use ws: or wss:' + ); + }); +}); + +describe('normalizeAcpHttpBaseUrl', () => { + it('normalizes root HTTPS base URLs', () => { + expect(normalizeAcpHttpBaseUrl('https://example.com/')).toBe('https://example.com'); + }); + + it('normalizes prefixed HTTPS base URLs', () => { + expect(normalizeAcpHttpBaseUrl('https://example.com/goose/')).toBe('https://example.com/goose'); + }); + + it('rejects WebSocket URLs', () => { + expect(() => normalizeAcpHttpBaseUrl('wss://example.com/acp')).toThrow( + 'External ACP backend URL must use http: or https:' + ); + }); + + it('rejects direct ACP endpoint URLs', () => { + expect(() => normalizeAcpHttpBaseUrl('https://example.com/acp')).toThrow( + 'External ACP backend URL must be the base URL before /acp' + ); + }); + + it('rejects query parameters and fragments', () => { + expect(() => normalizeAcpHttpBaseUrl('https://example.com?token=secret')).toThrow( + 'External ACP backend URL must not include query parameters or fragments' + ); + expect(() => normalizeAcpHttpBaseUrl('https://example.com#section')).toThrow( + 'External ACP backend URL must not include query parameters or fragments' + ); + }); +}); + +describe('HTTP endpoint URLs from ACP HTTP base URLs', () => { + it('builds status URLs from root and prefixed bases', () => { + expect(statusHttpUrlFromHttpBase('https://example.com/')).toBe('https://example.com/status'); + expect(statusHttpUrlFromHttpBase('https://example.com/goose/')).toBe( + 'https://example.com/goose/status' + ); + }); + + it('builds ACP URLs from root and prefixed bases', () => { + expect(acpHttpUrlFromHttpBase('https://example.com/')).toBe('https://example.com/acp'); + expect(acpHttpUrlFromHttpBase('https://example.com/goose/')).toBe( + 'https://example.com/goose/acp' + ); + }); + + it('adds ACP query tokens when provided', () => { + expect(acpHttpUrlFromHttpBase('https://example.com/goose', 'test secret')).toBe( + 'https://example.com/goose/acp?token=test+secret' + ); + }); +}); + +describe('acpWebSocketUrlFromHttpBase', () => { + it('derives WSS ACP URLs from HTTPS base URLs', () => { + expect(acpWebSocketUrlFromHttpBase('https://example.com/goose', 'secret')).toBe( + 'wss://example.com/goose/acp?token=secret' + ); + }); + + it('derives WS ACP URLs from HTTP base URLs', () => { + expect(acpWebSocketUrlFromHttpBase('http://127.0.0.1:1234', 'secret')).toBe( + 'ws://127.0.0.1:1234/acp?token=secret' + ); + }); +}); diff --git a/ui/desktop/src/acp/acpConnection.ts b/ui/desktop/src/acp/acpConnection.ts index b7b6d5561e81..fd40944ea4cf 100644 --- a/ui/desktop/src/acp/acpConnection.ts +++ b/ui/desktop/src/acp/acpConnection.ts @@ -19,6 +19,8 @@ type InitializedAcpClient = { initializeResponse: InitializeResponse; }; +const ACP_INITIALIZE_TIMEOUT_MS = 10_000; + let clientPromise: Promise | null = null; let resolvedClient: InitializedAcpClient | null = null; @@ -44,6 +46,21 @@ function monitorConnection(client: GooseClient): void { }); } +async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timeoutId: ReturnType | null = null; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + + try { + return await Promise.race([promise, timeout]); + } finally { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + } +} + async function initializeConnection(): Promise { const wsUrl = await window.electron.getAcpUrl(); if (!wsUrl) { @@ -53,26 +70,35 @@ async function initializeConnection(): Promise { const stream = createWebSocketStream(wsUrl); const client = new GooseClient(createClientCallbacks(), stream); - const initializeResponse = await client.initialize({ - protocolVersion: PROTOCOL_VERSION, - clientCapabilities: { - elicitation: { form: {} }, - _meta: { - goose: { - mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES, - customNotifications: true, - recipeParameterRequests: true, + try { + const initializeResponse = await withTimeout( + client.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + elicitation: { form: {} }, + _meta: { + goose: { + mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES, + customNotifications: true, + recipeParameterRequests: true, + }, + }, }, - }, - }, - clientInfo: { - name: packageJson.name, - version: packageJson.version, - }, - }); + clientInfo: { + name: packageJson.name, + version: packageJson.version, + }, + }), + ACP_INITIALIZE_TIMEOUT_MS, + `ACP initialize timed out after ${ACP_INITIALIZE_TIMEOUT_MS}ms` + ); - monitorConnection(client); - return { client, initializeResponse }; + monitorConnection(client); + return { client, initializeResponse }; + } catch (error) { + stream.close(); + throw error; + } } export async function getAcpClient(): Promise { diff --git a/ui/desktop/src/acp/createWebSocketStream.ts b/ui/desktop/src/acp/createWebSocketStream.ts index 61b21c558f94..73d74481613a 100644 --- a/ui/desktop/src/acp/createWebSocketStream.ts +++ b/ui/desktop/src/acp/createWebSocketStream.ts @@ -1,6 +1,10 @@ import type { Stream } from '@aaif/goose-sdk'; -export function createWebSocketStream(wsUrl: string): Stream { +export type ClosableAcpStream = Stream & { + close: () => void; +}; + +export function createWebSocketStream(wsUrl: string): ClosableAcpStream { const ws = new window.WebSocket(wsUrl); const incoming: unknown[] = []; @@ -73,5 +77,9 @@ export function createWebSocketStream(wsUrl: string): Stream { }, }); - return { readable, writable } as Stream; + return { + readable, + writable, + close: () => ws.close(), + } as ClosableAcpStream; } diff --git a/ui/desktop/src/acp/url.ts b/ui/desktop/src/acp/url.ts new file mode 100644 index 000000000000..b3a2719a0fd3 --- /dev/null +++ b/ui/desktop/src/acp/url.ts @@ -0,0 +1,87 @@ +export function httpBaseFromAcpWebSocketUrl(acpUrl: string): string { + const url = new URL(acpUrl); + + if (url.protocol === 'ws:') { + url.protocol = 'http:'; + } else if (url.protocol === 'wss:') { + url.protocol = 'https:'; + } else { + throw new Error(`ACP URL must use ws: or wss:, got ${url.protocol}`); + } + + const pathname = url.pathname.replace(/\/+$/, ''); + const pathPrefix = pathname.endsWith('/acp') ? pathname.slice(0, -'/acp'.length) : pathname; + + return `${url.origin}${pathPrefix}`; +} + +export function isLoopbackAcpWebSocketUrl(acpUrl: string): boolean { + const url = new URL(acpUrl); + + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { + throw new Error(`ACP URL must use ws: or wss:, got ${url.protocol}`); + } + + const hostname = url.hostname.toLowerCase().replace(/^\[(.*)\]$/, '$1'); + return hostname === 'localhost' || hostname === '::1' || isIpv4LoopbackLiteral(hostname); +} + +function isIpv4LoopbackLiteral(hostname: string): boolean { + const octets = hostname.split('.'); + if (octets.length !== 4 || octets.some((octet) => !/^\d+$/.test(octet))) { + return false; + } + + return octets.every((octet) => Number(octet) <= 255) && Number(octets[0]) === 127; +} + +export function normalizeAcpHttpBaseUrl(rawBaseUrl: string): string { + const trimmed = rawBaseUrl.trim(); + if (!trimmed) { + throw new Error('External ACP backend URL is required'); + } + + const url = new URL(trimmed); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`External ACP backend URL must use http: or https:, got ${url.protocol}`); + } + + if (url.search || url.hash) { + throw new Error('External ACP backend URL must not include query parameters or fragments'); + } + + const pathname = url.pathname.replace(/\/+$/, ''); + if (pathname.endsWith('/acp')) { + throw new Error('External ACP backend URL must be the base URL before /acp'); + } + + return `${url.origin}${pathname}`; +} + +function httpEndpointUrlFromHttpBase(rawBaseUrl: string, endpoint: 'status' | 'acp'): string { + const baseUrl = normalizeAcpHttpBaseUrl(rawBaseUrl); + const url = new URL(baseUrl); + url.pathname = `${url.pathname.replace(/\/+$/, '')}/${endpoint}`; + return url.toString(); +} + +export function statusHttpUrlFromHttpBase(rawBaseUrl: string): string { + return httpEndpointUrlFromHttpBase(rawBaseUrl, 'status'); +} + +export function acpHttpUrlFromHttpBase(rawBaseUrl: string, token?: string): string { + const url = new URL(httpEndpointUrlFromHttpBase(rawBaseUrl, 'acp')); + if (token) { + url.searchParams.set('token', token); + } + return url.toString(); +} + +export function acpWebSocketUrlFromHttpBase(rawBaseUrl: string, token: string): string { + const baseUrl = normalizeAcpHttpBaseUrl(rawBaseUrl); + const url = new URL(baseUrl); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + url.pathname = `${url.pathname.replace(/\/+$/, '')}/acp`; + url.searchParams.set('token', token); + return url.toString(); +} diff --git a/ui/desktop/src/api/client.gen.ts b/ui/desktop/src/api/client.gen.ts deleted file mode 100644 index d81ce3f8f717..000000000000 --- a/ui/desktop/src/api/client.gen.ts +++ /dev/null @@ -1,16 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { type ClientOptions, type Config, createClient, createConfig } from './client'; -import type { ClientOptions as ClientOptions2 } from './types.gen'; - -/** - * The `createClientConfig()` function will be called on client initialization - * and the returned object will become the client's initial configuration. - * - * You may want to initialize your client this way instead of calling - * `setConfig()`. This is useful for example if you're using Next.js - * to ensure your client always has the correct values. - */ -export type CreateClientConfig = (override?: Config) => Config & T> | Promise & T>>; - -export const client = createClient(createConfig()); diff --git a/ui/desktop/src/api/client/client.gen.ts b/ui/desktop/src/api/client/client.gen.ts deleted file mode 100644 index d2e55a14497d..000000000000 --- a/ui/desktop/src/api/client/client.gen.ts +++ /dev/null @@ -1,288 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { createSseClient } from '../core/serverSentEvents.gen'; -import type { HttpMethod } from '../core/types.gen'; -import { getValidRequestBody } from '../core/utils.gen'; -import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen'; -import { - buildUrl, - createConfig, - createInterceptors, - getParseAs, - mergeConfigs, - mergeHeaders, - setAuthParams, -} from './utils.gen'; - -type ReqInit = Omit & { - body?: any; - headers: ReturnType; -}; - -export const createClient = (config: Config = {}): Client => { - let _config = mergeConfigs(createConfig(), config); - - const getConfig = (): Config => ({ ..._config }); - - const setConfig = (config: Config): Config => { - _config = mergeConfigs(_config, config); - return getConfig(); - }; - - const interceptors = createInterceptors(); - - const beforeRequest = async (options: RequestOptions) => { - const opts = { - ..._config, - ...options, - fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, - headers: mergeHeaders(_config.headers, options.headers), - serializedBody: undefined, - }; - - if (opts.security) { - await setAuthParams({ - ...opts, - security: opts.security, - }); - } - - if (opts.requestValidator) { - await opts.requestValidator(opts); - } - - if (opts.body !== undefined && opts.bodySerializer) { - opts.serializedBody = opts.bodySerializer(opts.body); - } - - // remove Content-Type header if body is empty to avoid sending invalid requests - if (opts.body === undefined || opts.serializedBody === '') { - opts.headers.delete('Content-Type'); - } - - const url = buildUrl(opts); - - return { opts, url }; - }; - - const request: Client['request'] = async (options) => { - // @ts-expect-error - const { opts, url } = await beforeRequest(options); - const requestInit: ReqInit = { - redirect: 'follow', - ...opts, - body: getValidRequestBody(opts), - }; - - let request = new Request(url, requestInit); - - for (const fn of interceptors.request.fns) { - if (fn) { - request = await fn(request, opts); - } - } - - // fetch must be assigned here, otherwise it would throw the error: - // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = opts.fetch!; - let response: Response; - - try { - response = await _fetch(request); - } catch (error) { - // Handle fetch exceptions (AbortError, network errors, etc.) - let finalError = error; - - for (const fn of interceptors.error.fns) { - if (fn) { - finalError = (await fn(error, undefined as any, request, opts)) as unknown; - } - } - - finalError = finalError || ({} as unknown); - - if (opts.throwOnError) { - throw finalError; - } - - // Return error response - return opts.responseStyle === 'data' - ? undefined - : { - error: finalError, - request, - response: undefined as any, - }; - } - - for (const fn of interceptors.response.fns) { - if (fn) { - response = await fn(response, request, opts); - } - } - - const result = { - request, - response, - }; - - if (response.ok) { - const parseAs = - (opts.parseAs === 'auto' - ? getParseAs(response.headers.get('Content-Type')) - : opts.parseAs) ?? 'json'; - - if (response.status === 204 || response.headers.get('Content-Length') === '0') { - let emptyData: any; - switch (parseAs) { - case 'arrayBuffer': - case 'blob': - case 'text': - emptyData = await response[parseAs](); - break; - case 'formData': - emptyData = new FormData(); - break; - case 'stream': - emptyData = response.body; - break; - case 'json': - default: - emptyData = {}; - break; - } - return opts.responseStyle === 'data' - ? emptyData - : { - data: emptyData, - ...result, - }; - } - - let data: any; - switch (parseAs) { - case 'arrayBuffer': - case 'blob': - case 'formData': - case 'text': - data = await response[parseAs](); - break; - case 'json': { - // Some servers return 200 with no Content-Length and empty body. - // response.json() would throw; read as text and parse if non-empty. - const text = await response.text(); - data = text ? JSON.parse(text) : {}; - break; - } - case 'stream': - return opts.responseStyle === 'data' - ? response.body - : { - data: response.body, - ...result, - }; - } - - if (parseAs === 'json') { - if (opts.responseValidator) { - await opts.responseValidator(data); - } - - if (opts.responseTransformer) { - data = await opts.responseTransformer(data); - } - } - - return opts.responseStyle === 'data' - ? data - : { - data, - ...result, - }; - } - - const textError = await response.text(); - let jsonError: unknown; - - try { - jsonError = JSON.parse(textError); - } catch { - // noop - } - - const error = jsonError ?? textError; - let finalError = error; - - for (const fn of interceptors.error.fns) { - if (fn) { - finalError = (await fn(error, response, request, opts)) as string; - } - } - - finalError = finalError || ({} as string); - - if (opts.throwOnError) { - throw finalError; - } - - // TODO: we probably want to return error and improve types - return opts.responseStyle === 'data' - ? undefined - : { - error: finalError, - ...result, - }; - }; - - const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => - request({ ...options, method }); - - const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { - const { opts, url } = await beforeRequest(options); - return createSseClient({ - ...opts, - body: opts.body as BodyInit | null | undefined, - headers: opts.headers as unknown as Record, - method, - onRequest: async (url, init) => { - let request = new Request(url, init); - for (const fn of interceptors.request.fns) { - if (fn) { - request = await fn(request, opts); - } - } - return request; - }, - serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, - url, - }); - }; - - return { - buildUrl, - connect: makeMethodFn('CONNECT'), - delete: makeMethodFn('DELETE'), - get: makeMethodFn('GET'), - getConfig, - head: makeMethodFn('HEAD'), - interceptors, - options: makeMethodFn('OPTIONS'), - patch: makeMethodFn('PATCH'), - post: makeMethodFn('POST'), - put: makeMethodFn('PUT'), - request, - setConfig, - sse: { - connect: makeSseFn('CONNECT'), - delete: makeSseFn('DELETE'), - get: makeSseFn('GET'), - head: makeSseFn('HEAD'), - options: makeSseFn('OPTIONS'), - patch: makeSseFn('PATCH'), - post: makeSseFn('POST'), - put: makeSseFn('PUT'), - trace: makeSseFn('TRACE'), - }, - trace: makeMethodFn('TRACE'), - } as Client; -}; diff --git a/ui/desktop/src/api/client/index.ts b/ui/desktop/src/api/client/index.ts deleted file mode 100644 index b295edeca0ca..000000000000 --- a/ui/desktop/src/api/client/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type { Auth } from '../core/auth.gen'; -export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; -export { - formDataBodySerializer, - jsonBodySerializer, - urlSearchParamsBodySerializer, -} from '../core/bodySerializer.gen'; -export { buildClientParams } from '../core/params.gen'; -export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; -export { createClient } from './client.gen'; -export type { - Client, - ClientOptions, - Config, - CreateClientConfig, - Options, - RequestOptions, - RequestResult, - ResolvedRequestOptions, - ResponseStyle, - TDataShape, -} from './types.gen'; -export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/ui/desktop/src/api/client/types.gen.ts b/ui/desktop/src/api/client/types.gen.ts deleted file mode 100644 index 8c0df2321e82..000000000000 --- a/ui/desktop/src/api/client/types.gen.ts +++ /dev/null @@ -1,214 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Auth } from '../core/auth.gen'; -import type { - ServerSentEventsOptions, - ServerSentEventsResult, -} from '../core/serverSentEvents.gen'; -import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen'; -import type { Middleware } from './utils.gen'; - -export type ResponseStyle = 'data' | 'fields'; - -export interface Config - extends Omit, CoreConfig { - /** - * Base URL for all requests made by this client. - */ - baseUrl?: T['baseUrl']; - /** - * Fetch API implementation. You can use this option to provide a custom - * fetch instance. - * - * @default globalThis.fetch - */ - fetch?: typeof fetch; - /** - * Please don't use the Fetch client for Next.js applications. The `next` - * options won't have any effect. - * - * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. - */ - next?: never; - /** - * Return the response data parsed in a specified format. By default, `auto` - * will infer the appropriate method from the `Content-Type` response header. - * You can override this behavior with any of the {@link Body} methods. - * Select `stream` if you don't want to parse response data at all. - * - * @default 'auto' - */ - parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text'; - /** - * Should we return only data or multiple fields (data, error, response, etc.)? - * - * @default 'fields' - */ - responseStyle?: ResponseStyle; - /** - * Throw an error instead of returning it in the response? - * - * @default false - */ - throwOnError?: T['throwOnError']; -} - -export interface RequestOptions< - TData = unknown, - TResponseStyle extends ResponseStyle = 'fields', - ThrowOnError extends boolean = boolean, - Url extends string = string, -> - extends - Config<{ - responseStyle: TResponseStyle; - throwOnError: ThrowOnError; - }>, - Pick< - ServerSentEventsOptions, - | 'onRequest' - | 'onSseError' - | 'onSseEvent' - | 'sseDefaultRetryDelay' - | 'sseMaxRetryAttempts' - | 'sseMaxRetryDelay' - > { - /** - * Any body that you want to add to your request. - * - * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} - */ - body?: unknown; - path?: Record; - query?: Record; - /** - * Security mechanism(s) to use for the request. - */ - security?: ReadonlyArray; - url: Url; -} - -export interface ResolvedRequestOptions< - TResponseStyle extends ResponseStyle = 'fields', - ThrowOnError extends boolean = boolean, - Url extends string = string, -> extends RequestOptions { - serializedBody?: string; -} - -export type RequestResult< - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = boolean, - TResponseStyle extends ResponseStyle = 'fields', -> = ThrowOnError extends true - ? Promise< - TResponseStyle extends 'data' - ? TData extends Record - ? TData[keyof TData] - : TData - : { - data: TData extends Record ? TData[keyof TData] : TData; - request: Request; - response: Response; - } - > - : Promise< - TResponseStyle extends 'data' - ? (TData extends Record ? TData[keyof TData] : TData) | undefined - : ( - | { - data: TData extends Record ? TData[keyof TData] : TData; - error: undefined; - } - | { - data: undefined; - error: TError extends Record ? TError[keyof TError] : TError; - } - ) & { - request: Request; - response: Response; - } - >; - -export interface ClientOptions { - baseUrl?: string; - responseStyle?: ResponseStyle; - throwOnError?: boolean; -} - -type MethodFn = < - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = 'fields', ->( - options: Omit, 'method'>, -) => RequestResult; - -type SseFn = < - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = 'fields', ->( - options: Omit, 'method'>, -) => Promise>; - -type RequestFn = < - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = 'fields', ->( - options: Omit, 'method'> & - Pick>, 'method'>, -) => RequestResult; - -type BuildUrlFn = < - TData extends { - body?: unknown; - path?: Record; - query?: Record; - url: string; - }, ->( - options: TData & Options, -) => string; - -export type Client = CoreClient & { - interceptors: Middleware; -}; - -/** - * The `createClientConfig()` function will be called on client initialization - * and the returned object will become the client's initial configuration. - * - * You may want to initialize your client this way instead of calling - * `setConfig()`. This is useful for example if you're using Next.js - * to ensure your client always has the correct values. - */ -export type CreateClientConfig = ( - override?: Config, -) => Config & T> | Promise & T>>; - -export interface TDataShape { - body?: unknown; - headers?: unknown; - path?: unknown; - query?: unknown; - url: string; -} - -type OmitKeys = Pick>; - -export type Options< - TData extends TDataShape = TDataShape, - ThrowOnError extends boolean = boolean, - TResponse = unknown, - TResponseStyle extends ResponseStyle = 'fields', -> = OmitKeys< - RequestOptions, - 'body' | 'path' | 'query' | 'url' -> & - ([TData] extends [never] ? unknown : Omit); diff --git a/ui/desktop/src/api/client/utils.gen.ts b/ui/desktop/src/api/client/utils.gen.ts deleted file mode 100644 index b4bd2435ce0b..000000000000 --- a/ui/desktop/src/api/client/utils.gen.ts +++ /dev/null @@ -1,316 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { getAuthToken } from '../core/auth.gen'; -import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; -import { jsonBodySerializer } from '../core/bodySerializer.gen'; -import { - serializeArrayParam, - serializeObjectParam, - serializePrimitiveParam, -} from '../core/pathSerializer.gen'; -import { getUrl } from '../core/utils.gen'; -import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; - -export const createQuerySerializer = ({ - parameters = {}, - ...args -}: QuerySerializerOptions = {}) => { - const querySerializer = (queryParams: T) => { - const search: string[] = []; - if (queryParams && typeof queryParams === 'object') { - for (const name in queryParams) { - const value = queryParams[name]; - - if (value === undefined || value === null) { - continue; - } - - const options = parameters[name] || args; - - if (Array.isArray(value)) { - const serializedArray = serializeArrayParam({ - allowReserved: options.allowReserved, - explode: true, - name, - style: 'form', - value, - ...options.array, - }); - if (serializedArray) search.push(serializedArray); - } else if (typeof value === 'object') { - const serializedObject = serializeObjectParam({ - allowReserved: options.allowReserved, - explode: true, - name, - style: 'deepObject', - value: value as Record, - ...options.object, - }); - if (serializedObject) search.push(serializedObject); - } else { - const serializedPrimitive = serializePrimitiveParam({ - allowReserved: options.allowReserved, - name, - value: value as string, - }); - if (serializedPrimitive) search.push(serializedPrimitive); - } - } - } - return search.join('&'); - }; - return querySerializer; -}; - -/** - * Infers parseAs value from provided Content-Type header. - */ -export const getParseAs = (contentType: string | null): Exclude => { - if (!contentType) { - // If no Content-Type header is provided, the best we can do is return the raw response body, - // which is effectively the same as the 'stream' option. - return 'stream'; - } - - const cleanContent = contentType.split(';')[0]?.trim(); - - if (!cleanContent) { - return; - } - - if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) { - return 'json'; - } - - if (cleanContent === 'multipart/form-data') { - return 'formData'; - } - - if ( - ['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type)) - ) { - return 'blob'; - } - - if (cleanContent.startsWith('text/')) { - return 'text'; - } - - return; -}; - -const checkForExistence = ( - options: Pick & { - headers: Headers; - }, - name?: string, -): boolean => { - if (!name) { - return false; - } - if ( - options.headers.has(name) || - options.query?.[name] || - options.headers.get('Cookie')?.includes(`${name}=`) - ) { - return true; - } - return false; -}; - -export const setAuthParams = async ({ - security, - ...options -}: Pick, 'security'> & - Pick & { - headers: Headers; - }) => { - for (const auth of security) { - if (checkForExistence(options, auth.name)) { - continue; - } - - const token = await getAuthToken(auth, options.auth); - - if (!token) { - continue; - } - - const name = auth.name ?? 'Authorization'; - - switch (auth.in) { - case 'query': - if (!options.query) { - options.query = {}; - } - options.query[name] = token; - break; - case 'cookie': - options.headers.append('Cookie', `${name}=${token}`); - break; - case 'header': - default: - options.headers.set(name, token); - break; - } - } -}; - -export const buildUrl: Client['buildUrl'] = (options) => - getUrl({ - baseUrl: options.baseUrl as string, - path: options.path, - query: options.query, - querySerializer: - typeof options.querySerializer === 'function' - ? options.querySerializer - : createQuerySerializer(options.querySerializer), - url: options.url, - }); - -export const mergeConfigs = (a: Config, b: Config): Config => { - const config = { ...a, ...b }; - if (config.baseUrl?.endsWith('/')) { - config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); - } - config.headers = mergeHeaders(a.headers, b.headers); - return config; -}; - -const headersEntries = (headers: Headers): Array<[string, string]> => { - const entries: Array<[string, string]> = []; - headers.forEach((value, key) => { - entries.push([key, value]); - }); - return entries; -}; - -export const mergeHeaders = ( - ...headers: Array['headers'] | undefined> -): Headers => { - const mergedHeaders = new Headers(); - for (const header of headers) { - if (!header) { - continue; - } - - const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header); - - for (const [key, value] of iterator) { - if (value === null) { - mergedHeaders.delete(key); - } else if (Array.isArray(value)) { - for (const v of value) { - mergedHeaders.append(key, v as string); - } - } else if (value !== undefined) { - // assume object headers are meant to be JSON stringified, i.e. their - // content value in OpenAPI specification is 'application/json' - mergedHeaders.set( - key, - typeof value === 'object' ? JSON.stringify(value) : (value as string), - ); - } - } - } - return mergedHeaders; -}; - -type ErrInterceptor = ( - error: Err, - response: Res, - request: Req, - options: Options, -) => Err | Promise; - -type ReqInterceptor = (request: Req, options: Options) => Req | Promise; - -type ResInterceptor = ( - response: Res, - request: Req, - options: Options, -) => Res | Promise; - -class Interceptors { - fns: Array = []; - - clear(): void { - this.fns = []; - } - - eject(id: number | Interceptor): void { - const index = this.getInterceptorIndex(id); - if (this.fns[index]) { - this.fns[index] = null; - } - } - - exists(id: number | Interceptor): boolean { - const index = this.getInterceptorIndex(id); - return Boolean(this.fns[index]); - } - - getInterceptorIndex(id: number | Interceptor): number { - if (typeof id === 'number') { - return this.fns[id] ? id : -1; - } - return this.fns.indexOf(id); - } - - update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { - const index = this.getInterceptorIndex(id); - if (this.fns[index]) { - this.fns[index] = fn; - return id; - } - return false; - } - - use(fn: Interceptor): number { - this.fns.push(fn); - return this.fns.length - 1; - } -} - -export interface Middleware { - error: Interceptors>; - request: Interceptors>; - response: Interceptors>; -} - -export const createInterceptors = (): Middleware< - Req, - Res, - Err, - Options -> => ({ - error: new Interceptors>(), - request: new Interceptors>(), - response: new Interceptors>(), -}); - -const defaultQuerySerializer = createQuerySerializer({ - allowReserved: false, - array: { - explode: true, - style: 'form', - }, - object: { - explode: true, - style: 'deepObject', - }, -}); - -const defaultHeaders = { - 'Content-Type': 'application/json', -}; - -export const createConfig = ( - override: Config & T> = {}, -): Config & T> => ({ - ...jsonBodySerializer, - headers: defaultHeaders, - parseAs: 'auto', - querySerializer: defaultQuerySerializer, - ...override, -}); diff --git a/ui/desktop/src/api/core/auth.gen.ts b/ui/desktop/src/api/core/auth.gen.ts deleted file mode 100644 index 3ebf9947883f..000000000000 --- a/ui/desktop/src/api/core/auth.gen.ts +++ /dev/null @@ -1,41 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type AuthToken = string | undefined; - -export interface Auth { - /** - * Which part of the request do we use to send the auth? - * - * @default 'header' - */ - in?: 'header' | 'query' | 'cookie'; - /** - * Header or query parameter name. - * - * @default 'Authorization' - */ - name?: string; - scheme?: 'basic' | 'bearer'; - type: 'apiKey' | 'http'; -} - -export const getAuthToken = async ( - auth: Auth, - callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, -): Promise => { - const token = typeof callback === 'function' ? await callback(auth) : callback; - - if (!token) { - return; - } - - if (auth.scheme === 'bearer') { - return `Bearer ${token}`; - } - - if (auth.scheme === 'basic') { - return `Basic ${btoa(token)}`; - } - - return token; -}; diff --git a/ui/desktop/src/api/core/bodySerializer.gen.ts b/ui/desktop/src/api/core/bodySerializer.gen.ts deleted file mode 100644 index 8ad92c9ffd6a..000000000000 --- a/ui/desktop/src/api/core/bodySerializer.gen.ts +++ /dev/null @@ -1,84 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen'; - -export type QuerySerializer = (query: Record) => string; - -export type BodySerializer = (body: any) => any; - -type QuerySerializerOptionsObject = { - allowReserved?: boolean; - array?: Partial>; - object?: Partial>; -}; - -export type QuerySerializerOptions = QuerySerializerOptionsObject & { - /** - * Per-parameter serialization overrides. When provided, these settings - * override the global array/object settings for specific parameter names. - */ - parameters?: Record; -}; - -const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { - if (typeof value === 'string' || value instanceof Blob) { - data.append(key, value); - } else if (value instanceof Date) { - data.append(key, value.toISOString()); - } else { - data.append(key, JSON.stringify(value)); - } -}; - -const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { - if (typeof value === 'string') { - data.append(key, value); - } else { - data.append(key, JSON.stringify(value)); - } -}; - -export const formDataBodySerializer = { - bodySerializer: | Array>>( - body: T, - ): FormData => { - const data = new FormData(); - - Object.entries(body).forEach(([key, value]) => { - if (value === undefined || value === null) { - return; - } - if (Array.isArray(value)) { - value.forEach((v) => serializeFormDataPair(data, key, v)); - } else { - serializeFormDataPair(data, key, value); - } - }); - - return data; - }, -}; - -export const jsonBodySerializer = { - bodySerializer: (body: T): string => - JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), -}; - -export const urlSearchParamsBodySerializer = { - bodySerializer: | Array>>(body: T): string => { - const data = new URLSearchParams(); - - Object.entries(body).forEach(([key, value]) => { - if (value === undefined || value === null) { - return; - } - if (Array.isArray(value)) { - value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); - } else { - serializeUrlSearchParamsPair(data, key, value); - } - }); - - return data.toString(); - }, -}; diff --git a/ui/desktop/src/api/core/params.gen.ts b/ui/desktop/src/api/core/params.gen.ts deleted file mode 100644 index 7955601a5cc0..000000000000 --- a/ui/desktop/src/api/core/params.gen.ts +++ /dev/null @@ -1,169 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -type Slot = 'body' | 'headers' | 'path' | 'query'; - -export type Field = - | { - in: Exclude; - /** - * Field name. This is the name we want the user to see and use. - */ - key: string; - /** - * Field mapped name. This is the name we want to use in the request. - * If omitted, we use the same value as `key`. - */ - map?: string; - } - | { - in: Extract; - /** - * Key isn't required for bodies. - */ - key?: string; - map?: string; - } - | { - /** - * Field name. This is the name we want the user to see and use. - */ - key: string; - /** - * Field mapped name. This is the name we want to use in the request. - * If `in` is omitted, `map` aliases `key` to the transport layer. - */ - map: Slot; - }; - -export interface Fields { - allowExtra?: Partial>; - args?: ReadonlyArray; -} - -export type FieldsConfig = ReadonlyArray; - -const extraPrefixesMap: Record = { - $body_: 'body', - $headers_: 'headers', - $path_: 'path', - $query_: 'query', -}; -const extraPrefixes = Object.entries(extraPrefixesMap); - -type KeyMap = Map< - string, - | { - in: Slot; - map?: string; - } - | { - in?: never; - map: Slot; - } ->; - -const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { - if (!map) { - map = new Map(); - } - - for (const config of fields) { - if ('in' in config) { - if (config.key) { - map.set(config.key, { - in: config.in, - map: config.map, - }); - } - } else if ('key' in config) { - map.set(config.key, { - map: config.map, - }); - } else if (config.args) { - buildKeyMap(config.args, map); - } - } - - return map; -}; - -interface Params { - body: unknown; - headers: Record; - path: Record; - query: Record; -} - -const stripEmptySlots = (params: Params) => { - for (const [slot, value] of Object.entries(params)) { - if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) { - delete params[slot as Slot]; - } - } -}; - -export const buildClientParams = (args: ReadonlyArray, fields: FieldsConfig) => { - const params: Params = { - body: {}, - headers: {}, - path: {}, - query: {}, - }; - - const map = buildKeyMap(fields); - - let config: FieldsConfig[number] | undefined; - - for (const [index, arg] of args.entries()) { - if (fields[index]) { - config = fields[index]; - } - - if (!config) { - continue; - } - - if ('in' in config) { - if (config.key) { - const field = map.get(config.key)!; - const name = field.map || config.key; - if (field.in) { - (params[field.in] as Record)[name] = arg; - } - } else { - params.body = arg; - } - } else { - for (const [key, value] of Object.entries(arg ?? {})) { - const field = map.get(key); - - if (field) { - if (field.in) { - const name = field.map || key; - (params[field.in] as Record)[name] = value; - } else { - params[field.map] = value; - } - } else { - const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); - - if (extra) { - const [prefix, slot] = extra; - (params[slot] as Record)[key.slice(prefix.length)] = value; - } else if ('allowExtra' in config && config.allowExtra) { - for (const [slot, allowed] of Object.entries(config.allowExtra)) { - if (allowed) { - (params[slot as Slot] as Record)[key] = value; - break; - } - } - } - } - } - } - } - - stripEmptySlots(params); - - return params; -}; diff --git a/ui/desktop/src/api/core/pathSerializer.gen.ts b/ui/desktop/src/api/core/pathSerializer.gen.ts deleted file mode 100644 index 994b2848c63f..000000000000 --- a/ui/desktop/src/api/core/pathSerializer.gen.ts +++ /dev/null @@ -1,171 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} - -interface SerializePrimitiveOptions { - allowReserved?: boolean; - name: string; -} - -export interface SerializerOptions { - /** - * @default true - */ - explode: boolean; - style: T; -} - -export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; -export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; -type MatrixStyle = 'label' | 'matrix' | 'simple'; -export type ObjectStyle = 'form' | 'deepObject'; -type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; - -interface SerializePrimitiveParam extends SerializePrimitiveOptions { - value: string; -} - -export const separatorArrayExplode = (style: ArraySeparatorStyle) => { - switch (style) { - case 'label': - return '.'; - case 'matrix': - return ';'; - case 'simple': - return ','; - default: - return '&'; - } -}; - -export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { - switch (style) { - case 'form': - return ','; - case 'pipeDelimited': - return '|'; - case 'spaceDelimited': - return '%20'; - default: - return ','; - } -}; - -export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { - switch (style) { - case 'label': - return '.'; - case 'matrix': - return ';'; - case 'simple': - return ','; - default: - return '&'; - } -}; - -export const serializeArrayParam = ({ - allowReserved, - explode, - name, - style, - value, -}: SerializeOptions & { - value: unknown[]; -}) => { - if (!explode) { - const joinedValues = ( - allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) - ).join(separatorArrayNoExplode(style)); - switch (style) { - case 'label': - return `.${joinedValues}`; - case 'matrix': - return `;${name}=${joinedValues}`; - case 'simple': - return joinedValues; - default: - return `${name}=${joinedValues}`; - } - } - - const separator = separatorArrayExplode(style); - const joinedValues = value - .map((v) => { - if (style === 'label' || style === 'simple') { - return allowReserved ? v : encodeURIComponent(v as string); - } - - return serializePrimitiveParam({ - allowReserved, - name, - value: v as string, - }); - }) - .join(separator); - return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; -}; - -export const serializePrimitiveParam = ({ - allowReserved, - name, - value, -}: SerializePrimitiveParam) => { - if (value === undefined || value === null) { - return ''; - } - - if (typeof value === 'object') { - throw new Error( - 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', - ); - } - - return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; -}; - -export const serializeObjectParam = ({ - allowReserved, - explode, - name, - style, - value, - valueOnly, -}: SerializeOptions & { - value: Record | Date; - valueOnly?: boolean; -}) => { - if (value instanceof Date) { - return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; - } - - if (style !== 'deepObject' && !explode) { - let values: string[] = []; - Object.entries(value).forEach(([key, v]) => { - values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]; - }); - const joinedValues = values.join(','); - switch (style) { - case 'form': - return `${name}=${joinedValues}`; - case 'label': - return `.${joinedValues}`; - case 'matrix': - return `;${name}=${joinedValues}`; - default: - return joinedValues; - } - } - - const separator = separatorObjectExplode(style); - const joinedValues = Object.entries(value) - .map(([key, v]) => - serializePrimitiveParam({ - allowReserved, - name: style === 'deepObject' ? `${name}[${key}]` : key, - value: v as string, - }), - ) - .join(separator); - return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; -}; diff --git a/ui/desktop/src/api/core/queryKeySerializer.gen.ts b/ui/desktop/src/api/core/queryKeySerializer.gen.ts deleted file mode 100644 index 5000df606f37..000000000000 --- a/ui/desktop/src/api/core/queryKeySerializer.gen.ts +++ /dev/null @@ -1,117 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -/** - * JSON-friendly union that mirrors what Pinia Colada can hash. - */ -export type JsonValue = - | null - | string - | number - | boolean - | JsonValue[] - | { [key: string]: JsonValue }; - -/** - * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. - */ -export const queryKeyJsonReplacer = (_key: string, value: unknown) => { - if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { - return undefined; - } - if (typeof value === 'bigint') { - return value.toString(); - } - if (value instanceof Date) { - return value.toISOString(); - } - return value; -}; - -/** - * Safely stringifies a value and parses it back into a JsonValue. - */ -export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { - try { - const json = JSON.stringify(input, queryKeyJsonReplacer); - if (json === undefined) { - return undefined; - } - return JSON.parse(json) as JsonValue; - } catch { - return undefined; - } -}; - -/** - * Detects plain objects (including objects with a null prototype). - */ -const isPlainObject = (value: unknown): value is Record => { - if (value === null || typeof value !== 'object') { - return false; - } - const prototype = Object.getPrototypeOf(value as object); - return prototype === Object.prototype || prototype === null; -}; - -/** - * Turns URLSearchParams into a sorted JSON object for deterministic keys. - */ -const serializeSearchParams = (params: URLSearchParams): JsonValue => { - const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); - const result: Record = {}; - - for (const [key, value] of entries) { - const existing = result[key]; - if (existing === undefined) { - result[key] = value; - continue; - } - - if (Array.isArray(existing)) { - (existing as string[]).push(value); - } else { - result[key] = [existing, value]; - } - } - - return result; -}; - -/** - * Normalizes any accepted value into a JSON-friendly shape for query keys. - */ -export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { - if (value === null) { - return null; - } - - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return value; - } - - if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { - return undefined; - } - - if (typeof value === 'bigint') { - return value.toString(); - } - - if (value instanceof Date) { - return value.toISOString(); - } - - if (Array.isArray(value)) { - return stringifyToJsonValue(value); - } - - if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) { - return serializeSearchParams(value); - } - - if (isPlainObject(value)) { - return stringifyToJsonValue(value); - } - - return undefined; -}; diff --git a/ui/desktop/src/api/core/serverSentEvents.gen.ts b/ui/desktop/src/api/core/serverSentEvents.gen.ts deleted file mode 100644 index 6aa6cf02a4f4..000000000000 --- a/ui/desktop/src/api/core/serverSentEvents.gen.ts +++ /dev/null @@ -1,243 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Config } from './types.gen'; - -export type ServerSentEventsOptions = Omit & - Pick & { - /** - * Fetch API implementation. You can use this option to provide a custom - * fetch instance. - * - * @default globalThis.fetch - */ - fetch?: typeof fetch; - /** - * Implementing clients can call request interceptors inside this hook. - */ - onRequest?: (url: string, init: RequestInit) => Promise; - /** - * Callback invoked when a network or parsing error occurs during streaming. - * - * This option applies only if the endpoint returns a stream of events. - * - * @param error The error that occurred. - */ - onSseError?: (error: unknown) => void; - /** - * Callback invoked when an event is streamed from the server. - * - * This option applies only if the endpoint returns a stream of events. - * - * @param event Event streamed from the server. - * @returns Nothing (void). - */ - onSseEvent?: (event: StreamEvent) => void; - serializedBody?: RequestInit['body']; - /** - * Default retry delay in milliseconds. - * - * This option applies only if the endpoint returns a stream of events. - * - * @default 3000 - */ - sseDefaultRetryDelay?: number; - /** - * Maximum number of retry attempts before giving up. - */ - sseMaxRetryAttempts?: number; - /** - * Maximum retry delay in milliseconds. - * - * Applies only when exponential backoff is used. - * - * This option applies only if the endpoint returns a stream of events. - * - * @default 30000 - */ - sseMaxRetryDelay?: number; - /** - * Optional sleep function for retry backoff. - * - * Defaults to using `setTimeout`. - */ - sseSleepFn?: (ms: number) => Promise; - url: string; - }; - -export interface StreamEvent { - data: TData; - event?: string; - id?: string; - retry?: number; -} - -export type ServerSentEventsResult = { - stream: AsyncGenerator< - TData extends Record ? TData[keyof TData] : TData, - TReturn, - TNext - >; -}; - -export const createSseClient = ({ - onRequest, - onSseError, - onSseEvent, - responseTransformer, - responseValidator, - sseDefaultRetryDelay, - sseMaxRetryAttempts, - sseMaxRetryDelay, - sseSleepFn, - url, - ...options -}: ServerSentEventsOptions): ServerSentEventsResult => { - let lastEventId: string | undefined; - - const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); - - const createStream = async function* () { - let retryDelay: number = sseDefaultRetryDelay ?? 3000; - let attempt = 0; - const signal = options.signal ?? new AbortController().signal; - - while (true) { - if (signal.aborted) break; - - attempt++; - - const headers = - options.headers instanceof Headers - ? options.headers - : new Headers(options.headers as Record | undefined); - - if (lastEventId !== undefined) { - headers.set('Last-Event-ID', lastEventId); - } - - try { - const requestInit: RequestInit = { - redirect: 'follow', - ...options, - body: options.serializedBody, - headers, - signal, - }; - let request = new Request(url, requestInit); - if (onRequest) { - request = await onRequest(url, requestInit); - } - // fetch must be assigned here, otherwise it would throw the error: - // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = options.fetch ?? globalThis.fetch; - const response = await _fetch(request); - - if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`); - - if (!response.body) throw new Error('No body in SSE response'); - - const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); - - let buffer = ''; - - const abortHandler = () => { - try { - reader.cancel(); - } catch { - // noop - } - }; - - signal.addEventListener('abort', abortHandler); - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += value; - // Normalize line endings: CRLF -> LF, then CR -> LF - buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - - const chunks = buffer.split('\n\n'); - buffer = chunks.pop() ?? ''; - - for (const chunk of chunks) { - const lines = chunk.split('\n'); - const dataLines: Array = []; - let eventName: string | undefined; - - for (const line of lines) { - if (line.startsWith('data:')) { - dataLines.push(line.replace(/^data:\s*/, '')); - } else if (line.startsWith('event:')) { - eventName = line.replace(/^event:\s*/, ''); - } else if (line.startsWith('id:')) { - lastEventId = line.replace(/^id:\s*/, ''); - } else if (line.startsWith('retry:')) { - const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10); - if (!Number.isNaN(parsed)) { - retryDelay = parsed; - } - } - } - - let data: unknown; - let parsedJson = false; - - if (dataLines.length) { - const rawData = dataLines.join('\n'); - try { - data = JSON.parse(rawData); - parsedJson = true; - } catch { - data = rawData; - } - } - - if (parsedJson) { - if (responseValidator) { - await responseValidator(data); - } - - if (responseTransformer) { - data = await responseTransformer(data); - } - } - - onSseEvent?.({ - data, - event: eventName, - id: lastEventId, - retry: retryDelay, - }); - - if (dataLines.length) { - yield data as any; - } - } - } - } finally { - signal.removeEventListener('abort', abortHandler); - reader.releaseLock(); - } - - break; // exit loop on normal completion - } catch (error) { - // connection failed or aborted; retry after delay - onSseError?.(error); - - if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { - break; // stop after firing error - } - - // exponential backoff: double retry each attempt, cap at 30s - const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000); - await sleep(backoff); - } - } - }; - - const stream = createStream(); - - return { stream }; -}; diff --git a/ui/desktop/src/api/core/types.gen.ts b/ui/desktop/src/api/core/types.gen.ts deleted file mode 100644 index 97463257e43e..000000000000 --- a/ui/desktop/src/api/core/types.gen.ts +++ /dev/null @@ -1,104 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Auth, AuthToken } from './auth.gen'; -import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen'; - -export type HttpMethod = - | 'connect' - | 'delete' - | 'get' - | 'head' - | 'options' - | 'patch' - | 'post' - | 'put' - | 'trace'; - -export type Client< - RequestFn = never, - Config = unknown, - MethodFn = never, - BuildUrlFn = never, - SseFn = never, -> = { - /** - * Returns the final request URL. - */ - buildUrl: BuildUrlFn; - getConfig: () => Config; - request: RequestFn; - setConfig: (config: Config) => Config; -} & { - [K in HttpMethod]: MethodFn; -} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }); - -export interface Config { - /** - * Auth token or a function returning auth token. The resolved value will be - * added to the request payload as defined by its `security` array. - */ - auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; - /** - * A function for serializing request body parameter. By default, - * {@link JSON.stringify()} will be used. - */ - bodySerializer?: BodySerializer | null; - /** - * An object containing any HTTP headers that you want to pre-populate your - * `Headers` object with. - * - * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} - */ - headers?: - | RequestInit['headers'] - | Record< - string, - string | number | boolean | (string | number | boolean)[] | null | undefined | unknown - >; - /** - * The request method. - * - * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} - */ - method?: Uppercase; - /** - * A function for serializing request query parameters. By default, arrays - * will be exploded in form style, objects will be exploded in deepObject - * style, and reserved characters are percent-encoded. - * - * This method will have no effect if the native `paramsSerializer()` Axios - * API function is used. - * - * {@link https://swagger.io/docs/specification/serialization/#query View examples} - */ - querySerializer?: QuerySerializer | QuerySerializerOptions; - /** - * A function validating request data. This is useful if you want to ensure - * the request conforms to the desired shape, so it can be safely sent to - * the server. - */ - requestValidator?: (data: unknown) => Promise; - /** - * A function transforming response data before it's returned. This is useful - * for post-processing data, e.g. converting ISO strings into Date objects. - */ - responseTransformer?: (data: unknown) => Promise; - /** - * A function validating response data. This is useful if you want to ensure - * the response conforms to the desired shape, so it can be safely passed to - * the transformers and returned to the user. - */ - responseValidator?: (data: unknown) => Promise; -} - -type IsExactlyNeverOrNeverUndefined = [T] extends [never] - ? true - : [T] extends [never | undefined] - ? [undefined] extends [T] - ? false - : true - : false; - -export type OmitNever> = { - [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K]; -}; diff --git a/ui/desktop/src/api/core/utils.gen.ts b/ui/desktop/src/api/core/utils.gen.ts deleted file mode 100644 index e7ddbe354117..000000000000 --- a/ui/desktop/src/api/core/utils.gen.ts +++ /dev/null @@ -1,140 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; -import { - type ArraySeparatorStyle, - serializeArrayParam, - serializeObjectParam, - serializePrimitiveParam, -} from './pathSerializer.gen'; - -export interface PathSerializer { - path: Record; - url: string; -} - -export const PATH_PARAM_RE = /\{[^{}]+\}/g; - -export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { - let url = _url; - const matches = _url.match(PATH_PARAM_RE); - if (matches) { - for (const match of matches) { - let explode = false; - let name = match.substring(1, match.length - 1); - let style: ArraySeparatorStyle = 'simple'; - - if (name.endsWith('*')) { - explode = true; - name = name.substring(0, name.length - 1); - } - - if (name.startsWith('.')) { - name = name.substring(1); - style = 'label'; - } else if (name.startsWith(';')) { - name = name.substring(1); - style = 'matrix'; - } - - const value = path[name]; - - if (value === undefined || value === null) { - continue; - } - - if (Array.isArray(value)) { - url = url.replace(match, serializeArrayParam({ explode, name, style, value })); - continue; - } - - if (typeof value === 'object') { - url = url.replace( - match, - serializeObjectParam({ - explode, - name, - style, - value: value as Record, - valueOnly: true, - }), - ); - continue; - } - - if (style === 'matrix') { - url = url.replace( - match, - `;${serializePrimitiveParam({ - name, - value: value as string, - })}`, - ); - continue; - } - - const replaceValue = encodeURIComponent( - style === 'label' ? `.${value as string}` : (value as string), - ); - url = url.replace(match, replaceValue); - } - } - return url; -}; - -export const getUrl = ({ - baseUrl, - path, - query, - querySerializer, - url: _url, -}: { - baseUrl?: string; - path?: Record; - query?: Record; - querySerializer: QuerySerializer; - url: string; -}) => { - const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; - let url = (baseUrl ?? '') + pathUrl; - if (path) { - url = defaultPathSerializer({ path, url }); - } - let search = query ? querySerializer(query) : ''; - if (search.startsWith('?')) { - search = search.substring(1); - } - if (search) { - url += `?${search}`; - } - return url; -}; - -export function getValidRequestBody(options: { - body?: unknown; - bodySerializer?: BodySerializer | null; - serializedBody?: unknown; -}) { - const hasBody = options.body !== undefined; - const isSerializedBody = hasBody && options.bodySerializer; - - if (isSerializedBody) { - if ('serializedBody' in options) { - const hasSerializedBody = - options.serializedBody !== undefined && options.serializedBody !== ''; - - return hasSerializedBody ? options.serializedBody : null; - } - - // not all clients implement a serializedBody property (i.e. client-axios) - return options.body !== '' ? options.body : null; - } - - // plain/text body - if (hasBody) { - return options.body; - } - - // no body was provided - return undefined; -} diff --git a/ui/desktop/src/api/index.ts b/ui/desktop/src/api/index.ts deleted file mode 100644 index afe1f8254327..000000000000 --- a/ui/desktop/src/api/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export { addExtension, agentAddExtension, agentRemoveExtension, cancelDownload, checkProvider, cleanupProviderCache, confirmToolAction, createCustomProvider, createSchedule, decodeRecipe, deleteModel, deleteProviderSecret, deleteRecipe, deleteSchedule, diagnostics, downloadModel, encodeRecipe, forkSession, getCanonicalModelInfo, getCustomProvider, getDictationConfig, getDownloadProgress, getExtensions, getPrompt, getPrompts, getProviderCatalog, getProviderCatalogTemplate, getProviderModelInfo, getProviderModels, getSession, getSessionExtensions, getSlashCommands, getTools, inspectRunningJob, killRunningJob, listModels, listProviderSecrets, listRecipes, listSchedules, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, recipeToYaml, removeConfig, removeCustomProvider, removeExtension, reply, resetPrompt, restartAgent, resumeAgent, runNowHandler, savePrompt, saveRecipe, scanRecipe, scheduleRecipe, sendTelemetryEvent, sessionCancel, sessionEvents, sessionReply, sessionsHandler, setConfigProvider, setRecipeSlashCommand, startAgent, status, stopAgent, systemInfo, transcribeDictation, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateSchedule, updateSession, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, validateConfig } from './sdk.gen'; -export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelRequest, ChatRequest, CheckProviderData, CheckProviderRequest, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponse, CleanupProviderCacheResponses, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, ContentBlock, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponse2, CreateCustomProviderResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteProviderSecretData, DeleteProviderSecretErrors, DeleteProviderSecretResponse, DeleteProviderSecretResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DiagnosticsConfig, DiagnosticsData, DiagnosticsError, DiagnosticsErrors, DiagnosticsExtensions, DiagnosticsLevel, DiagnosticsLogs, DiagnosticsPrompt, DiagnosticsReport, DiagnosticsResponse, DiagnosticsResponses, DiagnosticsScheduledRecipe, DiagnosticsTextFile, DictationProvider, DictationProviderStatus, DownloadModelData, DownloadModelErrors, DownloadModelResponses, DownloadProgress, DownloadStatus, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, EnvVarConfig, ErrorResponse, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, ForkRequest, ForkResponse, ForkSessionData, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, FrontendToolRequest, GetCanonicalModelInfoData, GetCanonicalModelInfoResponse, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponse, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponse, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponse, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponse, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponse, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GooseApp, GooseMode, Icon, IconTheme, ImageContent, InferenceMetadata, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListModelsData, ListModelsResponse, ListModelsResponses, ListProviderSecretsData, ListProviderSecretsErrors, ListProviderSecretsResponse, ListProviderSecretsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, LoadedProvider, McpAppResource, Message, MessageContent, MessageEvent, MessageMetadata, ModelCapabilities, ModelConfig, ModelInfo, ModelInfoData, ModelInfoQuery, ModelInfoResponse, ModelTemplate, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, Permission, PermissionLevel, PermissionsMetadata, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderCatalogEntry, ProviderDetails, ProviderEngine, ProviderMetadata, ProviderModelInfoQuery, ProvidersData, ProviderSecret, ProviderSecretsResponse, ProviderSecretStatus, ProviderSecretStorage, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderTemplate, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionCancelData, SessionCancelResponses, SessionDisplayInfo, SessionEventsData, SessionEventsErrors, SessionEventsResponse, SessionEventsResponses, SessionExtensionsResponse, SessionReplyData, SessionReplyErrors, SessionReplyRequest, SessionReplyResponse, SessionReplyResponse2, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, SubRecipe, SuccessCheck, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TaskSupport, TelemetryEventRequest, Template, TextContent, ThinkingContent, ThinkingEffort, TokenState, Tool, ToolAnnotations, ToolConfirmationRequest, ToolExecution, ToolInfo, ToolRequest, ToolResponse, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponse, TranscribeDictationResponses, TranscribeRequest, TranscribeResponse, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionRequest, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, Usage, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WhisperModelResponse, WindowProps } from './types.gen'; diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts deleted file mode 100644 index 4786dcf2704e..000000000000 --- a/ui/desktop/src/api/sdk.gen.ts +++ /dev/null @@ -1,466 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Client, Options as Options2, TDataShape } from './client'; -import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CheckProviderData, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteProviderSecretData, DeleteProviderSecretErrors, DeleteProviderSecretResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, DownloadModelData, DownloadModelErrors, DownloadModelResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetCanonicalModelInfoData, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPromptData, GetPromptErrors, GetPromptResponses, GetPromptsData, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListModelsData, ListModelsResponses, ListProviderSecretsData, ListProviderSecretsErrors, ListProviderSecretsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SavePromptData, SavePromptErrors, SavePromptResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionCancelData, SessionCancelResponses, SessionEventsData, SessionEventsErrors, SessionEventsResponses, SessionReplyData, SessionReplyErrors, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, SystemInfoData, SystemInfoResponses, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; - -export type Options = Options2 & { - /** - * You can provide a client instance returned by `createClient()` instead of - * individual options. This might be also useful if you want to implement a - * custom client. - */ - client?: Client; - /** - * You can pass arbitrary values through the `meta` object. This can be - * used to access values that aren't defined as part of the SDK function. - */ - meta?: Record; -}; - -export const confirmToolAction = (options: Options) => (options.client ?? client).post({ - url: '/action-required/tool-confirmation', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const agentAddExtension = (options: Options) => (options.client ?? client).post({ - url: '/agent/add_extension', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const agentRemoveExtension = (options: Options) => (options.client ?? client).post({ - url: '/agent/remove_extension', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const restartAgent = (options: Options) => (options.client ?? client).post({ - url: '/agent/restart', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const resumeAgent = (options: Options) => (options.client ?? client).post({ - url: '/agent/resume', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const startAgent = (options: Options) => (options.client ?? client).post({ - url: '/agent/start', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const stopAgent = (options: Options) => (options.client ?? client).post({ - url: '/agent/stop', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const getTools = (options: Options) => (options.client ?? client).get({ url: '/agent/tools', ...options }); - -export const updateFromSession = (options: Options) => (options.client ?? client).post({ - url: '/agent/update_from_session', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const updateAgentProvider = (options: Options) => (options.client ?? client).post({ - url: '/agent/update_provider', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const updateSession = (options: Options) => (options.client ?? client).post({ - url: '/agent/update_session', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const updateWorkingDir = (options: Options) => (options.client ?? client).post({ - url: '/agent/update_working_dir', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const readAllConfig = (options?: Options) => (options?.client ?? client).get({ url: '/config', ...options }); - -export const getCanonicalModelInfo = (options: Options) => (options.client ?? client).post({ - url: '/config/canonical-model-info', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const checkProvider = (options: Options) => (options.client ?? client).post({ - url: '/config/check_provider', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const createCustomProvider = (options: Options) => (options.client ?? client).post({ - url: '/config/custom-providers', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const removeCustomProvider = (options: Options) => (options.client ?? client).delete({ url: '/config/custom-providers/{id}', ...options }); - -export const getCustomProvider = (options: Options) => (options.client ?? client).get({ url: '/config/custom-providers/{id}', ...options }); - -export const updateCustomProvider = (options: Options) => (options.client ?? client).put({ - url: '/config/custom-providers/{id}', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const getExtensions = (options?: Options) => (options?.client ?? client).get({ url: '/config/extensions', ...options }); - -export const addExtension = (options: Options) => (options.client ?? client).post({ - url: '/config/extensions', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const removeExtension = (options: Options) => (options.client ?? client).delete({ url: '/config/extensions/{name}', ...options }); - -export const getPrompts = (options?: Options) => (options?.client ?? client).get({ url: '/config/prompts', ...options }); - -export const resetPrompt = (options: Options) => (options.client ?? client).delete({ url: '/config/prompts/{name}', ...options }); - -export const getPrompt = (options: Options) => (options.client ?? client).get({ url: '/config/prompts/{name}', ...options }); - -export const savePrompt = (options: Options) => (options.client ?? client).put({ - url: '/config/prompts/{name}', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const getProviderCatalog = (options?: Options) => (options?.client ?? client).get({ url: '/config/provider-catalog', ...options }); - -export const getProviderCatalogTemplate = (options: Options) => (options.client ?? client).get({ url: '/config/provider-catalog/{id}', ...options }); - -export const listProviderSecrets = (options?: Options) => (options?.client ?? client).get({ url: '/config/provider-secrets', ...options }); - -export const deleteProviderSecret = (options: Options) => (options.client ?? client).delete({ url: '/config/provider-secrets/{id}', ...options }); - -export const providers = (options?: Options) => (options?.client ?? client).get({ url: '/config/providers', ...options }); - -export const cleanupProviderCache = (options: Options) => (options.client ?? client).post({ url: '/config/providers/{name}/cleanup', ...options }); - -export const getProviderModelInfo = (options: Options) => (options.client ?? client).post({ - url: '/config/providers/{name}/model-info', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const getProviderModels = (options: Options) => (options.client ?? client).get({ url: '/config/providers/{name}/models', ...options }); - -export const readConfig = (options: Options) => (options.client ?? client).post({ - url: '/config/read', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const removeConfig = (options: Options) => (options.client ?? client).post({ - url: '/config/remove', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const setConfigProvider = (options: Options) => (options.client ?? client).post({ - url: '/config/set_provider', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const getSlashCommands = (options?: Options) => (options?.client ?? client).get({ url: '/config/slash_commands', ...options }); - -export const upsertConfig = (options: Options) => (options.client ?? client).post({ - url: '/config/upsert', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const validateConfig = (options?: Options) => (options?.client ?? client).get({ url: '/config/validate', ...options }); - -export const diagnostics = (options: Options) => (options.client ?? client).get({ url: '/diagnostics/{session_id}', ...options }); - -export const getDictationConfig = (options?: Options) => (options?.client ?? client).get({ url: '/dictation/config', ...options }); - -export const listModels = (options?: Options) => (options?.client ?? client).get({ url: '/dictation/models', ...options }); - -export const deleteModel = (options: Options) => (options.client ?? client).delete({ url: '/dictation/models/{model_id}', ...options }); - -export const cancelDownload = (options: Options) => (options.client ?? client).delete({ url: '/dictation/models/{model_id}/download', ...options }); - -export const getDownloadProgress = (options: Options) => (options.client ?? client).get({ url: '/dictation/models/{model_id}/download', ...options }); - -export const downloadModel = (options: Options) => (options.client ?? client).post({ url: '/dictation/models/{model_id}/download', ...options }); - -export const transcribeDictation = (options: Options) => (options.client ?? client).post({ - url: '/dictation/transcribe', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const decodeRecipe = (options: Options) => (options.client ?? client).post({ - url: '/recipes/decode', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const deleteRecipe = (options: Options) => (options.client ?? client).post({ - url: '/recipes/delete', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const encodeRecipe = (options: Options) => (options.client ?? client).post({ - url: '/recipes/encode', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const listRecipes = (options?: Options) => (options?.client ?? client).get({ url: '/recipes/list', ...options }); - -export const parseRecipe = (options: Options) => (options.client ?? client).post({ - url: '/recipes/parse', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const saveRecipe = (options: Options) => (options.client ?? client).post({ - url: '/recipes/save', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const scanRecipe = (options: Options) => (options.client ?? client).post({ - url: '/recipes/scan', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const scheduleRecipe = (options: Options) => (options.client ?? client).post({ - url: '/recipes/schedule', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const setRecipeSlashCommand = (options: Options) => (options.client ?? client).post({ - url: '/recipes/slash-command', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const recipeToYaml = (options: Options) => (options.client ?? client).post({ - url: '/recipes/to-yaml', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const reply = (options: Options) => (options.client ?? client).sse.post({ - url: '/reply', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const createSchedule = (options: Options) => (options.client ?? client).post({ - url: '/schedule/create', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const deleteSchedule = (options: Options) => (options.client ?? client).delete({ url: '/schedule/delete/{id}', ...options }); - -export const listSchedules = (options?: Options) => (options?.client ?? client).get({ url: '/schedule/list', ...options }); - -export const updateSchedule = (options: Options) => (options.client ?? client).put({ - url: '/schedule/{id}', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const inspectRunningJob = (options: Options) => (options.client ?? client).get({ url: '/schedule/{id}/inspect', ...options }); - -export const killRunningJob = (options: Options) => (options.client ?? client).post({ url: '/schedule/{id}/kill', ...options }); - -export const pauseSchedule = (options: Options) => (options.client ?? client).post({ url: '/schedule/{id}/pause', ...options }); - -export const runNowHandler = (options: Options) => (options.client ?? client).post({ url: '/schedule/{id}/run_now', ...options }); - -export const sessionsHandler = (options: Options) => (options.client ?? client).get({ url: '/schedule/{id}/sessions', ...options }); - -export const unpauseSchedule = (options: Options) => (options.client ?? client).post({ url: '/schedule/{id}/unpause', ...options }); - -export const sessionCancel = (options: Options) => (options.client ?? client).post({ - url: '/sessions/{id}/cancel', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const sessionEvents = (options: Options) => (options.client ?? client).sse.get({ url: '/sessions/{id}/events', ...options }); - -export const sessionReply = (options: Options) => (options.client ?? client).post({ - url: '/sessions/{id}/reply', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const getSession = (options: Options) => (options.client ?? client).get({ url: '/sessions/{session_id}', ...options }); - -export const getSessionExtensions = (options: Options) => (options.client ?? client).get({ url: '/sessions/{session_id}/extensions', ...options }); - -export const forkSession = (options: Options) => (options.client ?? client).post({ - url: '/sessions/{session_id}/fork', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const updateSessionName = (options: Options) => (options.client ?? client).put({ - url: '/sessions/{session_id}/name', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const updateSessionUserRecipeValues = (options: Options) => (options.client ?? client).put({ - url: '/sessions/{session_id}/user_recipe_values', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -export const status = (options?: Options) => (options?.client ?? client).get({ url: '/status', ...options }); - -export const systemInfo = (options?: Options) => (options?.client ?? client).get({ url: '/system_info', ...options }); - -export const sendTelemetryEvent = (options: Options) => (options.client ?? client).post({ - url: '/telemetry/event', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts deleted file mode 100644 index 83c68e99e55d..000000000000 --- a/ui/desktop/src/api/types.gen.ts +++ /dev/null @@ -1,3798 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type ClientOptions = { - baseUrl: `${string}://${string}` | (string & {}); -}; - -export type ActionRequired = { - data: ActionRequiredData; -}; - -export type ActionRequiredData = { - actionType: 'toolConfirmation'; - arguments: JsonObject; - id: string; - prompt?: string | null; - toolName: string; -} | { - actionType: 'elicitation'; - id: string; - message: string; - requested_schema: unknown; -} | { - action?: string; - actionType: 'elicitationResponse'; - id: string; - user_data: unknown; -}; - -export type AddExtensionRequest = { - config: ExtensionConfig; - session_id: string; -}; - -export type Annotations = { - audience?: Array; - lastModified?: string; - priority?: number; -}; - -export type Author = { - contact?: string | null; - metadata?: string | null; -}; - -export type CancelRequest = { - request_id: string; -}; - -export type ChatRequest = { - /** - * Override the server's conversation history. Only use this when you need absolute control - * over the conversation state (e.g., administrative tools). For normal operations, the server - * is the source of truth - use truncate/fork endpoints to modify conversation history instead. - */ - override_conversation?: Array | null; - recipe_name?: string | null; - recipe_version?: string | null; - session_id: string; - user_message: Message; -}; - -export type CheckProviderRequest = { - provider: string; -}; - -export type CommandType = 'Builtin' | 'Recipe' | 'Skill' | 'Agent'; - -/** - * Configuration key metadata for provider setup - */ -export type ConfigKey = { - /** - * Optional default value for the key - */ - default?: string | null; - /** - * Whether this OAuth flow uses the device code grant (RFC 8628) - * When true, the user must enter a verification code in the browser - */ - device_code_flow?: boolean; - /** - * The name of the configuration key (e.g., "API_KEY") - */ - name: string; - /** - * 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 - */ - oauth_flow: boolean; - /** - * Whether this key should be shown prominently during provider setup - * (onboarding, settings modal, CLI configure) - */ - primary?: boolean; - /** - * Whether this key is required for the provider to function - */ - required: boolean; - /** - * Whether this key should be stored securely (e.g., in keychain) - */ - secret: boolean; -}; - -export type ConfigKeyQuery = { - is_secret: boolean; - key: string; -}; - -export type ConfigResponse = { - config: { - [key: string]: unknown; - }; -}; - -export type ConfirmToolActionRequest = { - action: Permission; - id: string; - principalType?: PrincipalType; - sessionId: string; -}; - -export type Content = ({ - type: 'text'; -} & RawTextContent) | ({ - type: 'image'; -} & RawImageContent) | ({ - type: 'resource'; -} & RawEmbeddedResource) | ({ - type: 'audio'; -} & RawAudioContent) | ({ - type: 'resource_link'; -} & RawResource); - -export type ContentBlock = ({ - type: 'text'; -} & RawTextContent) | ({ - type: 'image'; -} & RawImageContent) | ({ - type: 'resource'; -} & RawEmbeddedResource) | ({ - type: 'audio'; -} & RawAudioContent) | ({ - type: 'resource_link'; -} & RawResource); - -export type Conversation = Array; - -export type CreateCustomProviderResponse = { - provider_name: string; -}; - -export type CreateScheduleRequest = { - cron: string; - id: string; - recipe: Recipe; -}; - -/** - * Content Security Policy metadata for MCP Apps - * Specifies allowed domains for network connections and resource loading - */ -export type CspMetadata = { - /** - * Domains allowed for base-uri - */ - baseUriDomains?: Array | null; - /** - * Domains allowed for connect-src (fetch, XHR, WebSocket) - */ - connectDomains?: Array | null; - /** - * Domains allowed for frame-src (nested iframes) - */ - frameDomains?: Array | null; - /** - * Domains allowed for resource loading (scripts, styles, images, fonts, media) - */ - resourceDomains?: Array | null; -}; - -export type DeclarativeProviderConfig = { - api_key_env?: string; - base_path?: string | null; - base_url: string; - catalog_provider_id?: string | null; - description?: string | null; - display_name: string; - /** - * 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. - */ - dynamic_models?: boolean | null; - engine: ProviderEngine; - env_vars?: Array | null; - fast_model?: string | null; - headers?: { - [key: string]: string; - } | null; - model_doc_link?: string | null; - models: Array; - name: string; - preserves_thinking?: boolean; - requires_auth?: boolean; - setup_steps?: Array; - skip_canonical_filtering?: boolean; - supports_streaming?: boolean | null; - timeout_seconds?: number | null; -}; - -export type DecodeRecipeRequest = { - deeplink: string; -}; - -export type DecodeRecipeResponse = { - recipe: Recipe; -}; - -export type DeleteRecipeRequest = { - id: string; -}; - -export type DiagnosticsConfig = { - configPath: string; - configYaml?: string | null; - truncated: boolean; -}; - -export type DiagnosticsError = { - message: string; - path?: string | null; -}; - -export type DiagnosticsExtensions = { - enabled: Array; -}; - -export type DiagnosticsLevel = 'summary' | 'full'; - -export type DiagnosticsLogs = { - llm: Array; - server?: DiagnosticsTextFile | null; -}; - -export type DiagnosticsPrompt = { - content: string; - name: string; -}; - -export type DiagnosticsReport = { - config?: DiagnosticsConfig | null; - errors: Array; - extensions: DiagnosticsExtensions; - generatedAt: string; - level: DiagnosticsLevel; - logs: DiagnosticsLogs; - prompts: Array; - schedule?: unknown; - scheduledRecipes: Array; - schemaVersion: number; - session?: unknown; - system: SystemInfo; -}; - -export type DiagnosticsScheduledRecipe = { - content: string; - path: string; -}; - -export type DiagnosticsTextFile = { - content: string; - path: string; - truncated: boolean; -}; - -export type DictationProvider = 'openai' | 'elevenlabs' | 'groq' | 'local'; - -export type DictationProviderStatus = { - /** - * Config key name if uses_provider_config is false - */ - config_key?: string | null; - /** - * Whether the provider is fully configured and ready to use - */ - configured: boolean; - /** - * Description of what this provider does - */ - description: string; - /** - * Custom host URL if configured (only for providers that support it) - */ - host?: string | null; - /** - * Path to settings if uses_provider_config is true - */ - settings_path?: string | null; - /** - * Whether this provider uses the main provider config (true) or has its own key (false) - */ - uses_provider_config: boolean; -}; - -export type DownloadProgress = { - /** - * Bytes downloaded so far - */ - bytes_downloaded: number; - /** - * Error message if failed - */ - error?: string | null; - /** - * Estimated time remaining in seconds - */ - eta_seconds?: number | null; - /** - * Model ID being downloaded - */ - model_id: string; - /** - * Download progress percentage (0-100) - */ - progress_percent: number; - /** - * Download speed in bytes per second - */ - speed_bps?: number | null; - status: DownloadStatus; - /** - * Total bytes to download - */ - total_bytes: number; -}; - -export type DownloadStatus = 'downloading' | 'completed' | 'failed' | 'cancelled'; - -export type EmbeddedResource = { - _meta?: { - [key: string]: unknown; - }; - annotations?: Annotations | { - [key: string]: unknown; - }; - resource: ResourceContents; -}; - -export type EncodeRecipeRequest = { - recipe: Recipe; -}; - -export type EncodeRecipeResponse = { - deeplink: string; -}; - -export type EnvVarConfig = { - default?: string | null; - description?: string | null; - name: string; - /** - * When true, the field is shown prominently in the UI (not collapsed). - * Defaults to the value of `required` if not specified. - */ - primary?: boolean | null; - required?: boolean; - secret?: boolean; -}; - -export type Envs = { - [key: string]: string; -}; - -export type ErrorResponse = { - message: string; -}; - -/** - * Represents the different types of MCP extensions that can be added to the manager - */ -export type ExtensionConfig = { - description: string; - name: string; - type: 'sse'; - uri?: string | null; -} | { - args: Array; - available_tools?: Array; - bundled?: boolean | null; - cmd: string; - cwd?: string | null; - description: string; - env_keys?: Array; - envs?: Envs; - /** - * The name used to identify this extension - */ - name: string; - timeout?: number | null; - type: 'stdio'; -} | { - available_tools?: Array; - bundled?: boolean | null; - description: string; - display_name?: string | null; - /** - * The name used to identify this extension - */ - name: string; - timeout?: number | null; - type: 'builtin'; -} | { - available_tools?: Array; - bundled?: boolean | null; - description: string; - display_name?: string | null; - /** - * The name used to identify this extension - */ - name: string; - type: 'platform'; -} | { - available_tools?: Array; - bundled?: boolean | null; - description: string; - env_keys?: Array; - envs?: Envs; - headers?: { - [key: string]: string; - }; - /** - * The name used to identify this extension - */ - name: string; - /** - * Optional Unix domain socket path for HTTP-over-UDS transport. - * When set, the HTTP connection is routed through this socket while - * `uri` is used for the Host header and path. - * Use `@name` for Linux abstract sockets. - */ - socket?: string | null; - timeout?: number | null; - type: 'streamable_http'; - uri: string; -} | { - available_tools?: Array; - bundled?: boolean | null; - description: string; - /** - * Instructions for how to use these tools - */ - instructions?: string | null; - /** - * The name used to identify this extension - */ - name: string; - /** - * The tools provided by the frontend - */ - tools: Array; - type: 'frontend'; -} | { - available_tools?: Array; - /** - * The Python code to execute - */ - code: string; - /** - * Python package dependencies required by this extension - */ - dependencies?: Array | null; - description: string; - /** - * The name used to identify this extension - */ - name: string; - /** - * Timeout in seconds - */ - timeout?: number | null; - type: 'inline_python'; -}; - -/** - * Extension data containing all extension states - * Keys are in format "extension_name.version" (e.g., "todo.v0") - */ -export type ExtensionData = { - [key: string]: unknown; -}; - -export type ExtensionEntry = ExtensionConfig & { - enabled: boolean; -}; - -export type ExtensionLoadResult = { - error?: string | null; - name: string; - success: boolean; -}; - -export type ExtensionQuery = { - config: ExtensionConfig; - enabled: boolean; - name: string; -}; - -export type ExtensionResponse = { - extensions: Array; - warnings?: Array; -}; - -export type ForkRequest = { - copy: boolean; - timestamp?: number | null; - truncate: boolean; -}; - -export type ForkResponse = { - sessionId: string; -}; - -export type FrontendToolRequest = { - id: string; - toolCall: { - [key: string]: unknown; - }; -}; - -export type GetToolsQuery = { - extension_name?: string | null; - session_id: string; -}; - -export type GooseApp = McpAppResource & (WindowProps | null) & { - mcpServers?: Array; - prd?: string | null; -}; - -export type GooseMode = 'auto' | 'approve' | 'smart_approve' | 'chat'; - -export type Icon = { - mimeType?: string; - sizes?: Array; - src: string; - theme?: IconTheme | { - [key: string]: unknown; - }; -}; - -export type IconTheme = 'light' | 'dark'; - -export type ImageContent = { - _meta?: { - [key: string]: unknown; - }; - annotations?: Annotations | { - [key: string]: unknown; - }; - data: string; - mimeType: string; -}; - -export type InferenceMetadata = { - provider: string; - requestedModel: string; - resolvedModel?: string | null; -}; - -export type InspectJobResponse = { - processStartTime?: string | null; - runningDurationSeconds?: number | null; - sessionId?: string | null; -}; - -export type JsonObject = { - [key: string]: unknown; -}; - -export type KillJobResponse = { - message: string; -}; - -export type ListRecipeResponse = { - manifests: Array; -}; - -export type ListSchedulesResponse = { - jobs: Array; -}; - -export type LoadedProvider = { - config: DeclarativeProviderConfig; - is_editable: boolean; -}; - -/** - * MCP App Resource - * Represents a UI resource that can be rendered in an MCP App - */ -export type McpAppResource = { - _meta?: ResourceMetadata | null; - /** - * Base64-encoded binary content (alternative to text) - */ - blob?: string | null; - /** - * Optional description of what this resource does - */ - description?: string | null; - /** - * MIME type (should be "text/html;profile=mcp-app" for MCP Apps) - */ - mimeType: string; - /** - * Human-readable name of the resource - */ - name: string; - /** - * Text content of the resource (HTML for MCP Apps) - */ - text?: string | null; - /** - * URI of the resource (must use ui:// scheme) - */ - uri: string; -}; - -/** - * A message to or from an LLM - */ -export type Message = { - content: Array; - created: number; - id?: string | null; - metadata: MessageMetadata; - role: Role; -}; - -/** - * Content passed inside a message, which can be both simple content and tool content - */ -export type MessageContent = (TextContent & { - type: 'text'; -}) | (ImageContent & { - type: 'image'; -}) | (ToolRequest & { - type: 'toolRequest'; -}) | (ToolResponse & { - type: 'toolResponse'; -}) | (ToolConfirmationRequest & { - type: 'toolConfirmationRequest'; -}) | (ActionRequired & { - type: 'actionRequired'; -}) | (FrontendToolRequest & { - type: 'frontendToolRequest'; -}) | (ThinkingContent & { - type: 'thinking'; -}) | (RedactedThinkingContent & { - type: 'redactedThinking'; -}) | (SystemNotificationContent & { - type: 'systemNotification'; -}); - -export type MessageEvent = { - message: Message; - token_state: TokenState; - type: 'Message'; -} | { - error: string; - type: 'Error'; -} | { - reason: string; - token_state: TokenState; - type: 'Finish'; -} | { - message: { - [key: string]: unknown; - }; - request_id: string; - type: 'Notification'; -} | { - conversation: Conversation; - type: 'UpdateConversation'; -} | { - request_ids: Array; - type: 'ActiveRequests'; -} | { - type: 'Ping'; -}; - -/** - * Metadata for message visibility and model inference details - */ -export type MessageMetadata = { - /** - * Whether the message should be included in the agent's context window - */ - agentVisible: boolean; - inference?: InferenceMetadata | null; - /** - * 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. - */ - steer?: boolean; - /** - * Whether the message should be visible to the user in the UI - */ - userVisible: boolean; -}; - -export type ModelCapabilities = { - attachment: boolean; - reasoning: boolean; - temperature: boolean; - tool_call: boolean; -}; - -export type ModelConfig = { - context_limit?: number | null; - max_tokens?: number | null; - model_name: string; - reasoning?: boolean | null; - /** - * Provider-specific request parameters (e.g., anthropic_beta headers) - */ - request_params?: { - [key: string]: unknown; - } | null; - temperature?: number | null; - toolshim: boolean; - toolshim_model?: string | null; -}; - -/** - * Information about a model's capabilities - */ -export type ModelInfo = { - /** - * The maximum context length this model supports - */ - context_limit: number; - /** - * Currency for the costs (default: "$") - */ - currency?: string | null; - /** - * Cost per token for input in USD (optional) - */ - input_token_cost?: number | null; - /** - * The name of the model - */ - name: string; - /** - * Cost per token for output in USD (optional) - */ - output_token_cost?: number | null; - /** - * Whether this model supports reasoning/thinking controls - */ - reasoning?: boolean; - /** - * The underlying model resolved from provider metadata, when the configured model is an alias or endpoint. - */ - resolved_model?: string | null; - /** - * Whether this model supports cache control - */ - supports_cache_control?: boolean | null; -}; - -export type ModelInfoData = { - cache_read_token_cost?: number | null; - cache_write_token_cost?: number | null; - context_limit: number; - currency: string; - input_token_cost?: number | null; - max_output_tokens?: number | null; - model: string; - output_token_cost?: number | null; - provider: string; - reasoning: boolean; -}; - -export type ModelInfoQuery = { - model: string; - provider: string; -}; - -export type ModelInfoResponse = { - model_info?: ModelInfoData | null; - source: string; -}; - -export type ModelTemplate = { - capabilities: ModelCapabilities; - context_limit: number; - deprecated: boolean; - id: string; - name: string; -}; - -export type ParseRecipeRequest = { - content: string; -}; - -export type ParseRecipeResponse = { - recipe: Recipe; -}; - -export type Permission = 'always_allow' | 'allow_once' | 'cancel' | 'deny_once' | 'always_deny'; - -/** - * Enum representing the possible permission levels for a tool. - */ -export type PermissionLevel = 'always_allow' | 'ask_before' | 'never_allow'; - -/** - * Sandbox permissions for MCP Apps - * Specifies which browser capabilities the UI needs access to. - * Maps to the iframe Permission Policy `allow` attribute. - */ -export type PermissionsMetadata = { - /** - * Request camera access (maps to Permission Policy `camera` feature) - */ - camera?: boolean; - /** - * Request clipboard write access (maps to Permission Policy `clipboard-write` feature) - */ - clipboardWrite?: boolean; - /** - * Request geolocation access (maps to Permission Policy `geolocation` feature) - */ - geolocation?: boolean; - /** - * Request microphone access (maps to Permission Policy `microphone` feature) - */ - microphone?: boolean; -}; - -export type PrincipalType = 'Extension' | 'Tool'; - -export type PromptContentResponse = { - content: string; - default_content: string; - is_customized: boolean; - name: string; -}; - -export type PromptsListResponse = { - prompts: Array