diff --git a/.env.sample b/.env.sample deleted file mode 100644 index aebe5cca44..0000000000 --- a/.env.sample +++ /dev/null @@ -1,6 +0,0 @@ -POSTHOG_API_KEY=key-goes-here - -# Roo Code Cloud / Local Development -CLERK_BASE_URL=https://epic-chamois-85.clerk.accounts.dev -ROO_CODE_API_URL=http://localhost:3000 -ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy/v1 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a3daa0f144..e2e8fa34b6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ # These owners will be the default owners for everything in the repo -* @mrubens @cte @jr +* @mrubens @cte @jr @hannesrudolph @daniel-lxs diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 0351ad1930..8c7969776d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,5 @@ blank_issues_enabled: false contact_links: - - name: Feature Request - url: https://github.com/RooCodeInc/Roo-Code/discussions/categories/feature-requests - about: Share and vote on feature requests for Roo Code - name: Leave a Review url: https://marketplace.visualstudio.com/items?itemName=RooVeterinaryInc.roo-cline&ssr=false#review-details about: Enjoying Roo Code? Leave a review here! diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml new file mode 100644 index 0000000000..20961a9f2d --- /dev/null +++ b/.github/workflows/cli-release.yml @@ -0,0 +1,394 @@ +name: CLI Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 0.1.0). Leave empty to use package.json version.' + required: false + type: string + dry_run: + description: 'Dry run (build and test but do not create release).' + required: false + type: boolean + default: false + +jobs: + # Build CLI for each platform. + build: + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + platform: darwin-arm64 + runs-on: macos-latest + - os: ubuntu-latest + platform: linux-x64 + runs-on: ubuntu-latest + - os: ubuntu-24.04-arm + platform: linux-arm64 + runs-on: ubuntu-24.04-arm + + runs-on: ${{ matrix.runs-on }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + + - name: Get version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + else + VERSION=$(node -p "require('./apps/cli/package.json').version") + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=cli-v$VERSION" >> $GITHUB_OUTPUT + echo "Using version: $VERSION" + + - name: Build extension bundle + run: pnpm bundle + + - name: Build CLI + run: pnpm --filter @roo-code/cli build + + - name: Create release tarball + id: tarball + env: + VERSION: ${{ steps.version.outputs.version }} + PLATFORM: ${{ matrix.platform }} + run: | + RELEASE_DIR="roo-cli-${PLATFORM}" + TARBALL="roo-cli-${PLATFORM}.tar.gz" + + # Clean up any previous build. + rm -rf "$RELEASE_DIR" + rm -f "$TARBALL" + + # Create directory structure. + mkdir -p "$RELEASE_DIR/bin" + mkdir -p "$RELEASE_DIR/lib" + mkdir -p "$RELEASE_DIR/extension" + + # Copy CLI dist files. + echo "Copying CLI files..." + cp -r apps/cli/dist/* "$RELEASE_DIR/lib/" + + # Create package.json for npm install. + echo "Creating package.json..." + node -e " + const pkg = require('./apps/cli/package.json'); + const newPkg = { + name: '@roo-code/cli', + version: '$VERSION', + type: 'module', + dependencies: { + '@inkjs/ui': pkg.dependencies['@inkjs/ui'], + '@trpc/client': pkg.dependencies['@trpc/client'], + 'commander': pkg.dependencies.commander, + 'fuzzysort': pkg.dependencies.fuzzysort, + 'ink': pkg.dependencies.ink, + 'p-wait-for': pkg.dependencies['p-wait-for'], + 'react': pkg.dependencies.react, + 'superjson': pkg.dependencies.superjson, + 'zustand': pkg.dependencies.zustand + } + }; + console.log(JSON.stringify(newPkg, null, 2)); + " > "$RELEASE_DIR/package.json" + + # Copy extension bundle. + echo "Copying extension bundle..." + cp -r src/dist/* "$RELEASE_DIR/extension/" + + # Add package.json to extension directory for CommonJS. + echo '{"type": "commonjs"}' > "$RELEASE_DIR/extension/package.json" + + # Find and copy ripgrep binary. + echo "Looking for ripgrep binary..." + RIPGREP_PATH=$(find node_modules -path "*/@vscode/ripgrep/bin/rg" -type f 2>/dev/null | head -1) + if [ -n "$RIPGREP_PATH" ] && [ -f "$RIPGREP_PATH" ]; then + echo "Found ripgrep at: $RIPGREP_PATH" + mkdir -p "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin" + cp "$RIPGREP_PATH" "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/" + chmod +x "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/rg" + mkdir -p "$RELEASE_DIR/bin" + cp "$RIPGREP_PATH" "$RELEASE_DIR/bin/" + chmod +x "$RELEASE_DIR/bin/rg" + else + echo "Warning: ripgrep binary not found" + fi + + # Create the wrapper script + echo "Creating wrapper script..." + printf '%s\n' '#!/usr/bin/env node' \ + '' \ + "import { fileURLToPath } from 'url';" \ + "import { dirname, join } from 'path';" \ + '' \ + 'const __filename = fileURLToPath(import.meta.url);' \ + 'const __dirname = dirname(__filename);' \ + '' \ + '// Set environment variables for the CLI' \ + "process.env.ROO_CLI_ROOT = join(__dirname, '..');" \ + "process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension');" \ + "process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg');" \ + '' \ + '// Import and run the actual CLI' \ + "await import(join(__dirname, '..', 'lib', 'index.js'));" \ + > "$RELEASE_DIR/bin/roo" + + chmod +x "$RELEASE_DIR/bin/roo" + + # Create empty .env file. + touch "$RELEASE_DIR/.env" + + # Create tarball. + echo "Creating tarball..." + tar -czvf "$TARBALL" "$RELEASE_DIR" + + # Clean up release directory. + rm -rf "$RELEASE_DIR" + + # Create checksum. + if command -v sha256sum &> /dev/null; then + sha256sum "$TARBALL" > "${TARBALL}.sha256" + elif command -v shasum &> /dev/null; then + shasum -a 256 "$TARBALL" > "${TARBALL}.sha256" + fi + + echo "tarball=$TARBALL" >> $GITHUB_OUTPUT + echo "Created: $TARBALL" + ls -la "$TARBALL" + + - name: Verify tarball + env: + PLATFORM: ${{ matrix.platform }} + run: | + TARBALL="roo-cli-${PLATFORM}.tar.gz" + + # Create temp directory for verification. + VERIFY_DIR=$(mktemp -d) + + # Extract and verify structure. + tar -xzf "$TARBALL" -C "$VERIFY_DIR" + + echo "Verifying tarball contents..." + ls -la "$VERIFY_DIR/roo-cli-${PLATFORM}/" + + # Check required files exist. + test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/bin/roo" || { echo "Missing bin/roo"; exit 1; } + test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/lib/index.js" || { echo "Missing lib/index.js"; exit 1; } + test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/package.json" || { echo "Missing package.json"; exit 1; } + test -d "$VERIFY_DIR/roo-cli-${PLATFORM}/extension" || { echo "Missing extension directory"; exit 1; } + + echo "Tarball verification passed!" + + # Cleanup. + rm -rf "$VERIFY_DIR" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: cli-${{ matrix.platform }} + path: | + roo-cli-${{ matrix.platform }}.tar.gz + roo-cli-${{ matrix.platform }}.tar.gz.sha256 + retention-days: 7 + + # Create GitHub release with all platform artifacts. + release: + needs: build + runs-on: ubuntu-latest + if: ${{ !inputs.dry_run }} + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + else + VERSION=$(node -p "require('./apps/cli/package.json').version") + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=cli-v$VERSION" >> $GITHUB_OUTPUT + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Prepare release files + run: | + mkdir -p release + find artifacts -name "*.tar.gz" -exec cp {} release/ \; + find artifacts -name "*.sha256" -exec cp {} release/ \; + ls -la release/ + + - name: Extract changelog + id: changelog + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + CHANGELOG_FILE="apps/cli/CHANGELOG.md" + + if [ -f "$CHANGELOG_FILE" ]; then + # Extract content between version headers. + CONTENT=$(awk -v version="$VERSION" ' + BEGIN { found = 0; content = ""; target = "[" version "]" } + /^## \[/ { + if (found) { exit } + if (index($0, target) > 0) { found = 1; next } + } + found { content = content $0 "\n" } + END { print content } + ' "$CHANGELOG_FILE") + + if [ -n "$CONTENT" ]; then + echo "Found changelog content" + echo "content<> $GITHUB_OUTPUT + echo "$CONTENT" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + else + echo "No changelog content found for version $VERSION" + echo "content=" >> $GITHUB_OUTPUT + fi + else + echo "No changelog file found" + echo "content=" >> $GITHUB_OUTPUT + fi + + - name: Generate checksums summary + id: checksums + run: | + echo "checksums<> $GITHUB_OUTPUT + cat release/*.sha256 >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Check for existing release + id: check_release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + run: | + if gh release view "$TAG" &> /dev/null; then + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Delete existing release + if: steps.check_release.outputs.exists == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + run: | + echo "Deleting existing release $TAG..." + gh release delete "$TAG" --yes || true + git push origin ":refs/tags/$TAG" || true + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + TAG: ${{ steps.version.outputs.tag }} + CHANGELOG_CONTENT: ${{ steps.changelog.outputs.content }} + CHECKSUMS: ${{ steps.checksums.outputs.checksums }} + run: | + NOTES_FILE=$(mktemp) + + if [ -n "$CHANGELOG_CONTENT" ]; then + echo "## What's New" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "$CHANGELOG_CONTENT" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + fi + + echo "## Installation" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo '```bash' >> "$NOTES_FILE" + echo "curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "Or install a specific version:" >> "$NOTES_FILE" + echo '```bash' >> "$NOTES_FILE" + echo "ROO_VERSION=$VERSION curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "## Requirements" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "- Node.js 20 or higher" >> "$NOTES_FILE" + echo "- macOS Apple Silicon (M1/M2/M3/M4), Linux x64, or Linux ARM64" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "## Usage" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo '```bash' >> "$NOTES_FILE" + echo "# Run a task" >> "$NOTES_FILE" + echo 'roo "What is this project?"' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "# See all options" >> "$NOTES_FILE" + echo "roo --help" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "## Platform Support" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "This release includes binaries for:" >> "$NOTES_FILE" + echo '- `roo-cli-darwin-arm64.tar.gz` - macOS Apple Silicon (M1/M2/M3)' >> "$NOTES_FILE" + echo '- `roo-cli-linux-x64.tar.gz` - Linux x64' >> "$NOTES_FILE" + echo '- `roo-cli-linux-arm64.tar.gz` - Linux ARM64' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "## Checksums" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" + echo "$CHECKSUMS" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" + + gh release create "$TAG" \ + --title "Roo Code CLI v$VERSION" \ + --notes-file "$NOTES_FILE" \ + --prerelease \ + release/* + + rm -f "$NOTES_FILE" + echo "Release created: https://github.com/${{ github.repository }}/releases/tag/$TAG" + + # Summary job for dry runs + summary: + needs: build + runs-on: ubuntu-latest + if: ${{ inputs.dry_run }} + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Show build summary + run: | + echo "## Dry Run Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "The following artifacts were built:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + find artifacts -name "*.tar.gz" | while read f; do + SIZE=$(ls -lh "$f" | awk '{print $5}') + echo "- $(basename $f) ($SIZE)" >> $GITHUB_STEP_SUMMARY + done + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Checksums" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + cat artifacts/*/*.sha256 >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 364b391a01..1dbcdc6a36 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ bin/ # Local prompts and rules /local-prompts +AGENTS.local.md # Test environment .test_env diff --git a/.roo/commands/cli-release.md b/.roo/commands/cli-release.md index 70b3698528..5e68e4df2d 100644 --- a/.roo/commands/cli-release.md +++ b/.roo/commands/cli-release.md @@ -1,5 +1,5 @@ --- -description: "Create a new release of the Roo Code CLI" +description: "Prepare a new release of the Roo Code CLI" argument-hint: "[version-description]" mode: code --- @@ -84,41 +84,3 @@ mode: code - [ ] All CI checks pass" \ --base main ``` - -7. Wait for PR approval and merge: - - - Request review if required by your workflow - - Ensure CI checks pass - - Merge the PR using: `gh pr merge --squash --delete-branch` - - Or merge via the GitHub UI - -8. Run the release script from the monorepo root: - - ```bash - # Ensure you're on the updated main branch after the PR merge - git checkout main - git pull origin main - - # Run the release script - ./apps/cli/scripts/release.sh - ``` - - The release script will automatically: - - - Build the extension and CLI - - Create a platform-specific tarball - - Verify the installation works correctly (runs --help, --version, and e2e test) - - Extract changelog content and include it in the GitHub release notes - - Create the GitHub release with the tarball attached - -9. After a successful release, verify: - - Check the release page: https://github.com/RooCodeInc/Roo-Code/releases - - Verify the "What's New" section contains the changelog content - - Test installation: `curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh` - -**Notes:** - -- The release script requires GitHub CLI (`gh`) to be installed and authenticated -- If a release already exists for the tag, the script will prompt to delete and recreate it -- The script creates a tarball for the current platform only (darwin-arm64, darwin-x64, linux-arm64, or linux-x64) -- Multi-platform releases require running the script on each platform and manually uploading additional tarballs diff --git a/.roo/rules-docs-extractor/1_extraction_workflow.xml b/.roo/rules-docs-extractor/1_extraction_workflow.xml index c707fa7809..200e48da0c 100644 --- a/.roo/rules-docs-extractor/1_extraction_workflow.xml +++ b/.roo/rules-docs-extractor/1_extraction_workflow.xml @@ -1,163 +1,113 @@ - - The Docs Extractor mode has exactly two workflow paths: - 1) Verify provided documentation for factual accuracy against the codebase - 2) Generate source material for user-facing docs about a requested feature or aspect of the codebase + + Extract raw facts from a codebase about a feature or aspect. + Output is structured data for documentation teams to use. + Do NOT write documentation. Do NOT format prose. Do NOT make structure decisions. + - Outputs are designed to support explanatory documentation (not merely descriptive): - - Capture why users need steps and why certain actions are restricted - - Surface constraints, limitations, and trade‑offs - - Provide troubleshooting playbooks (symptoms → causes → fixes → prevention) - - Recommend targeted visuals for complex states (not step‑by‑step screenshots) - - This mode does not generate final user documentation; it produces verification and source-material reports for docs teams. - - - + - Parse Request + Identify Target - Identify the feature/aspect in the user's request. - Decide path: verification vs. source-material generation. - For source-material: capture audience (user or developer) and depth (overview vs task-focused). - For verification: identify the documentation to be verified (provided text/links/files). - Note any specific areas to emphasize or check. + Parse the user's request to identify the feature/aspect + Clarify scope if ambiguous (ask one question max) - Discover Feature + Discover Code - Locate relevant code and assets using appropriate discovery methods. - Identify entry points and key components that affect user experience. - Map the high-level workflow a user follows. + Use codebase_search to find relevant files + Identify entry points, components, and related code + Map the boundaries of the feature - - - UI components and their interactions - User workflows and decision points - Configuration that changes user-visible behavior - Error states, messages, and recovery - Benefits, limits, prerequisites, and version notes - Why this exists: user goals, constraints, and design intent - “Cannot do” boundaries: permissions, invariants, and business rules - Troubleshooting: symptoms, likely causes, diagnostics, fixes, prevention - Common pitfalls and anti‑patterns (what to avoid and why) - Decision rationale and trade‑offs that affect user choices - Complex UI states that merit visuals (criteria for screenshots/diagrams) - + + Extract Facts + + Read code and extract facts into categories (see fact_categories) + Record file paths as sources for each fact + Do NOT interpret, summarize, or explain - just extract + + - - - Generate Source Material for User-Facing Docs - Extract concise, user-oriented facts and structure them for documentation teams. - - - Scope and Audience - - Confirm the feature/aspect and intended audience. - List primary tasks the audience performs with this feature. - - - - Extract User-Facing Facts - - Summarize what the feature does and key benefits. - Explain why users need this (jobs-to-be-done, outcomes) and when to use it. - Document step-by-step user workflows and UI interactions. - Capture configuration options that impact user behavior (name, default, effect). - Clarify constraints, limits, and “cannot do” cases with rationale. - Identify common pitfalls and anti-patterns; include “Do/Don’t” guidance. - List common errors with user-facing messages, diagnostics, fixes, and prevention. - Record prerequisites, permissions, and compatibility/version notes. - Flag complex states that warrant visuals (what to show and why), not every step. - - - - Create Source Material Report - - Organize findings using user-focused structure (benefits, use cases, how it works, configuration, FAQ, troubleshooting). - Include short code/UI snippets or paths where relevant. - Create `EXTRACTION-[feature].md` with findings. - Highlight items that need visuals (screenshots/diagrams). - - - - Executive summary of the feature/aspect - - Why it matters (goals, value, when to use) - - User workflows and interactions - - Configuration and setup affecting users (with defaults and impact) - - Constraints and limitations (with rationale) - - Common scenarios and troubleshooting playbooks (symptoms → causes → fixes → prevention) - - Do/Don’t and anti‑patterns - - Recommended visuals (what complex states to illustrate and why) - - FAQ and tips - - Version/compatibility notes - - - - + + Output Structured Data + + Write extraction to .roo/extraction/EXTRACT-[feature].yaml + Use the output schema (see output_format.xml) + + + - - Verify Documentation Accuracy - Check provided documentation against codebase reality and actual UX. - - - Analyze Provided Documentation - - Parse the documentation to identify claims and descriptions. - Extract technical or user-facing specifics mentioned. - Note workflows, configuration, and examples described. - - - - Verify Against Codebase - - Check claims against actual implementation and UX. - Verify endpoints/parameters if referenced. - Confirm configuration options and defaults. - Validate code snippets and examples. - Ensure described workflows match implementation. - - - - Create Verification Report - - Categorize findings by severity (Critical, Major, Minor). - List inaccuracies with the correct information. - Identify missing important information. - Provide specific corrections and suggestions. - Create `VERIFICATION-[feature].md` with findings. - - - - Verification summary (Accurate/Needs Updates) - - Critical inaccuracies that could mislead users - - Corrections and missing information - - Explanatory gaps (missing “why”, constraints, or decision rationale) - - Troubleshooting coverage gaps (missing symptoms/diagnostics/fixes/prevention) - - Visual recommendations (which complex states warrant screenshots/diagrams) - - Suggestions for clarity improvements - - - - - + + + + Feature name as it appears in code + File paths where feature is implemented + Entry points (commands, UI elements, API endpoints) + + - - - Audience and scope captured - User workflows and UI interactions documented - User-impacting configuration recorded - Common errors and troubleshooting documented - Report organized for documentation team use - - - All documentation claims verified - Inaccuracies identified and corrected - Missing information noted - Suggestions for improvement provided - Clear verification report created - - + + + What the feature does (from code logic) + Inputs it accepts + Outputs it produces + Side effects (files created, state changed, etc.) + + + + + + Settings/options that affect behavior + Default values + Valid ranges or allowed values + Where configured (settings file, env var, UI) + + + + + + Prerequisites and dependencies + Limitations (what it cannot do) + Permissions required + Compatibility requirements + + + + + + Error conditions in code + Error messages (exact text) + Recovery paths in code + + + + + + UI components involved + User-visible labels and text + Interaction patterns + + + + + + Other features this interacts with + External APIs or services called + Events emitted or consumed + + + + + + Extract facts, not opinions + Include source file paths for every fact + Use code identifiers and exact strings from source + Do NOT paraphrase - quote when possible + Do NOT decide what's important - extract everything relevant + Do NOT format for end users - output is for docs team + \ No newline at end of file diff --git a/.roo/rules-docs-extractor/2_documentation_patterns.xml b/.roo/rules-docs-extractor/2_documentation_patterns.xml deleted file mode 100644 index da743483da..0000000000 --- a/.roo/rules-docs-extractor/2_documentation_patterns.xml +++ /dev/null @@ -1,357 +0,0 @@ - - - Standard templates for structuring extracted documentation. - - - - -# [Feature Name] - -[Description of what the feature does and why a user should care.] - -### Key Features -- [Benefit-oriented feature 1] -- [Benefit-oriented feature 2] -- [Benefit-oriented feature 3] - ---- - -## Use Case - -**Before**: [Description of the old way] -- [Pain point 1] -- [Pain point 2] - -**With this feature**: [Description of the new experience.] - -## How it Works - -[Simple explanation of the feature's operation.] - -[Suggest visual representations where helpful.] - ---- - -## Configuration - -[Explanation of relevant settings.] - -1. **[Setting Name]**: - - **Setting**: `[technical_name]` - - **Description**: [What this does.] - - **Default**: [Default value and its meaning.] - -2. **[Setting Name]**: - - **Setting**: `[technical_name]` - - **Description**: [What this does.] - - **Default**: [Default value and its meaning.] - ---- - -## FAQ - -**"[User question]"** -- [Answer.] -- [Optional tip.] - -**"[User question]"** -- [Answer.] -- [Optional tip.] - - - - -# [Feature Name] Technical Documentation - -## Table of Contents -1. Overview -2. Quick Start -3. Architecture -4. API Reference -5. Configuration -6. User Guide -7. Developer Guide -8. Security -9. Performance -10. Troubleshooting -11. FAQ -12. Changelog -13. References - -[Use this as an internal source-material outline for technical sections; not for final docs.] - - - - - - - - - - --- - Separate sections. - - - - - - - - Show tool output or UI elements. - Use actual file paths and setting names. - Include common errors and solutions. - - - - - - - - - - - - - - - Tutorials - Use cases - Troubleshooting - Benefits - - - - - - - Code examples - API specs - Integration patterns - Performance - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - [Link Text](#section-anchor) - [See Configuration Guide](#configuration) - - - - [Link Text](https://external.url) - [Official Documentation](https://docs.example.com) - - - - - - - - - - - \ No newline at end of file diff --git a/.roo/rules-docs-extractor/2_verification_workflow.xml b/.roo/rules-docs-extractor/2_verification_workflow.xml new file mode 100644 index 0000000000..4635d8eb45 --- /dev/null +++ b/.roo/rules-docs-extractor/2_verification_workflow.xml @@ -0,0 +1,85 @@ + + + Compare provided documentation against actual codebase implementation. + Output is a structured diff of claims vs reality. + Do NOT rewrite the docs. Do NOT suggest wording. Just report discrepancies. + + + + + Receive Documentation + + User provides documentation to verify (text, file, or URL) + Identify the feature/aspect being documented + + + + + Extract Claims + + Parse the documentation into discrete claims + Tag each claim with a category (behavior, config, constraint, etc.) + Record the exact quote from the documentation + + + + + Verify Against Code + + For each claim, find the relevant code + Compare claim to actual implementation + Record: ACCURATE, INACCURATE, OUTDATED, MISSING_CONTEXT, or UNVERIFIABLE + For inaccuracies, record what the code actually does + + + + + Output Verification Report + + Write verification to .roo/extraction/VERIFY-[feature].yaml + Use the output schema (see output_format.xml) + + + + + + + Claim matches implementation + + + Claim contradicts implementation + What the code actually does + + + Claim was once true but code has changed + Current behavior + + + Claim is true but omits important information + The missing context + + + Cannot find code to verify this claim + Search paths attempted + + + + + behavior + configuration + constraint + error_handling + ui + integration + prerequisite + + + + Verify facts, not writing quality + Report what code does, not what docs should say + Include source file paths as evidence + Do NOT suggest documentation rewrites + Do NOT evaluate if docs are "good" - only if they're accurate + Quote exact code when showing discrepancies + + \ No newline at end of file diff --git a/.roo/rules-docs-extractor/3_analysis_techniques.xml b/.roo/rules-docs-extractor/3_analysis_techniques.xml deleted file mode 100644 index 12b3d1fd26..0000000000 --- a/.roo/rules-docs-extractor/3_analysis_techniques.xml +++ /dev/null @@ -1,349 +0,0 @@ - - - Heuristics for analyzing a codebase to extract reliable, user-facing documentation. - This file contains technique checklists only—no tool instructions or invocations. - - - - - Find and analyze UI components and their interactions - - Start from feature or route directories and enumerate components related to the requested topic. - Differentiate container vs presentational components; note composition patterns. - Trace inputs/outputs: props, state, context, events, and side effects. - Record conditional rendering that affects user-visible states. - - - Primary components and responsibilities. - Props/state/context that change behavior. - High-level dependency/composition map. - - - - - Analyze styling and visual elements - - Identify design tokens and utility classes used to drive layout and state. - Capture responsive behavior and breakpoint rules that materially change UX. - Document visual affordances tied to state (loading, error, disabled). - - - Key classes/selectors influencing layout/state. - Responsive behavior summary and breakpoints. - - - - - Map user interactions and navigation flows - - Route definitions and navigation - Form submissions and validations - Button clicks and event handlers - State changes and UI updates - Loading and error states - - - Outline entry points and expected outcomes for each primary flow. - Summarize validation rules and failure states the user can encounter. - Record redirects and deep-link behavior relevant to the feature. - - - Flow diagrams or bullet sequences for main tasks. - Validation conditions and error messages. - Navigation transitions and guards. - - - - - Analyze how the system communicates with users - - Error messages and alerts - Success notifications - Loading indicators - Tooltips and help text - Confirmation dialogs - Progress indicators - - - Map message triggers to the user actions that cause them. - Capture severity, persistence, and dismissal behavior. - Note localization or accessibility considerations in messages. - - - Catalog of messages with purpose and conditions. - Loading/progress patterns and timeouts. - - - - - Check for accessibility features and compliance - - ARIA labels and roles - Keyboard navigation support - Screen reader compatibility - Focus management - Color contrast considerations - - - Confirm interactive elements have clear focus and labels. - Describe keyboard-only navigation paths for core flows. - - - Accessibility gaps affecting task completion. - - - - - Analyze responsive design and mobile experience - - Breakpoint definitions - Mobile-specific components - Touch event handlers - Viewport configurations - Media queries - - - Summarize layout changes across breakpoints that alter workflow. - Note touch targets and gestures required on mobile. - - - Table of key differences per breakpoint. - - - - - - - Understand feature entry points and control flow - - Identify main functions, controllers, or route handlers. - Trace execution and decision branches. - Document input validation and preconditions. - - - Entry points list and short purpose statements. - Decision matrix or flow sketch. - - - - - Extract API specifications from code - - - - HTTP method and route path - Path/query parameters - Request/response schemas - Status codes and error bodies - - - - - Schema and input types - Resolvers and return types - Field arguments and constraints - - - - - - - Map dependencies and integration points - - Imports and module boundaries - Package and runtime dependencies - External API/SDK usage - DB connections and migrations - Messaging/queue/event streams - Filesystem or network side effects - - - Dependency graph summary and hot spots. - List of external integrations and auth methods. - - - - - Extract data models, schemas, and type definitions - - - - interfaces, types, classes, enums - - - - Schema definitions, migration files, ORM models - - - - JSON Schema, Joi/Yup/Zod schemas, validation decorators - - - - Canonical definitions and field constraints. - Entity relationships and ownership. - - - - - Identify and document business rules - - Complex conditionals - Calculation functions - Validation rules - State machines - Domain-specific constants and algorithms - - - Why the logic exists (business need) - When the logic applies (conditions) - What the logic does (transformation) - Edge cases and invariants - Impact of changes - - - - - Document error handling and recovery - - try/catch blocks and error boundaries - Custom error classes and codes - Logging, fallbacks, retries, circuit breakers - - - Error taxonomy and user-facing messages. - Recovery/rollback strategies and timeouts. - - - - - Identify security measures and vulnerabilities - - JWT, sessions, OAuth, API keys - RBAC, permission checks, ownership validation - Encryption, hashing, sensitive data handling - Sanitization and injection prevention - - - Threat surfaces and mitigations relevant to the feature. - - - - - Identify performance factors and optimization opportunities - - Expensive loops/algorithms - DB query patterns (e.g., N+1) - Caching strategies - Concurrency and async usage - Batching and resource pooling - Memory management and object lifetimes - - - Time/space complexity - DB query counts - API response times - Memory usage - Concurrency handling - - - - - Assess test coverage at a useful granularity - - - Function-level coverage and edge cases - - - Workflow coverage and contract boundaries - - - Endpoint success/failure paths and schemas - - - - List of critical behaviors missing tests. - - - - - Extract configuration options and their impacts - - .env files, config files, CLI args, feature flags - - - Default values and valid ranges - Behavioral impact of each option - Dependencies between options - Security implications - - - - - - - Map user workflows through the feature - - Identify entry points (UI, API, CLI) - Trace user actions and decision points - Map data transformations - Identify outcomes and completion criteria - - - Flow diagrams, procedures, decision trees, state diagrams - - - - - Document integration with other systems - - Sync API calls, async messaging, events, batch processing, streaming - - - Protocols, auth, error handling, data transforms, SLAs - - - - - - - Summarize version constraints and compatibility - - package manifests, READMEs, migration guides, breaking changes docs - - - Minimum/recommended versions and notable constraints. - - - - - Track deprecations and migrations - - Explicit deprecation notices and TODO markers - Legacy code paths and adapters - - - Deprecation date and removal timeline - Migration path and alternatives - - - - - - - - Public APIs documented with inputs/outputs and errors - Examples for complex features - Error scenarios covered with recovery guidance - Config options explained with defaults and impacts - Security considerations addressed - - - - - Cyclomatic complexity - Code duplication - Test coverage and gaps - Documentation coverage for user-visible behaviors - Known technical debt affecting UX - - - - \ No newline at end of file diff --git a/.roo/rules-docs-extractor/3_output_format.xml b/.roo/rules-docs-extractor/3_output_format.xml new file mode 100644 index 0000000000..185f7b23b8 --- /dev/null +++ b/.roo/rules-docs-extractor/3_output_format.xml @@ -0,0 +1,133 @@ + + + Structured data output formats for extraction and verification. + All output is YAML. No prose. No markdown formatting. + This data feeds into documentation-writer mode. + + + + Schema for EXTRACT-[feature].yaml files + + + + + Schema for VERIFY-[feature].yaml files + + + + + Use YAML, not JSON or markdown + Include source file:line for every fact + Quote exact strings from code using double quotes + Use null for unknown/missing values, not empty strings + Keep descriptions factual and brief - one line max + Do NOT add commentary, suggestions, or explanations + + + + EXTRACT-[feature-slug].yaml + VERIFY-[feature-slug].yaml + .roo/extraction/ + + \ No newline at end of file diff --git a/.roo/rules-docs-extractor/4_communication_guidelines.xml b/.roo/rules-docs-extractor/4_communication_guidelines.xml deleted file mode 100644 index 43ec8479fc..0000000000 --- a/.roo/rules-docs-extractor/4_communication_guidelines.xml +++ /dev/null @@ -1,298 +0,0 @@ - - - Guidelines for user communication and output formatting. - - - - - Act on the user's request immediately. - Only ask for clarification if the request is ambiguous. - - - - - Multiple features with similar names are found. - The request is ambiguous. - The user explicitly asks for options. - - - - - - - Starting a major analysis phase. - Extraction is complete. - Unexpected complexity is found. - - - - - - - - - - - Alert user to security concerns found during analysis. - - - Note deprecated features needing migration docs. - - - Highlight code that lacks inline documentation. - - - Warn about complex dependency chains. - - - - - - - - - - - - - - - Use # for main title, ## for major sections, ### for subsections. - Never skip heading levels. - - - - Always specify language for syntax highlighting (e.g., typescript, json, bash). - Include file paths as comments where relevant. - -```typescript -// src/auth/auth.service.ts -export class AuthService { - async validateUser(email: string, password: string): Promise { - // Implementation - } -} -``` - - - - - Use tables for structured data like configs. - Include headers and align columns. - Keep cell content brief. - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `JWT_SECRET` | string | - | Secret key for JWT signing | -| `JWT_EXPIRATION` | string | '15m' | Token expiration time | - - - - - Use bullets for unordered lists, numbers for sequential steps. - Keep list items parallel in structure. - - - - - - [Link text](#section-anchor) - Use lowercase, hyphenated anchors. Test all links. - - - - [Link text](https://example.com) - Use HTTPS. Link to official docs. - - - - `path/to/file.ts` - Use relative paths from project root, in backticks. - - - - - - - > ⚠️ **Warning**: [message] - Security, breaking changes, deprecations. - - - > 📝 **Note**: [message] - Important info, clarifications. - - - > 💡 **Tip**: [message] - Best practices, optimizations. - - - - - ---- -Feature: Authentication System -Version: 2.1.0 -Last Updated: 2024-01-15 -Status: Stable ---- - - - - - - - - Be direct, not conversational. - Use active voice. - Lead with benefits. - Use concrete examples. - Keep paragraphs short. - Avoid unnecessary technical details. - - - - - Technical and direct. - Standard programming terms. - Code snippets, implementation details. - - - Instructional, step-by-step. - Simple language, no jargon. - Screenshots, real-world scenarios. - - - - - - - Summary of analysis performed. - Key findings or issues identified. - Report file location. - Recommended next steps. - - - -Feature extraction complete for the authentication system. - -**Extraction Report**: `EXTRACTION-authentication-system.md` - -**Technical Summary**: -- JWT-based authentication with refresh tokens -- 5 API endpoints (login, logout, refresh, register, profile) -- 12 configuration options -- bcrypt password hashing, rate limiting - -**Non-Technical Summary**: -- Users can register, login, and manage sessions -- Supports "remember me" functionality -- Automatic session refresh for seamless experience -- Account lockout after failed attempts - -**Documentation Considerations**: -- Token expiration times need clear explanation -- Password requirements should be prominently displayed -- Error messages need user-friendly translations - -The extraction report contains all details needed for comprehensive documentation. - - - -Documentation verification complete for the authentication system. - -**Verification Report**: `VERIFICATION-authentication-system.md` - -**Overall Assessment**: Needs Updates - -**Critical Issues Found**: -1. JWT_SECRET documented as optional, but it's required -2. Token expiration listed as 30m, actual is 15m -3. Missing documentation for rate limiting feature - -**Technical Corrections**: 7 items -**Missing Information**: 4 sections -**Clarity Improvements**: 3 suggestions - -Please review the verification report for specific corrections needed. - - - - - - - - Could not find a feature matching "[feature name]". Similar features found: - - [List similar features] - Document one of these instead? - - - - - - Code for [feature] has limited inline documentation. Extracting from code structure, tests, and usage patterns. - - - - - - This feature is complex. Choose documentation scope: - - Document comprehensively - - Focus on core functionality - - Split into multiple documents - - - - - - - - No placeholder content remains. - Code examples are correct. - Links and cross-references work. - Tables are formatted correctly. - Version info is included. - Filename follows conventions. - - - \ No newline at end of file diff --git a/.roomodes b/.roomodes index 01f6ed4505..7950e67f5f 100644 --- a/.roomodes +++ b/.roomodes @@ -88,27 +88,6 @@ customModes: - fileRegex: (apps/vscode-e2e/.*\.(ts|js)$|packages/types/.*\.ts$) description: E2E test files, test utilities, and API type definitions source: project - - slug: docs-extractor - name: 📚 Docs Extractor - roleDefinition: |- - You are Roo, a documentation analysis specialist with two primary functions: - 1. Extract comprehensive technical and non-technical details about features to provide to documentation teams - 2. Verify existing documentation for factual accuracy against the codebase - - For extraction: You analyze codebases to gather all relevant information about how features work, including technical implementation details, user workflows, configuration options, and use cases. You organize this information clearly for documentation teams to use. - - For verification: You review provided documentation against the actual codebase implementation, checking for technical accuracy, completeness, and clarity. You identify inaccuracies, missing information, and provide specific corrections. - - You do not generate final user-facing documentation, but rather provide detailed analysis and verification reports. - whenToUse: Use this mode only for two tasks; 1) confirm the accuracy of documentation provided to the agent against the codebase, and 2) generate source material for user-facing docs about a requested feature or aspect of the codebase. - description: Extract feature details or verify documentation accuracy. - groups: - - read - - - edit - - fileRegex: (EXTRACTION-.*\.md$|VERIFICATION-.*\.md$|DOCS-TEMP-.*\.md$|\.roo/docs-extractor/.*\.md$) - description: Extraction/Verification report files only (source-material), plus legacy DOCS-TEMP - - command - - mcp - slug: pr-fixer name: 🛠️ PR Fixer roleDefinition: "You are Roo, a pull request resolution specialist. Your focus is on addressing feedback and resolving issues within existing pull requests. Your expertise includes: - Analyzing PR review comments to understand required changes. - Checking CI/CD workflow statuses to identify failing tests. - Fetching and analyzing test logs to diagnose failures. - Identifying and resolving merge conflicts. - Guiding the user through the resolution process." @@ -236,3 +215,26 @@ customModes: - command - mcp source: project + - slug: docs-extractor + name: 📚 Docs Extractor + roleDefinition: |- + You are Roo Code, a codebase analyst who extracts raw facts for documentation teams. + You do NOT write documentation. You extract and organize information. + + Two functions: + 1. Extract: Gather facts about a feature/aspect from the codebase + 2. Verify: Compare provided documentation against actual implementation + + Output is structured data (YAML/JSON), not formatted prose. + No templates, no markdown formatting, no document structure decisions. + Let documentation-writer mode handle all writing. + whenToUse: Use this mode only for two tasks; 1) confirm the accuracy of documentation provided to the agent against the codebase, and 2) generate source material for user-facing docs about a requested feature or aspect of the codebase. + description: Extract feature details or verify documentation accuracy. + groups: + - read + - - edit + - fileRegex: \.roo/extraction/.*\.(yaml|json|md)$ + description: Extraction output files only + - command + - mcp + source: project diff --git a/CHANGELOG.md b/CHANGELOG.md index 82d523b897..e006ce210f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # Roo Code Changelog +## [3.47.3] - 2026-02-06 + +- Remove "Enable URL context" and "Enable Grounding with Google search" checkboxes that are no longer needed (PR #11253 by @roomote) +- Revert refactor that appended environment details into existing blocks, restoring original behavior (PR #11256 by @mrubens) +- Revert removal of stripAppendedEnvironmentDetails and helpers, restoring necessary utility functions (PR #11255 by @mrubens) + +## [3.47.2] - 2026-02-05 + +- Add support for .agents/skills directory (PR #11181 by @roomote) +- Fix: Restore Gemini thought signature round-tripping after AI SDK migration (PR #11237 by @hannesrudolph) +- Fix: Capture and round-trip thinking signature for Bedrock Claude (PR #11238 by @hannesrudolph) + +## [3.47.1] - 2026-02-05 + +- Fix: Correct Bedrock model ID for Claude Opus 4.6, resolving model selection issues for Bedrock users (#11231 by @cogwirrel, PR #11232 by @roomote) +- Fix: Guard against empty-string baseURL in provider constructors, preventing connection errors when baseURL is accidentally set to empty string (PR #11233 by @hannesrudolph) +- Chore: Remove unused stripAppendedEnvironmentDetails and helpers to clean up codebase (#11228 by @hannesrudolph, PR #11226 by @hannesrudolph) + +## [3.47.0] - 2026-02-05 + +![3.47.0 Release - Claude Opus 4.6 & GPT-5.3-Codex](/releases/3.47.0-release.png) + +- Add Claude Opus 4.6 support across all providers (#11223 by @hannesrudolph, PR #11224 by @hannesrudolph and @PeterDaveHello) +- Add GPT-5.3-Codex model to OpenAI - ChatGPT provider (PR #11225 by @roomote) +- Migrate Gemini and Vertex providers to AI SDK for improved reliability and consistency (PR #11180 by @daniel-lxs) +- Improve Skills and Slash Commands settings UI with multi-mode support (PR #11157 by @brunobergher) +- Add support for AGENTS.local.md personal override files (PR #11183 by @roomote) +- Add Kimi K2.5 model to Fireworks provider (PR #11177 by @daniel-lxs) +- Improve CLI dev experience and Roo provider API key support (PR #11203 by @cte) +- Fix: Preserve reasoning parts in AI SDK message conversion (#11199 by @hannesrudolph, PR #11217 by @hannesrudolph) +- Refactor: Append environment details into existing blocks for cleaner context (#11200 by @hannesrudolph, PR #11198 by @hannesrudolph) +- Fix: Resolve race condition causing provider switch during CLI mode changes (PR #11205 by @cte) +- Roo Code CLI v0.0.50 (PR #11204 by @cte) +- Chore: Remove dead toolFormat code from getEnvironmentDetails (#11206 by @hannesrudolph, PR #11207 by @roomote) +- Refactor: Simplify docs-extractor mode to focus on raw fact extraction (PR #11129 by @hannesrudolph) +- Revert then re-land AI SDK reasoning fix (PR #11216 by @mrubens, PR #11196 by @hannesrudolph) + +## [3.46.2] - 2026-02-03 + +- Fix: Queue messages during command execution instead of losing them (PR #11140 by @mrubens) +- Fix: Transform tool blocks to text before condensing to prevent context corruption (PR #10975 by @daniel-lxs) +- Fix: Add image content support to MCP tool responses (PR #10874 by @roomote) +- Fix: Remove deprecated text-embedding-004 and migrate code index to gemini-embedding-001 (PR #11038 by @roomote) +- Feat: Use custom Base URL for OpenRouter model list fetch (#11150 by @sebastianlang84, PR #11154 by @roomote) +- Feat: Migrate Mistral provider to AI SDK (PR #11089 by @daniel-lxs) +- Feat: Migrate SambaNova provider to AI SDK (PR #11153 by @roomote) +- Feat: Migrate xAI provider to AI SDK (PR #11158 by @roomote) +- Chore: Remove Feature Request from issue template options (PR #11141 by @roomote) +- Fix: IPC improvements for task cancellation and queued message handling (PR #11162 by @cte) + ## [3.46.1] - 2026-01-30 - Fix: Sanitize tool_use_id in tool_result blocks to match API history, preventing message format errors (PR #11131 by @daniel-lxs) diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 0babc28fd8..98231e8480 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -5,6 +5,41 @@ All notable changes to the `@roo-code/cli` package will be documented in this fi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.52] - 2026-02-09 + +### Added + +- **Linux Support**: Added support for `linux-arm64`. + +## [0.0.51] - 2026-02-06 + +### Changed + +- **Default Model Update**: Changed the default model from Opus 4.5 to Opus 4.6 for improved performance and capabilities + +## [0.0.50] - 2026-02-05 + +### Added + +- **Linux Support**: The CLI now supports Linux platforms in addition to macOS +- **Roo Provider API Key Support**: Allow `--api-key` flag and `ROO_API_KEY` environment variable for the roo provider instead of requiring cloud auth token +- **Exit on Error**: New `--exit-on-error` flag to exit immediately on API request errors instead of retrying, useful for CI/CD pipelines + +### Changed + +- **Improved Dev Experience**: Dev scripts now use `tsx` for running directly from source without building first +- **Path Resolution Fixes**: Fixed path resolution in [`version.ts`](src/lib/utils/version.ts), [`extension.ts`](src/lib/utils/extension.ts), and [`extension-host.ts`](src/agent/extension-host.ts) to work from both source and bundled locations +- **Debug Logging**: Debug log file (`~/.roo/cli-debug.log`) is now disabled by default unless `--debug` flag is passed +- Updated README with complete environment variable table and dev workflow documentation + +### Fixed + +- Corrected example in install script + +### Removed + +- Dropped macOS 13 support + ## [0.0.49] - 2026-01-18 ### Added diff --git a/apps/cli/README.md b/apps/cli/README.md index 8814c68702..7060be6e8f 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -19,7 +19,7 @@ curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/i **Requirements:** - Node.js 20 or higher -- macOS (Intel or Apple Silicon) or Linux (x64 or ARM64) +- macOS Apple Silicon (M1/M2/M3/M4) or Linux x64 **Custom installation directory:** @@ -71,13 +71,13 @@ By default, the CLI prompts for approval before executing actions: ```bash export OPENROUTER_API_KEY=sk-or-v1-... -roo "What is this project?" -w ~/Documents/my-project +roo "What is this project?" -w ~/Documents/my-project ``` You can also run without a prompt and enter it interactively in TUI mode: ```bash -roo ~/Documents/my-project +roo -w ~/Documents/my-project ``` In interactive mode: @@ -147,21 +147,23 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo ## Options -| Option | Description | Default | -| --------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------- | -| `[prompt]` | Your prompt (positional argument, optional) | None | -| `-w, --workspace ` | Workspace path to operate in | Current directory | -| `-e, --extension ` | Path to the extension bundle directory | Auto-detected | -| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` | -| `-x, --exit-on-complete` | Exit the process when task completes (useful for testing) | `false` | -| `-y, --yes` | Non-interactive mode: auto-approve all actions | `false` | -| `-k, --api-key ` | API key for the LLM provider | From env var | -| `-p, --provider ` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` | -| `-m, --model ` | Model to use | `anthropic/claude-sonnet-4.5` | -| `-M, --mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | -| `-r, --reasoning-effort ` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` | -| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` | -| `--no-tui` | Disable TUI, use plain text output | `false` | +| Option | Description | Default | +| ------------------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------- | +| `[prompt]` | Your prompt (positional argument, optional) | None | +| `--prompt-file ` | Read prompt from a file instead of command line argument | None | +| `-w, --workspace ` | Workspace path to operate in | Current directory | +| `-p, --print` | Print response and exit (non-interactive mode) | `false` | +| `-e, --extension ` | Path to the extension bundle directory | Auto-detected | +| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` | +| `-y, --yes, --dangerously-skip-permissions` | Auto-approve all actions (use with caution) | `false` | +| `-k, --api-key ` | API key for the LLM provider | From env var | +| `--provider ` | API provider (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) | +| `-m, --model ` | Model to use | `anthropic/claude-opus-4.6` | +| `--mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | +| `-r, --reasoning-effort ` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` | +| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` | +| `--oneshot` | Exit upon task completion | `false` | +| `--output-format ` | Output format with `--print`: `text`, `json`, or `stream-json` | `text` | ## Auth Commands @@ -175,13 +177,14 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo The CLI will look for API keys in environment variables if not provided via `--api-key`: -| Provider | Environment Variable | -| ------------- | -------------------- | -| anthropic | `ANTHROPIC_API_KEY` | -| openai | `OPENAI_API_KEY` | -| openrouter | `OPENROUTER_API_KEY` | -| google/gemini | `GOOGLE_API_KEY` | -| ... | ... | +| Provider | Environment Variable | +| ----------------- | --------------------------- | +| roo | `ROO_API_KEY` | +| anthropic | `ANTHROPIC_API_KEY` | +| openai-native | `OPENAI_API_KEY` | +| openrouter | `OPENROUTER_API_KEY` | +| gemini | `GOOGLE_API_KEY` | +| vercel-ai-gateway | `VERCEL_AI_GATEWAY_API_KEY` | **Authentication Environment Variables:** @@ -231,8 +234,8 @@ The CLI will look for API keys in environment variables if not provided via `--a ## Development ```bash -# Watch mode for development -pnpm dev +# Run directly from source (no build required) +pnpm dev --provider roo --api-key $ROO_API_KEY --print "Hello" # Run tests pnpm test @@ -244,19 +247,41 @@ pnpm check-types pnpm lint ``` -## Releasing - -To create a new release, execute the /cli-release slash command: +By default the `start` script points `ROO_CODE_PROVIDER_URL` at `http://localhost:8080/proxy` for local development. To point at the production API instead, override the environment variable: ```bash -roo "/cli-release" -w ~/Documents/Roo-Code -y +ROO_CODE_PROVIDER_URL=https://api.roocode.com/proxy pnpm dev --provider roo --api-key $ROO_API_KEY --print "Hello" ``` +## Releasing + +Official releases are created via the GitHub Actions workflow at `.github/workflows/cli-release.yml`. + +To trigger a release: + +1. Go to **Actions** → **CLI Release** +2. Click **Run workflow** +3. Optionally specify a version (defaults to `package.json` version) +4. Click **Run workflow** + The workflow will: -1. Bump the version -2. Update the CHANGELOG -3. Build the extension and CLI -4. Create a platform-specific tarball (for your current OS/architecture) -5. Test the install script -6. Create a GitHub release with the tarball attached +1. Build the CLI on all platforms (macOS Apple Silicon, Linux x64) +2. Create platform-specific tarballs with bundled ripgrep +3. Verify each tarball +4. Create a GitHub release with all tarballs attached + +### Local Builds + +For local development and testing, use the build script: + +```bash +# Build tarball for your current platform +./apps/cli/scripts/build.sh + +# Build and install locally +./apps/cli/scripts/build.sh --install + +# Fast build (skip verification) +./apps/cli/scripts/build.sh --skip-verify +``` diff --git a/apps/cli/install.sh b/apps/cli/install.sh index 1b01e51aa5..2576ec6cce 100755 --- a/apps/cli/install.sh +++ b/apps/cli/install.sh @@ -278,7 +278,7 @@ print_success() { echo "" echo " ${BOLD}Example:${NC}" echo " export OPENROUTER_API_KEY=sk-or-v1-..." - echo " roo ~/my-project -P \"What is this project?\"" + echo " cd ~/my-project && roo \"What is this project?\"" echo "" } diff --git a/apps/cli/package.json b/apps/cli/package.json index 6348bbe020..029e677201 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@roo-code/cli", - "version": "0.0.49", + "version": "0.0.52", "description": "Roo Code CLI - Run the Roo Code agent from the command line", "private": true, "type": "module", @@ -15,11 +15,8 @@ "test": "vitest run", "build": "tsup", "build:extension": "pnpm --filter roo-cline bundle", - "build:all": "pnpm --filter roo-cline bundle && tsup", - "dev": "tsup --watch", - "start": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy node dist/index.js", - "start:production": "node dist/index.js", - "release": "scripts/release.sh", + "dev": "ROO_AUTH_BASE_URL=https://app.roocode.com ROO_SDK_BASE_URL=https://cloud-api.roocode.com ROO_CODE_PROVIDER_URL=https://api.roocode.com/proxy tsx src/index.ts -y", + "dev:local": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy tsx src/index.ts", "clean": "rimraf dist .turbo" }, "dependencies": { diff --git a/apps/cli/scripts/build.sh b/apps/cli/scripts/build.sh new file mode 100755 index 0000000000..97a33c384c --- /dev/null +++ b/apps/cli/scripts/build.sh @@ -0,0 +1,343 @@ +#!/bin/bash +# Roo Code CLI Local Build Script +# +# Usage: +# ./apps/cli/scripts/build.sh [options] +# +# Options: +# --install Install locally after building +# --skip-verify Skip end-to-end verification tests (faster builds) +# +# Examples: +# ./apps/cli/scripts/build.sh # Build for local testing +# ./apps/cli/scripts/build.sh --install # Build and install locally +# ./apps/cli/scripts/build.sh --skip-verify # Fast local build +# +# This script builds the CLI for your current platform. For official releases +# with multi-platform support, use the GitHub Actions workflow instead: +# .github/workflows/cli-release.yml +# +# Prerequisites: +# - pnpm installed +# - Run from the monorepo root directory + +set -e + +# Parse arguments +LOCAL_INSTALL=false +SKIP_VERIFY=false + +while [[ $# -gt 0 ]]; do + case $1 in + --install) + LOCAL_INSTALL=true + shift + ;; + --skip-verify) + SKIP_VERIFY=true + shift + ;; + -*) + echo "Unknown option: $1" >&2 + exit 1 + ;; + *) + shift + ;; + esac +done + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' + +info() { printf "${GREEN}==>${NC} %s\n" "$1"; } +warn() { printf "${YELLOW}Warning:${NC} %s\n" "$1"; } +error() { printf "${RED}Error:${NC} %s\n" "$1" >&2; exit 1; } +step() { printf "${BLUE}${BOLD}[%s]${NC} %s\n" "$1" "$2"; } + +# Get script directory and repo root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +CLI_DIR="$REPO_ROOT/apps/cli" + +# Detect current platform +detect_platform() { + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m) + + case "$OS" in + darwin) OS="darwin" ;; + linux) OS="linux" ;; + *) error "Unsupported OS: $OS" ;; + esac + + case "$ARCH" in + x86_64|amd64) ARCH="x64" ;; + arm64|aarch64) ARCH="arm64" ;; + *) error "Unsupported architecture: $ARCH" ;; + esac + + PLATFORM="${OS}-${ARCH}" +} + +# Check prerequisites +check_prerequisites() { + step "1/6" "Checking prerequisites..." + + if ! command -v pnpm &> /dev/null; then + error "pnpm is not installed." + fi + + if ! command -v node &> /dev/null; then + error "Node.js is not installed." + fi + + info "Prerequisites OK" +} + +# Get version +get_version() { + VERSION=$(node -p "require('$CLI_DIR/package.json').version") + GIT_SHORT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + VERSION="${VERSION}-local.${GIT_SHORT_HASH}" + + info "Version: $VERSION" +} + +# Build everything +build() { + step "2/6" "Building extension bundle..." + cd "$REPO_ROOT" + pnpm bundle + + step "3/6" "Building CLI..." + pnpm --filter @roo-code/cli build + + info "Build complete" +} + +# Create release tarball +create_tarball() { + step "4/6" "Creating release tarball for $PLATFORM..." + + RELEASE_DIR="$REPO_ROOT/roo-cli-${PLATFORM}" + TARBALL="roo-cli-${PLATFORM}.tar.gz" + + # Clean up any previous build + rm -rf "$RELEASE_DIR" + rm -f "$REPO_ROOT/$TARBALL" + + # Create directory structure + mkdir -p "$RELEASE_DIR/bin" + mkdir -p "$RELEASE_DIR/lib" + mkdir -p "$RELEASE_DIR/extension" + + # Copy CLI dist files + info "Copying CLI files..." + cp -r "$CLI_DIR/dist/"* "$RELEASE_DIR/lib/" + + # Create package.json for npm install + info "Creating package.json..." + node -e " + const pkg = require('$CLI_DIR/package.json'); + const newPkg = { + name: '@roo-code/cli', + version: '$VERSION', + type: 'module', + dependencies: { + '@inkjs/ui': pkg.dependencies['@inkjs/ui'], + '@trpc/client': pkg.dependencies['@trpc/client'], + 'commander': pkg.dependencies.commander, + 'fuzzysort': pkg.dependencies.fuzzysort, + 'ink': pkg.dependencies.ink, + 'p-wait-for': pkg.dependencies['p-wait-for'], + 'react': pkg.dependencies.react, + 'superjson': pkg.dependencies.superjson, + 'zustand': pkg.dependencies.zustand + } + }; + console.log(JSON.stringify(newPkg, null, 2)); + " > "$RELEASE_DIR/package.json" + + # Copy extension bundle + info "Copying extension bundle..." + cp -r "$REPO_ROOT/src/dist/"* "$RELEASE_DIR/extension/" + + # Add package.json to extension directory for CommonJS + echo '{"type": "commonjs"}' > "$RELEASE_DIR/extension/package.json" + + # Find and copy ripgrep binary + info "Looking for ripgrep binary..." + RIPGREP_PATH=$(find "$REPO_ROOT/node_modules" -path "*/@vscode/ripgrep/bin/rg" -type f 2>/dev/null | head -1) + if [ -n "$RIPGREP_PATH" ] && [ -f "$RIPGREP_PATH" ]; then + info "Found ripgrep at: $RIPGREP_PATH" + mkdir -p "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin" + cp "$RIPGREP_PATH" "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/" + chmod +x "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/rg" + mkdir -p "$RELEASE_DIR/bin" + cp "$RIPGREP_PATH" "$RELEASE_DIR/bin/" + chmod +x "$RELEASE_DIR/bin/rg" + else + warn "ripgrep binary not found - users will need ripgrep installed" + fi + + # Create the wrapper script + info "Creating wrapper script..." + cat > "$RELEASE_DIR/bin/roo" << 'WRAPPER_EOF' +#!/usr/bin/env node + +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Set environment variables for the CLI +process.env.ROO_CLI_ROOT = join(__dirname, '..'); +process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension'); +process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg'); + +// Import and run the actual CLI +await import(join(__dirname, '..', 'lib', 'index.js')); +WRAPPER_EOF + + chmod +x "$RELEASE_DIR/bin/roo" + + # Create empty .env file + touch "$RELEASE_DIR/.env" + + # Create tarball + info "Creating tarball..." + cd "$REPO_ROOT" + tar -czvf "$TARBALL" "$(basename "$RELEASE_DIR")" + + # Clean up release directory + rm -rf "$RELEASE_DIR" + + # Show size + TARBALL_PATH="$REPO_ROOT/$TARBALL" + TARBALL_SIZE=$(ls -lh "$TARBALL_PATH" | awk '{print $5}') + info "Created: $TARBALL ($TARBALL_SIZE)" +} + +# Verify local installation +verify_local_install() { + if [ "$SKIP_VERIFY" = true ]; then + step "5/6" "Skipping verification (--skip-verify)" + return + fi + + step "5/6" "Verifying installation..." + + VERIFY_DIR="$REPO_ROOT/.verify-release" + VERIFY_INSTALL_DIR="$VERIFY_DIR/cli" + VERIFY_BIN_DIR="$VERIFY_DIR/bin" + + rm -rf "$VERIFY_DIR" + mkdir -p "$VERIFY_DIR" + + TARBALL_PATH="$REPO_ROOT/$TARBALL" + + ROO_LOCAL_TARBALL="$TARBALL_PATH" \ + ROO_INSTALL_DIR="$VERIFY_INSTALL_DIR" \ + ROO_BIN_DIR="$VERIFY_BIN_DIR" \ + ROO_VERSION="$VERSION" \ + "$CLI_DIR/install.sh" || { + rm -rf "$VERIFY_DIR" + error "Installation verification failed!" + } + + # Test --help + if ! "$VERIFY_BIN_DIR/roo" --help > /dev/null 2>&1; then + rm -rf "$VERIFY_DIR" + error "CLI --help check failed!" + fi + info "CLI --help check passed" + + # Test --version + if ! "$VERIFY_BIN_DIR/roo" --version > /dev/null 2>&1; then + rm -rf "$VERIFY_DIR" + error "CLI --version check failed!" + fi + info "CLI --version check passed" + + cd "$REPO_ROOT" + rm -rf "$VERIFY_DIR" + + info "Verification passed!" +} + +# Install locally +install_local() { + if [ "$LOCAL_INSTALL" = false ]; then + step "6/6" "Skipping install (use --install to auto-install)" + return + fi + + step "6/6" "Installing locally..." + + TARBALL_PATH="$REPO_ROOT/$TARBALL" + + ROO_LOCAL_TARBALL="$TARBALL_PATH" \ + ROO_VERSION="$VERSION" \ + "$CLI_DIR/install.sh" || { + error "Local installation failed!" + } + + info "Local installation complete!" +} + +# Print summary +print_summary() { + echo "" + printf "${GREEN}${BOLD}✓ Local build complete for v$VERSION${NC}\n" + echo "" + echo " Tarball: $REPO_ROOT/$TARBALL" + echo "" + + if [ "$LOCAL_INSTALL" = true ]; then + echo " Installed to: ~/.roo/cli" + echo " Binary: ~/.local/bin/roo" + echo "" + echo " Test it out:" + echo " roo --version" + echo " roo --help" + else + echo " To install manually:" + echo " ROO_LOCAL_TARBALL=$REPO_ROOT/$TARBALL ./apps/cli/install.sh" + echo "" + echo " Or re-run with --install:" + echo " ./apps/cli/scripts/build.sh --install" + fi + echo "" + echo " For official multi-platform releases, use the GitHub Actions workflow:" + echo " .github/workflows/cli-release.yml" + echo "" +} + +# Main +main() { + echo "" + printf "${BLUE}${BOLD}" + echo " ╭─────────────────────────────────╮" + echo " │ Roo Code CLI Local Build │" + echo " ╰─────────────────────────────────╯" + printf "${NC}" + echo "" + + detect_platform + check_prerequisites + get_version + build + create_tarball + verify_local_install + install_local + print_summary +} + +main diff --git a/apps/cli/scripts/release.sh b/apps/cli/scripts/release.sh deleted file mode 100755 index 7e736db3db..0000000000 --- a/apps/cli/scripts/release.sh +++ /dev/null @@ -1,711 +0,0 @@ -#!/bin/bash -# Roo Code CLI Release Script -# -# Usage: -# ./apps/cli/scripts/release.sh [options] [version] -# -# Options: -# --dry-run Run all steps except creating the GitHub release -# --local Build for local testing only (no GitHub checks, no changelog prompts) -# --install Install locally after building (only with --local) -# --skip-verify Skip end-to-end verification tests (faster local builds) -# -# Examples: -# ./apps/cli/scripts/release.sh # Use version from package.json -# ./apps/cli/scripts/release.sh 0.1.0 # Specify version -# ./apps/cli/scripts/release.sh --dry-run # Test the release flow without pushing -# ./apps/cli/scripts/release.sh --dry-run 0.1.0 # Dry run with specific version -# ./apps/cli/scripts/release.sh --local # Build for local testing -# ./apps/cli/scripts/release.sh --local --install # Build and install locally -# ./apps/cli/scripts/release.sh --local --skip-verify # Fast local build -# -# This script: -# 1. Builds the extension and CLI -# 2. Creates a tarball for the current platform -# 3. Creates a GitHub release and uploads the tarball (unless --dry-run or --local) -# -# Prerequisites: -# - GitHub CLI (gh) installed and authenticated (not needed for --local) -# - pnpm installed -# - Run from the monorepo root directory - -set -e - -# Parse arguments -DRY_RUN=false -LOCAL_BUILD=false -LOCAL_INSTALL=false -SKIP_VERIFY=false -VERSION_ARG="" - -while [[ $# -gt 0 ]]; do - case $1 in - --dry-run) - DRY_RUN=true - shift - ;; - --local) - LOCAL_BUILD=true - shift - ;; - --install) - LOCAL_INSTALL=true - shift - ;; - --skip-verify) - SKIP_VERIFY=true - shift - ;; - -*) - echo "Unknown option: $1" >&2 - exit 1 - ;; - *) - VERSION_ARG="$1" - shift - ;; - esac -done - -# Validate option combinations -if [ "$LOCAL_INSTALL" = true ] && [ "$LOCAL_BUILD" = false ]; then - echo "Error: --install can only be used with --local" >&2 - exit 1 -fi - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -BOLD='\033[1m' -NC='\033[0m' - -info() { printf "${GREEN}==>${NC} %s\n" "$1"; } -warn() { printf "${YELLOW}Warning:${NC} %s\n" "$1"; } -error() { printf "${RED}Error:${NC} %s\n" "$1" >&2; exit 1; } -step() { printf "${BLUE}${BOLD}[%s]${NC} %s\n" "$1" "$2"; } - -# Get script directory and repo root -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -CLI_DIR="$REPO_ROOT/apps/cli" - -# Detect current platform -detect_platform() { - OS=$(uname -s | tr '[:upper:]' '[:lower:]') - ARCH=$(uname -m) - - case "$OS" in - darwin) OS="darwin" ;; - linux) OS="linux" ;; - *) error "Unsupported OS: $OS" ;; - esac - - case "$ARCH" in - x86_64|amd64) ARCH="x64" ;; - arm64|aarch64) ARCH="arm64" ;; - *) error "Unsupported architecture: $ARCH" ;; - esac - - PLATFORM="${OS}-${ARCH}" -} - -# Check prerequisites -check_prerequisites() { - step "1/8" "Checking prerequisites..." - - # Skip GitHub CLI checks for local builds - if [ "$LOCAL_BUILD" = false ]; then - if ! command -v gh &> /dev/null; then - error "GitHub CLI (gh) is not installed. Install it with: brew install gh" - fi - - if ! gh auth status &> /dev/null; then - error "GitHub CLI is not authenticated. Run: gh auth login" - fi - fi - - if ! command -v pnpm &> /dev/null; then - error "pnpm is not installed." - fi - - if ! command -v node &> /dev/null; then - error "Node.js is not installed." - fi - - info "Prerequisites OK" -} - -# Get version -get_version() { - if [ -n "$VERSION_ARG" ]; then - VERSION="$VERSION_ARG" - else - VERSION=$(node -p "require('$CLI_DIR/package.json').version") - fi - - # For local builds, append a local suffix with git short hash - # This creates versions like: 0.1.0-local.abc1234 - if [ "$LOCAL_BUILD" = true ]; then - GIT_SHORT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") - # Only append suffix if not already a local version - if ! echo "$VERSION" | grep -qE '\-local\.'; then - VERSION="${VERSION}-local.${GIT_SHORT_HASH}" - fi - fi - - # Validate semver format (allow -local.hash suffix) - if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then - error "Invalid version format: $VERSION (expected semver like 0.1.0)" - fi - - TAG="cli-v$VERSION" - info "Version: $VERSION (tag: $TAG)" -} - -# Extract changelog content for a specific version -# Returns the content between the version header and the next version header (or EOF) -get_changelog_content() { - CHANGELOG_FILE="$CLI_DIR/CHANGELOG.md" - - if [ ! -f "$CHANGELOG_FILE" ]; then - warn "No CHANGELOG.md found at $CHANGELOG_FILE" - CHANGELOG_CONTENT="" - return - fi - - # Try to find the version section (handles both "[0.0.43]" and "[0.0.43] - date" formats) - # Also handles "Unreleased" marker - VERSION_PATTERN="^\#\# \[${VERSION}\]" - - # Check if the version exists in the changelog - if ! grep -qE "$VERSION_PATTERN" "$CHANGELOG_FILE"; then - warn "No changelog entry found for version $VERSION" - # Skip prompts for local builds - if [ "$LOCAL_BUILD" = true ]; then - info "Skipping changelog prompt for local build" - CHANGELOG_CONTENT="" - return - fi - warn "Please add an entry to $CHANGELOG_FILE before releasing" - echo "" - echo "Expected format:" - echo " ## [$VERSION] - $(date +%Y-%m-%d)" - echo " " - echo " ### Added" - echo " - Your changes here" - echo "" - read -p "Continue without changelog content? [y/N] " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - error "Aborted. Please add a changelog entry and try again." - fi - CHANGELOG_CONTENT="" - return - fi - - # Extract content between this version and the next version header (or EOF) - # Uses awk to capture everything between ## [VERSION] and the next ## [ - # Using index() with "[VERSION]" ensures exact matching (1.0.1 won't match 1.0.10) - CHANGELOG_CONTENT=$(awk -v version="$VERSION" ' - BEGIN { found = 0; content = ""; target = "[" version "]" } - /^## \[/ { - if (found) { exit } - if (index($0, target) > 0) { found = 1; next } - } - found { content = content $0 "\n" } - END { print content } - ' "$CHANGELOG_FILE") - - # Trim leading/trailing whitespace - CHANGELOG_CONTENT=$(echo "$CHANGELOG_CONTENT" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') - - if [ -n "$CHANGELOG_CONTENT" ]; then - info "Found changelog content for version $VERSION" - else - warn "Changelog entry for $VERSION appears to be empty" - fi -} - -# Build everything -build() { - step "2/8" "Building extension bundle..." - cd "$REPO_ROOT" - pnpm bundle - - step "3/8" "Building CLI..." - pnpm --filter @roo-code/cli build - - info "Build complete" -} - -# Create release tarball -create_tarball() { - step "4/8" "Creating release tarball for $PLATFORM..." - - RELEASE_DIR="$REPO_ROOT/roo-cli-${PLATFORM}" - TARBALL="roo-cli-${PLATFORM}.tar.gz" - - # Clean up any previous build - rm -rf "$RELEASE_DIR" - rm -f "$REPO_ROOT/$TARBALL" - - # Create directory structure - mkdir -p "$RELEASE_DIR/bin" - mkdir -p "$RELEASE_DIR/lib" - mkdir -p "$RELEASE_DIR/extension" - - # Copy CLI dist files - info "Copying CLI files..." - cp -r "$CLI_DIR/dist/"* "$RELEASE_DIR/lib/" - - # Create package.json for npm install (runtime dependencies that can't be bundled) - info "Creating package.json..." - node -e " - const pkg = require('$CLI_DIR/package.json'); - const newPkg = { - name: '@roo-code/cli', - version: '$VERSION', - type: 'module', - dependencies: { - '@inkjs/ui': pkg.dependencies['@inkjs/ui'], - '@trpc/client': pkg.dependencies['@trpc/client'], - 'commander': pkg.dependencies.commander, - 'fuzzysort': pkg.dependencies.fuzzysort, - 'ink': pkg.dependencies.ink, - 'p-wait-for': pkg.dependencies['p-wait-for'], - 'react': pkg.dependencies.react, - 'superjson': pkg.dependencies.superjson, - 'zustand': pkg.dependencies.zustand - } - }; - console.log(JSON.stringify(newPkg, null, 2)); - " > "$RELEASE_DIR/package.json" - - # Copy extension bundle - info "Copying extension bundle..." - cp -r "$REPO_ROOT/src/dist/"* "$RELEASE_DIR/extension/" - - # Add package.json to extension directory to mark it as CommonJS - # This is necessary because the main package.json has "type": "module" - # but the extension bundle is CommonJS - echo '{"type": "commonjs"}' > "$RELEASE_DIR/extension/package.json" - - # Find and copy ripgrep binary - # The extension looks for ripgrep at: appRoot/node_modules/@vscode/ripgrep/bin/rg - # The CLI sets appRoot to the CLI package root, so we need to put ripgrep there - info "Looking for ripgrep binary..." - RIPGREP_PATH=$(find "$REPO_ROOT/node_modules" -path "*/@vscode/ripgrep/bin/rg" -type f 2>/dev/null | head -1) - if [ -n "$RIPGREP_PATH" ] && [ -f "$RIPGREP_PATH" ]; then - info "Found ripgrep at: $RIPGREP_PATH" - # Create the expected directory structure for the extension to find ripgrep - mkdir -p "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin" - cp "$RIPGREP_PATH" "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/" - chmod +x "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/rg" - # Also keep a copy in bin/ for direct access - mkdir -p "$RELEASE_DIR/bin" - cp "$RIPGREP_PATH" "$RELEASE_DIR/bin/" - chmod +x "$RELEASE_DIR/bin/rg" - else - warn "ripgrep binary not found - users will need ripgrep installed" - fi - - # Create the wrapper script - info "Creating wrapper script..." - cat > "$RELEASE_DIR/bin/roo" << 'WRAPPER_EOF' -#!/usr/bin/env node - -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -// Set environment variables for the CLI -// ROO_CLI_ROOT is the installed CLI package root (where node_modules/@vscode/ripgrep is) -process.env.ROO_CLI_ROOT = join(__dirname, '..'); -process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension'); -process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg'); - -// Import and run the actual CLI -await import(join(__dirname, '..', 'lib', 'index.js')); -WRAPPER_EOF - - chmod +x "$RELEASE_DIR/bin/roo" - - # Create empty .env file to suppress dotenvx warnings - touch "$RELEASE_DIR/.env" - - # Create empty .env file to suppress dotenvx warnings - touch "$RELEASE_DIR/.env" - - # Create tarball - info "Creating tarball..." - cd "$REPO_ROOT" - tar -czvf "$TARBALL" "$(basename "$RELEASE_DIR")" - - # Clean up release directory - rm -rf "$RELEASE_DIR" - - # Show size - TARBALL_PATH="$REPO_ROOT/$TARBALL" - TARBALL_SIZE=$(ls -lh "$TARBALL_PATH" | awk '{print $5}') - info "Created: $TARBALL ($TARBALL_SIZE)" -} - -# Verify local installation -verify_local_install() { - if [ "$SKIP_VERIFY" = true ]; then - step "5/8" "Skipping verification (--skip-verify)" - return - fi - - step "5/8" "Verifying local installation..." - - VERIFY_DIR="$REPO_ROOT/.verify-release" - VERIFY_INSTALL_DIR="$VERIFY_DIR/cli" - VERIFY_BIN_DIR="$VERIFY_DIR/bin" - - # Clean up any previous verification directory - rm -rf "$VERIFY_DIR" - mkdir -p "$VERIFY_DIR" - - # Run the actual install script with the local tarball - info "Running install script with local tarball..." - TARBALL_PATH="$REPO_ROOT/$TARBALL" - - ROO_LOCAL_TARBALL="$TARBALL_PATH" \ - ROO_INSTALL_DIR="$VERIFY_INSTALL_DIR" \ - ROO_BIN_DIR="$VERIFY_BIN_DIR" \ - ROO_VERSION="$VERSION" \ - "$CLI_DIR/install.sh" || { - echo "" - warn "Install script failed. Showing tarball contents:" - tar -tzf "$TARBALL_PATH" 2>&1 || true - echo "" - rm -rf "$VERIFY_DIR" - error "Installation verification failed! The install script could not complete successfully." - } - - # Verify the CLI runs correctly with basic commands - info "Testing installed CLI..." - - # Test --help - if ! "$VERIFY_BIN_DIR/roo" --help > /dev/null 2>&1; then - echo "" - warn "CLI --help output:" - "$VERIFY_BIN_DIR/roo" --help 2>&1 || true - echo "" - rm -rf "$VERIFY_DIR" - error "CLI --help check failed! The release tarball may have missing dependencies." - fi - info "CLI --help check passed" - - # Test --version - if ! "$VERIFY_BIN_DIR/roo" --version > /dev/null 2>&1; then - echo "" - warn "CLI --version output:" - "$VERIFY_BIN_DIR/roo" --version 2>&1 || true - echo "" - rm -rf "$VERIFY_DIR" - error "CLI --version check failed! The release tarball may have missing dependencies." - fi - info "CLI --version check passed" - - # Run a simple end-to-end test to verify the CLI actually works - info "Running end-to-end verification test..." - - # Create a temporary workspace for the test - VERIFY_WORKSPACE="$VERIFY_DIR/workspace" - mkdir -p "$VERIFY_WORKSPACE" - - # Run the CLI with a simple prompt - if timeout 60 "$VERIFY_BIN_DIR/roo" --yes --oneshot -w "$VERIFY_WORKSPACE" "1+1=?" > "$VERIFY_DIR/test-output.log" 2>&1; then - info "End-to-end test passed" - else - EXIT_CODE=$? - echo "" - warn "End-to-end test failed (exit code: $EXIT_CODE). Output:" - cat "$VERIFY_DIR/test-output.log" 2>&1 || true - echo "" - rm -rf "$VERIFY_DIR" - error "CLI end-to-end test failed! The CLI may be broken." - fi - - # Clean up verification directory - cd "$REPO_ROOT" - rm -rf "$VERIFY_DIR" - - info "Local verification passed!" -} - -# Create checksum -create_checksum() { - step "6/8" "Creating checksum..." - cd "$REPO_ROOT" - - if command -v sha256sum &> /dev/null; then - sha256sum "$TARBALL" > "${TARBALL}.sha256" - elif command -v shasum &> /dev/null; then - shasum -a 256 "$TARBALL" > "${TARBALL}.sha256" - else - warn "No sha256sum or shasum found, skipping checksum" - return - fi - - info "Checksum: $(cat "${TARBALL}.sha256")" -} - -# Check if release already exists -check_existing_release() { - step "7/8" "Checking for existing release..." - - if gh release view "$TAG" &> /dev/null; then - warn "Release $TAG already exists" - read -p "Do you want to delete it and create a new one? [y/N] " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]]; then - info "Deleting existing release..." - gh release delete "$TAG" --yes - # Also delete the tag if it exists - git tag -d "$TAG" 2>/dev/null || true - git push origin ":refs/tags/$TAG" 2>/dev/null || true - else - error "Aborted. Use a different version or delete the existing release manually." - fi - fi -} - -# Create GitHub release -create_release() { - step "8/8" "Creating GitHub release..." - cd "$REPO_ROOT" - - # Get the current commit SHA for the release target - COMMIT_SHA=$(git rev-parse HEAD) - - # Verify the commit exists on GitHub before attempting to create the release - # This prevents the "Release.target_commitish is invalid" error - info "Verifying commit ${COMMIT_SHA:0:8} exists on GitHub..." - git fetch origin 2>/dev/null || true - if ! git branch -r --contains "$COMMIT_SHA" 2>/dev/null | grep -q "origin/"; then - warn "Commit ${COMMIT_SHA:0:8} has not been pushed to GitHub" - echo "" - echo "The release script needs to create a release at your current commit," - echo "but this commit hasn't been pushed to GitHub yet." - echo "" - read -p "Push current branch to origin now? [Y/n] " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Nn]$ ]]; then - info "Pushing to origin..." - git push origin HEAD || error "Failed to push to origin. Please push manually and try again." - else - error "Aborted. Please push your commits to GitHub and try again." - fi - fi - info "Commit verified on GitHub" - - # Build the What's New section from changelog content - WHATS_NEW_SECTION="" - if [ -n "$CHANGELOG_CONTENT" ]; then - WHATS_NEW_SECTION="## What's New - -$CHANGELOG_CONTENT - -" - fi - - RELEASE_NOTES=$(cat << EOF -${WHATS_NEW_SECTION}## Installation - -\`\`\`bash -curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh -\`\`\` - -Or install a specific version: -\`\`\`bash -ROO_VERSION=$VERSION curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh -\`\`\` - -## Requirements - -- Node.js 20 or higher -- macOS (Intel or Apple Silicon) or Linux (x64 or ARM64) - -## Usage - -\`\`\`bash -# Run a task -roo "What is this project?" - -# See all options -roo --help -\`\`\` - -## Platform Support - -This release includes: -- \`roo-cli-${PLATFORM}.tar.gz\` - Built on $(uname -s) $(uname -m) - -> **Note:** Additional platforms will be added as needed. If you need a different platform, please open an issue. - -## Checksum - -\`\`\` -$(cat "${TARBALL}.sha256" 2>/dev/null || echo "N/A") -\`\`\` -EOF -) - - info "Creating release at commit: ${COMMIT_SHA:0:8}" - - # Create release (gh will create the tag automatically) - info "Creating release..." - RELEASE_FILES="$TARBALL" - if [ -f "${TARBALL}.sha256" ]; then - RELEASE_FILES="$RELEASE_FILES ${TARBALL}.sha256" - fi - - gh release create "$TAG" \ - --title "Roo Code CLI v$VERSION" \ - --notes "$RELEASE_NOTES" \ - --prerelease \ - --target "$COMMIT_SHA" \ - $RELEASE_FILES - - info "Release created!" -} - -# Cleanup -cleanup() { - info "Cleaning up..." - cd "$REPO_ROOT" - rm -f "$TARBALL" "${TARBALL}.sha256" -} - -# Print summary -print_summary() { - echo "" - printf "${GREEN}${BOLD}✓ Release v$VERSION created successfully!${NC}\n" - echo "" - echo " Release URL: https://github.com/RooCodeInc/Roo-Code/releases/tag/$TAG" - echo "" - echo " Install with:" - echo " curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh" - echo "" -} - -# Print dry-run summary -print_dry_run_summary() { - echo "" - printf "${YELLOW}${BOLD}✓ Dry run complete for v$VERSION${NC}\n" - echo "" - echo " The following artifacts were created:" - echo " - $TARBALL" - if [ -f "${TARBALL}.sha256" ]; then - echo " - ${TARBALL}.sha256" - fi - echo "" - echo " To complete the release, run without --dry-run:" - echo " ./apps/cli/scripts/release.sh $VERSION" - echo "" - echo " Or manually upload the tarball to a new GitHub release." - echo "" -} - -# Print local build summary -print_local_summary() { - echo "" - printf "${GREEN}${BOLD}✓ Local build complete for v$VERSION${NC}\n" - echo "" - echo " Tarball: $REPO_ROOT/$TARBALL" - if [ -f "${TARBALL}.sha256" ]; then - echo " Checksum: $REPO_ROOT/${TARBALL}.sha256" - fi - echo "" - echo " To install manually:" - echo " ROO_LOCAL_TARBALL=$REPO_ROOT/$TARBALL ./apps/cli/install.sh" - echo "" - echo " Or re-run with --install to install automatically:" - echo " ./apps/cli/scripts/release.sh --local --install" - echo "" -} - -# Install locally using the install script -install_local() { - step "7/8" "Installing locally..." - - TARBALL_PATH="$REPO_ROOT/$TARBALL" - - ROO_LOCAL_TARBALL="$TARBALL_PATH" \ - ROO_VERSION="$VERSION" \ - "$CLI_DIR/install.sh" || { - error "Local installation failed!" - } - - info "Local installation complete!" -} - -# Print local install summary -print_local_install_summary() { - echo "" - printf "${GREEN}${BOLD}✓ Local build installed for v$VERSION${NC}\n" - echo "" - echo " Tarball: $REPO_ROOT/$TARBALL" - echo " Installed to: ~/.roo/cli" - echo " Binary: ~/.local/bin/roo" - echo "" - echo " Test it out:" - echo " roo --version" - echo " roo --help" - echo "" -} - -# Main -main() { - echo "" - printf "${BLUE}${BOLD}" - echo " ╭─────────────────────────────────╮" - echo " │ Roo Code CLI Release Script │" - echo " ╰─────────────────────────────────╯" - printf "${NC}" - - if [ "$DRY_RUN" = true ]; then - printf "${YELLOW} (DRY RUN MODE)${NC}\n" - elif [ "$LOCAL_BUILD" = true ]; then - printf "${YELLOW} (LOCAL BUILD MODE)${NC}\n" - fi - echo "" - - detect_platform - check_prerequisites - get_version - get_changelog_content - build - create_tarball - verify_local_install - create_checksum - - if [ "$LOCAL_BUILD" = true ]; then - step "7/8" "Skipping GitHub checks (local build)" - if [ "$LOCAL_INSTALL" = true ]; then - install_local - print_local_install_summary - else - step "8/8" "Skipping installation (use --install to auto-install)" - print_local_summary - fi - elif [ "$DRY_RUN" = true ]; then - step "7/8" "Skipping existing release check (dry run)" - step "8/8" "Skipping GitHub release creation (dry run)" - print_dry_run_summary - else - check_existing_release - create_release - cleanup - print_summary - fi -} - -main diff --git a/apps/cli/src/agent/ask-dispatcher.ts b/apps/cli/src/agent/ask-dispatcher.ts index 8d57e4547c..fe8c557d8d 100644 --- a/apps/cli/src/agent/ask-dispatcher.ts +++ b/apps/cli/src/agent/ask-dispatcher.ts @@ -59,6 +59,11 @@ export interface AskDispatcherOptions { */ nonInteractive?: boolean + /** + * Whether to exit on API request errors instead of retrying. + */ + exitOnError?: boolean + /** * Whether to disable ask handling (for TUI mode). * In TUI mode, the TUI handles asks directly. @@ -87,6 +92,7 @@ export class AskDispatcher { private promptManager: PromptManager private sendMessage: (message: WebviewMessage) => void private nonInteractive: boolean + private exitOnError: boolean private disabled: boolean /** @@ -100,6 +106,7 @@ export class AskDispatcher { this.promptManager = options.promptManager this.sendMessage = options.sendMessage this.nonInteractive = options.nonInteractive ?? false + this.exitOnError = options.exitOnError ?? false this.disabled = options.disabled ?? false } @@ -518,6 +525,11 @@ export class AskDispatcher { this.outputManager.output(` Error: ${text || "Unknown error"}`) this.outputManager.markDisplayed(ts, text || "", false) + if (this.exitOnError) { + console.error(`[CLI] API request failed: ${text || "Unknown error"}`) + process.exit(1) + } + if (this.nonInteractive) { this.outputManager.output("\n[retrying api request]") // Auto-retry in non-interactive mode diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index e1f55a30d1..42edff1214 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -24,7 +24,7 @@ import type { WebviewMessage, } from "@roo-code/types" import { createVSCodeAPI, IExtensionHost, ExtensionHostEventMap, setRuntimeConfigValues } from "@roo-code/vscode-shim" -import { DebugLogger } from "@roo-code/core/cli" +import { DebugLogger, setDebugLogEnabled } from "@roo-code/core/cli" import type { SupportedProvider } from "@/types/index.js" import type { User } from "@/lib/sdk/index.js" @@ -43,10 +43,25 @@ const cliLogger = new DebugLogger("CLI") // Get the CLI package root directory (for finding node_modules/@vscode/ripgrep) // When running from a release tarball, ROO_CLI_ROOT is set by the wrapper script. -// In development, we fall back to calculating from __dirname. -// After bundling with tsup, the code is in dist/index.js (flat), so we go up one level. +// In development, we fall back to finding the CLI package root by walking up to package.json. +// This works whether running from dist/ (bundled) or src/agent/ (tsx dev). const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const CLI_PACKAGE_ROOT = process.env.ROO_CLI_ROOT || path.resolve(__dirname, "..") + +function findCliPackageRoot(): string { + let dir = __dirname + + while (dir !== path.dirname(dir)) { + if (fs.existsSync(path.join(dir, "package.json"))) { + return dir + } + + dir = path.dirname(dir) + } + + return path.resolve(__dirname, "..") +} + +const CLI_PACKAGE_ROOT = process.env.ROO_CLI_ROOT || findCliPackageRoot() export interface ExtensionHostOptions { mode: string @@ -64,6 +79,10 @@ export interface ExtensionHostOptions { ephemeral: boolean debug: boolean exitOnComplete: boolean + /** + * When true, exit the process on API request errors instead of retrying. + */ + exitOnError?: boolean /** * When true, completely disables all direct stdout/stderr output. * Use this when running in TUI mode where Ink controls the terminal. @@ -154,6 +173,11 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac this.options = options + // Enable file-based debug logging only when --debug is passed. + if (options.debug) { + setDebugLogEnabled(true) + } + // Set up quiet mode early, before any extension code runs. // This suppresses console output from the extension during load. this.setupQuietMode() @@ -179,6 +203,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac promptManager: this.promptManager, sendMessage: (msg) => this.sendToExtension(msg), nonInteractive: options.nonInteractive, + exitOnError: options.exitOnError, disabled: options.disableOutput, // TUI mode handles asks directly. }) @@ -403,12 +428,16 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac public markWebviewReady(): void { this.isReady = true - // Send initial webview messages to trigger proper extension initialization. - // This is critical for the extension to start sending state updates properly. - this.sendToExtension({ type: "webviewDidLaunch" }) - + // Apply CLI settings to the runtime config and context proxy BEFORE + // sending webviewDidLaunch. This prevents a race condition where the + // webviewDidLaunch handler's first-time init sync reads default state + // (apiProvider: "anthropic") instead of the CLI-provided settings. setRuntimeConfigValues("roo-cline", this.initialSettings as Record) this.sendToExtension({ type: "updateSettings", updatedSettings: this.initialSettings }) + + // Now trigger extension initialization. The context proxy should already + // have CLI-provided values when the webviewDidLaunch handler runs. + this.sendToExtension({ type: "webviewDidLaunch" }) } public isInInitialSetup(): boolean { @@ -448,6 +477,25 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac const cleanup = () => { this.client.off("taskCompleted", completeHandler) this.client.off("error", errorHandler) + + if (messageHandler) { + this.client.off("message", messageHandler) + } + } + + // When exitOnError is enabled, listen for api_req_retry_delayed messages + // (sent by Task.ts during auto-approval retry backoff) and exit immediately. + let messageHandler: ((msg: ClineMessage) => void) | null = null + + if (this.options.exitOnError) { + messageHandler = (msg: ClineMessage) => { + if (msg.type === "say" && msg.say === "api_req_retry_delayed") { + cleanup() + reject(new Error(msg.text?.split("\n")[0] || "API request failed")) + } + } + + this.client.on("message", messageHandler) } this.client.once("taskCompleted", completeHandler) diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index 663ed5cf75..881107fb82 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -78,6 +78,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption workspacePath: effectiveWorkspacePath, extensionPath: path.resolve(flagOptions.extension || getDefaultExtensionPath(__dirname)), nonInteractive: effectiveDangerouslySkipPermissions, + exitOnError: flagOptions.exitOnError, ephemeral: flagOptions.ephemeral, debug: flagOptions.debug, exitOnComplete: effectiveExitOnComplete, @@ -112,15 +113,18 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption extensionHostOptions.apiKey = rooToken extensionHostOptions.user = me.user } catch { - console.error("[CLI] Your Roo Code Router token is not valid.") - console.error("[CLI] Please run: roo auth login") - process.exit(1) + // If an explicit API key was provided via flag or env var, fall through + // to the general API key resolution below instead of exiting. + if (!flagOptions.apiKey && !getApiKeyFromEnv(extensionHostOptions.provider)) { + console.error("[CLI] Your Roo Code Router token is not valid.") + console.error("[CLI] Please run: roo auth login") + console.error("[CLI] Or use --api-key or set ROO_API_KEY to provide your own API key.") + process.exit(1) + } } - } else { - console.error("[CLI] Your Roo Code Router token is missing.") - console.error("[CLI] Please run: roo auth login") - process.exit(1) } + // If no rooToken, fall through to the general API key resolution below + // which will check flagOptions.apiKey and ROO_API_KEY env var. } // Validations diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 5b663c2bdc..51218aa860 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -18,7 +18,8 @@ program .option("-p, --print", "Print response and exit (non-interactive mode)", false) .option("-e, --extension ", "Path to the extension bundle directory") .option("-d, --debug", "Enable debug output (includes detailed debug information)", false) - .option("-y, --yes, --dangerously-skip-permissions", "Auto-approve all prompts (use with caution)", false) + .option("-y, --yes", "Auto-approve all prompts (use with caution)", false) + .option("--dangerously-skip-permissions", "Alias for --yes", false) .option("-k, --api-key ", "API key for the LLM provider") .option("--provider ", "API provider (roo, anthropic, openai, openrouter, etc.)") .option("-m, --model ", "Model to use", DEFAULT_FLAGS.model) @@ -28,6 +29,7 @@ program "Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)", DEFAULT_FLAGS.reasoningEffort, ) + .option("--exit-on-error", "Exit on API request errors instead of retrying", false) .option("--ephemeral", "Run without persisting state (uses temporary storage)", false) .option("--oneshot", "Exit upon task completion", false) .option( diff --git a/apps/cli/src/lib/storage/__tests__/settings.test.ts b/apps/cli/src/lib/storage/__tests__/settings.test.ts index c133f733b9..d5d520a597 100644 --- a/apps/cli/src/lib/storage/__tests__/settings.test.ts +++ b/apps/cli/src/lib/storage/__tests__/settings.test.ts @@ -103,7 +103,7 @@ describe("Settings Storage", () => { await saveSettings({ mode: "architect", provider: "anthropic" as const, - model: "claude-opus-4.5", + model: "claude-opus-4.6", reasoningEffort: "medium" as const, }) @@ -112,7 +112,7 @@ describe("Settings Storage", () => { expect(settings.mode).toBe("architect") expect(settings.provider).toBe("anthropic") - expect(settings.model).toBe("claude-opus-4.5") + expect(settings.model).toBe("claude-opus-4.6") expect(settings.reasoningEffort).toBe("medium") }) diff --git a/apps/cli/src/lib/utils/__tests__/extension.test.ts b/apps/cli/src/lib/utils/__tests__/extension.test.ts index 31fdbe87f0..4b4a2db585 100644 --- a/apps/cli/src/lib/utils/__tests__/extension.test.ts +++ b/apps/cli/src/lib/utils/__tests__/extension.test.ts @@ -21,9 +21,26 @@ describe("getDefaultExtensionPath", () => { it("should return monorepo path when extension.js exists there", () => { const mockDirname = "/test/apps/cli/dist" - const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist") + const expectedMonorepoPath = path.resolve("/test/apps/cli", "../../src/dist") - vi.mocked(fs.existsSync).mockReturnValue(true) + // Walk-up: dist/ has no package.json, apps/cli/ does + vi.mocked(fs.existsSync).mockImplementation((p) => { + const s = String(p) + + if (s === path.join(mockDirname, "package.json")) { + return false + } + + if (s === path.join("/test/apps/cli", "package.json")) { + return true + } + + if (s === path.join(expectedMonorepoPath, "extension.js")) { + return true + } + + return false + }) const result = getDefaultExtensionPath(mockDirname) @@ -33,9 +50,18 @@ describe("getDefaultExtensionPath", () => { it("should return package path when extension.js does not exist in monorepo path", () => { const mockDirname = "/test/apps/cli/dist" - const expectedPackagePath = path.resolve(mockDirname, "../extension") + const expectedPackagePath = path.resolve("/test/apps/cli", "extension") - vi.mocked(fs.existsSync).mockReturnValue(false) + // Walk-up finds package.json at apps/cli/, but no extension.js in monorepo path + vi.mocked(fs.existsSync).mockImplementation((p) => { + const s = String(p) + + if (s === path.join("/test/apps/cli", "package.json")) { + return true + } + + return false + }) const result = getDefaultExtensionPath(mockDirname) @@ -43,12 +69,45 @@ describe("getDefaultExtensionPath", () => { }) it("should check monorepo path first", () => { - const mockDirname = "/some/path" - vi.mocked(fs.existsSync).mockReturnValue(false) + const mockDirname = "/test/apps/cli/dist" + + vi.mocked(fs.existsSync).mockImplementation((p) => { + const s = String(p) + + if (s === path.join("/test/apps/cli", "package.json")) { + return true + } + + return false + }) getDefaultExtensionPath(mockDirname) - const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist") + const expectedMonorepoPath = path.resolve("/test/apps/cli", "../../src/dist") expect(fs.existsSync).toHaveBeenCalledWith(path.join(expectedMonorepoPath, "extension.js")) }) + + it("should work when called from source directory (tsx dev)", () => { + const mockDirname = "/test/apps/cli/src/commands/cli" + const expectedMonorepoPath = path.resolve("/test/apps/cli", "../../src/dist") + + // Walk-up: no package.json in src subdirs, found at apps/cli/ + vi.mocked(fs.existsSync).mockImplementation((p) => { + const s = String(p) + + if (s === path.join("/test/apps/cli", "package.json")) { + return true + } + + if (s === path.join(expectedMonorepoPath, "extension.js")) { + return true + } + + return false + }) + + const result = getDefaultExtensionPath(mockDirname) + + expect(result).toBe(expectedMonorepoPath) + }) }) diff --git a/apps/cli/src/lib/utils/context-window.ts b/apps/cli/src/lib/utils/context-window.ts index c1224c8b1e..df878e16b0 100644 --- a/apps/cli/src/lib/utils/context-window.ts +++ b/apps/cli/src/lib/utils/context-window.ts @@ -48,18 +48,10 @@ function getModelIdForProvider(config: ProviderSettings): string | undefined { return config.requestyModelId case "litellm": return config.litellmModelId - case "deepinfra": - return config.deepInfraModelId - case "huggingface": - return config.huggingFaceModelId - case "unbound": - return config.unboundModelId case "vercel-ai-gateway": return config.vercelAiGatewayModelId - case "io-intelligence": - return config.ioIntelligenceModelId default: - // For anthropic, bedrock, vertex, gemini, xai, groq, etc. + // For anthropic, bedrock, vertex, gemini, xai, etc. return config.apiModelId } } diff --git a/apps/cli/src/lib/utils/extension.ts b/apps/cli/src/lib/utils/extension.ts index 904940ec00..f49b2df865 100644 --- a/apps/cli/src/lib/utils/extension.ts +++ b/apps/cli/src/lib/utils/extension.ts @@ -17,17 +17,26 @@ export function getDefaultExtensionPath(dirname: string): string { } } - // __dirname is apps/cli/dist when bundled - // The extension is at src/dist (relative to monorepo root) - // So from apps/cli/dist, we need to go ../../../src/dist - const monorepoPath = path.resolve(dirname, "../../../src/dist") + // Find the CLI package root (apps/cli) by walking up to the nearest package.json. + // This works whether called from dist/ (bundled) or src/commands/cli/ (tsx dev). + let packageRoot = dirname + + while (packageRoot !== path.dirname(packageRoot)) { + if (fs.existsSync(path.join(packageRoot, "package.json"))) { + break + } + + packageRoot = path.dirname(packageRoot) + } + + // The extension is at ../../src/dist relative to apps/cli (monorepo/src/dist) + const monorepoPath = path.resolve(packageRoot, "../../src/dist") - // Try monorepo path first (for development) if (fs.existsSync(path.join(monorepoPath, "extension.js"))) { return monorepoPath } - // Fallback: when installed via curl script, extension is at ../extension - const packagePath = path.resolve(dirname, "../extension") + // Fallback: when installed via curl script, extension is at apps/cli/extension + const packagePath = path.resolve(packageRoot, "extension") return packagePath } diff --git a/apps/cli/src/lib/utils/version.ts b/apps/cli/src/lib/utils/version.ts index e4f2ce59b2..c599963bdc 100644 --- a/apps/cli/src/lib/utils/version.ts +++ b/apps/cli/src/lib/utils/version.ts @@ -1,6 +1,24 @@ -import { createRequire } from "module" +import fs from "fs" +import path from "path" +import { fileURLToPath } from "url" -const require = createRequire(import.meta.url) -const packageJson = require("../package.json") +// Walk up from the current file to find the nearest package.json. +// This works whether running from source (tsx src/lib/utils/) or bundle (dist/). +function findVersion(): string { + let dir = path.dirname(fileURLToPath(import.meta.url)) -export const VERSION = packageJson.version + while (dir !== path.dirname(dir)) { + const candidate = path.join(dir, "package.json") + + if (fs.existsSync(candidate)) { + const packageJson = JSON.parse(fs.readFileSync(candidate, "utf-8")) + return packageJson.version + } + + dir = path.dirname(dir) + } + + return "0.0.0" +} + +export const VERSION = findVersion() diff --git a/apps/cli/src/types/constants.ts b/apps/cli/src/types/constants.ts index 5b3dc57778..6c54348a9c 100644 --- a/apps/cli/src/types/constants.ts +++ b/apps/cli/src/types/constants.ts @@ -3,7 +3,7 @@ import { reasoningEffortsExtended } from "@roo-code/types" export const DEFAULT_FLAGS = { mode: "code", reasoningEffort: "medium" as const, - model: "anthropic/claude-opus-4.5", + model: "anthropic/claude-opus-4.6", } export const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"] diff --git a/apps/cli/src/types/types.ts b/apps/cli/src/types/types.ts index 05392ccca8..827afad513 100644 --- a/apps/cli/src/types/types.ts +++ b/apps/cli/src/types/types.ts @@ -26,6 +26,7 @@ export type FlagOptions = { debug: boolean yes: boolean dangerouslySkipPermissions: boolean + exitOnError: boolean apiKey?: string provider?: SupportedProvider model?: string diff --git a/apps/cli/tsup.config.ts b/apps/cli/tsup.config.ts index eff2c14e2c..3ad1234d99 100644 --- a/apps/cli/tsup.config.ts +++ b/apps/cli/tsup.config.ts @@ -16,7 +16,6 @@ export default defineConfig({ external: [ // Keep native modules external "@anthropic-ai/sdk", - "@anthropic-ai/bedrock-sdk", "@anthropic-ai/vertex-sdk", // Keep @vscode/ripgrep external - we bundle the binary separately "@vscode/ripgrep", diff --git a/apps/vscode-nightly/esbuild.mjs b/apps/vscode-nightly/esbuild.mjs index e45dbd3c3e..92b80b50d4 100644 --- a/apps/vscode-nightly/esbuild.mjs +++ b/apps/vscode-nightly/esbuild.mjs @@ -57,6 +57,22 @@ async function main() { * @type {import('esbuild').Plugin[]} */ const plugins = [ + { + // Stub out @basetenlabs/performance-client which contains native .node + // binaries that esbuild cannot bundle. This module is only used by + // @ai-sdk/baseten for embedding models, not for chat completions. + name: "stub-baseten-native", + setup(build) { + build.onResolve({ filter: /^@basetenlabs\/performance-client/ }, (args) => ({ + path: args.path, + namespace: "stub-baseten-native", + })) + build.onLoad({ filter: /.*/, namespace: "stub-baseten-native" }, () => ({ + contents: "module.exports = { PerformanceClient: class PerformanceClient {} };", + loader: "js", + })) + }, + }, { name: "copyPaths", setup(build) { diff --git a/apps/web-evals/package.json b/apps/web-evals/package.json index 0a721bf36c..83d69edd59 100644 --- a/apps/web-evals/package.json +++ b/apps/web-evals/package.json @@ -27,7 +27,7 @@ "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.2.8", "@roo-code/evals": "workspace:^", - "@roo-code/types": "^1.108.0", + "@roo-code/types": "workspace:^", "@tanstack/react-query": "^5.69.0", "archiver": "^7.0.1", "class-variance-authority": "^0.7.1", diff --git a/apps/web-evals/src/app/api/runs/[id]/logs/[taskId]/route.ts b/apps/web-evals/src/app/api/runs/[id]/logs/[taskId]/route.ts deleted file mode 100644 index e5ec8751ab..0000000000 --- a/apps/web-evals/src/app/api/runs/[id]/logs/[taskId]/route.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { NextResponse } from "next/server" -import type { NextRequest } from "next/server" -import * as fs from "node:fs/promises" -import * as path from "node:path" - -import { findTask, findRun } from "@roo-code/evals" - -export const dynamic = "force-dynamic" - -const LOG_BASE_PATH = "/tmp/evals/runs" - -// Sanitize path components to prevent path traversal attacks -function sanitizePathComponent(component: string): string { - // Remove any path separators, null bytes, and other dangerous characters - return component.replace(/[/\\:\0*?"<>|]/g, "_") -} - -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string; taskId: string }> }) { - const { id, taskId } = await params - - try { - const runId = Number(id) - const taskIdNum = Number(taskId) - - if (isNaN(runId) || isNaN(taskIdNum)) { - return NextResponse.json({ error: "Invalid run ID or task ID" }, { status: 400 }) - } - - // Verify the run exists - await findRun(runId) - - // Get the task to find its language and exercise - const task = await findTask(taskIdNum) - - // Verify the task belongs to this run - if (task.runId !== runId) { - return NextResponse.json({ error: "Task does not belong to this run" }, { status: 404 }) - } - - // Sanitize language and exercise to prevent path traversal - const safeLanguage = sanitizePathComponent(task.language) - const safeExercise = sanitizePathComponent(task.exercise) - - // Construct the log file path - const logFileName = `${safeLanguage}-${safeExercise}.log` - const logFilePath = path.join(LOG_BASE_PATH, String(runId), logFileName) - - // Verify the resolved path is within the expected directory (defense in depth) - const resolvedPath = path.resolve(logFilePath) - const expectedBase = path.resolve(LOG_BASE_PATH) - if (!resolvedPath.startsWith(expectedBase)) { - return NextResponse.json({ error: "Invalid log path" }, { status: 400 }) - } - - // Check if the log file exists and read it (async) - try { - const logContent = await fs.readFile(logFilePath, "utf-8") - return NextResponse.json({ logContent }) - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - return NextResponse.json({ error: "Log file not found", logContent: null }, { status: 200 }) - } - throw err - } - } catch (error) { - console.error("Error reading task log:", error) - - if (error instanceof Error && error.name === "RecordNotFoundError") { - return NextResponse.json({ error: "Task or run not found" }, { status: 404 }) - } - - return NextResponse.json({ error: "Failed to read log file" }, { status: 500 }) - } -} diff --git a/apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts b/apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts deleted file mode 100644 index 8b2760df98..0000000000 --- a/apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { NextResponse } from "next/server" -import type { NextRequest } from "next/server" -import * as fs from "node:fs" -import * as path from "node:path" -import archiver from "archiver" - -import { findRun, getTasks } from "@roo-code/evals" - -export const dynamic = "force-dynamic" - -const LOG_BASE_PATH = "/tmp/evals/runs" - -// Sanitize path components to prevent path traversal attacks -function sanitizePathComponent(component: string): string { - // Remove any path separators, null bytes, and other dangerous characters - return component.replace(/[/\\:\0*?"<>|]/g, "_") -} - -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params - - try { - const runId = Number(id) - - if (isNaN(runId)) { - return NextResponse.json({ error: "Invalid run ID" }, { status: 400 }) - } - - // Verify the run exists - await findRun(runId) - - // Get all tasks for this run - const tasks = await getTasks(runId) - - // Filter for failed tasks only - const failedTasks = tasks.filter((task) => task.passed === false) - - if (failedTasks.length === 0) { - return NextResponse.json({ error: "No failed tasks to export" }, { status: 400 }) - } - - // Create a zip archive - const archive = archiver("zip", { zlib: { level: 9 } }) - - // Collect chunks to build the response - const chunks: Buffer[] = [] - - archive.on("data", (chunk: Buffer) => { - chunks.push(chunk) - }) - - // Track archive errors - let archiveError: Error | null = null - archive.on("error", (err: Error) => { - archiveError = err - }) - - // Set up the end promise before finalizing (proper event listener ordering) - const archiveEndPromise = new Promise((resolve, reject) => { - archive.on("end", resolve) - archive.on("error", reject) - }) - - // Add each failed task's log file and history files to the archive - const logDir = path.join(LOG_BASE_PATH, String(runId)) - let filesAdded = 0 - - for (const task of failedTasks) { - // Sanitize language and exercise to prevent path traversal - const safeLanguage = sanitizePathComponent(task.language) - const safeExercise = sanitizePathComponent(task.exercise) - const expectedBase = path.resolve(LOG_BASE_PATH) - - // Add the log file - const logFileName = `${safeLanguage}-${safeExercise}.log` - const logFilePath = path.join(logDir, logFileName) - - // Verify the resolved path is within the expected directory (defense in depth) - const resolvedLogPath = path.resolve(logFilePath) - if (resolvedLogPath.startsWith(expectedBase) && fs.existsSync(logFilePath)) { - archive.file(logFilePath, { name: logFileName }) - filesAdded++ - } - - // Add the API conversation history file - // Format: {language}-{exercise}.{iteration}_api_conversation_history.json - const apiHistoryFileName = `${safeLanguage}-${safeExercise}.${task.iteration}_api_conversation_history.json` - const apiHistoryFilePath = path.join(logDir, apiHistoryFileName) - const resolvedApiHistoryPath = path.resolve(apiHistoryFilePath) - if (resolvedApiHistoryPath.startsWith(expectedBase) && fs.existsSync(apiHistoryFilePath)) { - archive.file(apiHistoryFilePath, { name: apiHistoryFileName }) - filesAdded++ - } - - // Add the UI messages file - // Format: {language}-{exercise}.{iteration}_ui_messages.json - const uiMessagesFileName = `${safeLanguage}-${safeExercise}.${task.iteration}_ui_messages.json` - const uiMessagesFilePath = path.join(logDir, uiMessagesFileName) - const resolvedUiMessagesPath = path.resolve(uiMessagesFilePath) - if (resolvedUiMessagesPath.startsWith(expectedBase) && fs.existsSync(uiMessagesFilePath)) { - archive.file(uiMessagesFilePath, { name: uiMessagesFileName }) - filesAdded++ - } - } - - // Check if any files were actually added - if (filesAdded === 0) { - archive.abort() - return NextResponse.json( - { error: "No log files found - they may have been cleared from disk" }, - { status: 404 }, - ) - } - - // Finalize the archive - await archive.finalize() - - // Wait for all data to be collected - await archiveEndPromise - - // Check for archive errors - if (archiveError) { - throw archiveError - } - - // Combine all chunks into a single buffer - const zipBuffer = Buffer.concat(chunks) - - // Return the zip file - return new NextResponse(zipBuffer, { - status: 200, - headers: { - "Content-Type": "application/zip", - "Content-Disposition": `attachment; filename="run-${runId}-failed-logs.zip"`, - "Content-Length": String(zipBuffer.length), - }, - }) - } catch (error) { - console.error("Error exporting failed logs:", error) - - if (error instanceof Error && error.name === "RecordNotFoundError") { - return NextResponse.json({ error: "Run not found" }, { status: 404 }) - } - - return NextResponse.json({ error: "Failed to export logs" }, { status: 500 }) - } -} diff --git a/apps/web-roo-code/src/app/cloud/page.tsx b/apps/web-roo-code/src/app/cloud/page.tsx index 68d3c3d2bc..51df0642ee 100644 --- a/apps/web-roo-code/src/app/cloud/page.tsx +++ b/apps/web-roo-code/src/app/cloud/page.tsx @@ -5,9 +5,9 @@ import { ChartLine, Github, History, + ListChecks, LucideIcon, Pencil, - Router, Share2, Slack, Users, @@ -112,9 +112,9 @@ const features: Feature[] = [ description: "Start tasks, get updates, and collaborate with agents directly from your team's Slack channels.", }, { - icon: Router, - title: "Roomote Control", - description: "Connect to your local VS Code instance and control the extension remotely from the browser.", + icon: ListChecks, + title: "Linear Integration", + description: "Assign issues to Roo Code directly from Linear. Get PRs back without switching tools.", }, { icon: Users, diff --git a/packages/core/src/debug-log/index.ts b/packages/core/src/debug-log/index.ts index 48fb22c4fa..f157d32734 100644 --- a/packages/core/src/debug-log/index.ts +++ b/packages/core/src/debug-log/index.ts @@ -21,11 +21,25 @@ import * as os from "os" const DEBUG_LOG_PATH = path.join(os.homedir(), ".roo", "cli-debug.log") +let debugLogEnabled = false + +/** + * Enable or disable file-based debug logging. + * Logging is disabled by default and should only be enabled in dev/debug mode. + */ +export function setDebugLogEnabled(enabled: boolean): void { + debugLogEnabled = enabled +} + /** * Simple file-based debug log function. * Writes timestamped entries to ~/.roo/cli-debug.log + * Only writes when enabled via setDebugLogEnabled(true). */ export function debugLog(message: string, data?: unknown): void { + if (!debugLogEnabled) { + return + } try { const logDir = path.dirname(DEBUG_LOG_PATH) diff --git a/packages/evals/src/cli/runTaskInCli.ts b/packages/evals/src/cli/runTaskInCli.ts index 031136f8ea..03b3ad4f70 100644 --- a/packages/evals/src/cli/runTaskInCli.ts +++ b/packages/evals/src/cli/runTaskInCli.ts @@ -264,7 +264,7 @@ export const runTaskWithCli = async ({ run, task, publish, logger, jobToken }: R if (rooTaskId && !isClientDisconnected) { logger.info("cancelling task") - client.sendCommand({ commandName: TaskCommandName.CancelTask, data: rooTaskId }) + client.sendCommand({ commandName: TaskCommandName.CancelTask }) await new Promise((resolve) => setTimeout(resolve, 5_000)) } @@ -289,7 +289,7 @@ export const runTaskWithCli = async ({ run, task, publish, logger, jobToken }: R if (rooTaskId && !isClientDisconnected) { logger.info("closing task") - client.sendCommand({ commandName: TaskCommandName.CloseTask, data: rooTaskId }) + client.sendCommand({ commandName: TaskCommandName.CloseTask }) await new Promise((resolve) => setTimeout(resolve, 2_000)) } diff --git a/packages/evals/src/cli/runTaskInVscode.ts b/packages/evals/src/cli/runTaskInVscode.ts index 07b7bd7e29..5819f8d405 100644 --- a/packages/evals/src/cli/runTaskInVscode.ts +++ b/packages/evals/src/cli/runTaskInVscode.ts @@ -270,7 +270,7 @@ export const runTaskInVscode = async ({ run, task, publish, logger, jobToken }: if (rooTaskId && !isClientDisconnected) { logger.info("cancelling task") - client.sendCommand({ commandName: TaskCommandName.CancelTask, data: rooTaskId }) + client.sendCommand({ commandName: TaskCommandName.CancelTask }) await new Promise((resolve) => setTimeout(resolve, 5_000)) // Allow some time for the task to cancel. } @@ -289,7 +289,7 @@ export const runTaskInVscode = async ({ run, task, publish, logger, jobToken }: if (rooTaskId && !isClientDisconnected) { logger.info("closing task") - client.sendCommand({ commandName: TaskCommandName.CloseTask, data: rooTaskId }) + client.sendCommand({ commandName: TaskCommandName.CloseTask }) await new Promise((resolve) => setTimeout(resolve, 2_000)) // Allow some time for the window to close. } diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index 6596dd184d..50a8c6512f 100644 --- a/packages/types/npm/package.metadata.json +++ b/packages/types/npm/package.metadata.json @@ -1,6 +1,6 @@ { "name": "@roo-code/types", - "version": "1.106.0", + "version": "1.110.0", "description": "TypeScript type definitions for Roo Code.", "publishConfig": { "access": "public", diff --git a/packages/types/src/__tests__/cloud.test.ts b/packages/types/src/__tests__/cloud.test.ts index 7a6cebd8a5..be8d631ce0 100644 --- a/packages/types/src/__tests__/cloud.test.ts +++ b/packages/types/src/__tests__/cloud.test.ts @@ -2,10 +2,12 @@ import { organizationCloudSettingsSchema, + organizationDefaultSettingsSchema, organizationFeaturesSchema, organizationSettingsSchema, userSettingsConfigSchema, type OrganizationCloudSettings, + type OrganizationDefaultSettings, type OrganizationFeatures, type OrganizationSettings, type UserSettingsConfig, @@ -481,3 +483,38 @@ describe("userSettingsConfigSchema with llmEnhancedFeaturesEnabled", () => { expect(result.data?.llmEnhancedFeaturesEnabled).toBe(true) }) }) + +describe("organizationDefaultSettingsSchema with disabledTools", () => { + it("should accept disabledTools as an array of valid tool names", () => { + const input: OrganizationDefaultSettings = { + disabledTools: ["execute_command", "browser_action"], + } + const result = organizationDefaultSettingsSchema.safeParse(input) + expect(result.success).toBe(true) + expect(result.data?.disabledTools).toEqual(["execute_command", "browser_action"]) + }) + + it("should accept empty disabledTools array", () => { + const input: OrganizationDefaultSettings = { + disabledTools: [], + } + const result = organizationDefaultSettingsSchema.safeParse(input) + expect(result.success).toBe(true) + expect(result.data?.disabledTools).toEqual([]) + }) + + it("should accept omitted disabledTools", () => { + const input: OrganizationDefaultSettings = {} + const result = organizationDefaultSettingsSchema.safeParse(input) + expect(result.success).toBe(true) + expect(result.data?.disabledTools).toBeUndefined() + }) + + it("should reject invalid tool names in disabledTools", () => { + const input = { + disabledTools: ["not_a_real_tool"], + } + const result = organizationDefaultSettingsSchema.safeParse(input) + expect(result.success).toBe(false) + }) +}) diff --git a/packages/types/src/__tests__/ipc.test.ts b/packages/types/src/__tests__/ipc.test.ts index dd0f7c5cdc..856b3f2cc1 100644 --- a/packages/types/src/__tests__/ipc.test.ts +++ b/packages/types/src/__tests__/ipc.test.ts @@ -27,7 +27,7 @@ describe("IPC Types", () => { const result = taskCommandSchema.safeParse(resumeTaskCommand) expect(result.success).toBe(true) - if (result.success) { + if (result.success && result.data.commandName === TaskCommandName.ResumeTask) { expect(result.data.commandName).toBe("ResumeTask") expect(result.data.data).toBe("non-existent-task-id") } @@ -45,7 +45,7 @@ describe("IPC Types", () => { const result = taskCommandSchema.safeParse(resumeTaskCommand) expect(result.success).toBe(true) - if (result.success) { + if (result.success && result.data.commandName === TaskCommandName.ResumeTask) { expect(result.data.commandName).toBe("ResumeTask") expect(result.data.data).toBe("task-123") } diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index 206a5647b3..2de8ce9168 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -101,6 +101,7 @@ export const organizationDefaultSettingsSchema = globalSettingsSchema terminalShellIntegrationDisabled: true, terminalShellIntegrationTimeout: true, terminalZshClearEolMark: true, + disabledTools: true, }) // Add stronger validations for some fields. .merge( diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index 5743ac2940..54267d67e4 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -1,6 +1,7 @@ import { z } from "zod" -import { clineMessageSchema, tokenUsageSchema } from "./message.js" +import { clineMessageSchema, queuedMessageSchema, tokenUsageSchema } from "./message.js" +import { modelInfoSchema } from "./model.js" import { toolNamesSchema, toolUsageSchema } from "./tool.js" /** @@ -35,6 +36,7 @@ export enum RooCodeEventName { TaskModeSwitched = "taskModeSwitched", TaskAskResponded = "taskAskResponded", TaskUserMessage = "taskUserMessage", + QueuedMessagesUpdated = "queuedMessagesUpdated", // Task Analytics TaskTokenUsageUpdated = "taskTokenUsageUpdated", @@ -44,6 +46,11 @@ export enum RooCodeEventName { ModeChanged = "modeChanged", ProviderProfileChanged = "providerProfileChanged", + // Query Responses + CommandsResponse = "commandsResponse", + ModesResponse = "modesResponse", + ModelsResponse = "modelsResponse", + // Evals EvalPass = "evalPass", EvalFail = "evalFail", @@ -100,12 +107,27 @@ export const rooCodeEventsSchema = z.object({ [RooCodeEventName.TaskModeSwitched]: z.tuple([z.string(), z.string()]), [RooCodeEventName.TaskAskResponded]: z.tuple([z.string()]), [RooCodeEventName.TaskUserMessage]: z.tuple([z.string()]), + [RooCodeEventName.QueuedMessagesUpdated]: z.tuple([z.string(), z.array(queuedMessageSchema)]), [RooCodeEventName.TaskToolFailed]: z.tuple([z.string(), toolNamesSchema, z.string()]), [RooCodeEventName.TaskTokenUsageUpdated]: z.tuple([z.string(), tokenUsageSchema, toolUsageSchema]), [RooCodeEventName.ModeChanged]: z.tuple([z.string()]), [RooCodeEventName.ProviderProfileChanged]: z.tuple([z.object({ name: z.string(), provider: z.string() })]), + + [RooCodeEventName.CommandsResponse]: z.tuple([ + z.array( + z.object({ + name: z.string(), + source: z.enum(["global", "project", "built-in"]), + filePath: z.string().optional(), + description: z.string().optional(), + argumentHint: z.string().optional(), + }), + ), + ]), + [RooCodeEventName.ModesResponse]: z.tuple([z.array(z.object({ slug: z.string(), name: z.string() }))]), + [RooCodeEventName.ModelsResponse]: z.tuple([z.record(z.string(), modelInfoSchema)]), }) export type RooCodeEvents = z.infer @@ -217,6 +239,11 @@ export const taskEventSchema = z.discriminatedUnion("eventName", [ payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskAskResponded], taskId: z.number().optional(), }), + z.object({ + eventName: z.literal(RooCodeEventName.QueuedMessagesUpdated), + payload: rooCodeEventsSchema.shape[RooCodeEventName.QueuedMessagesUpdated], + taskId: z.number().optional(), + }), // Task Analytics z.object({ @@ -230,6 +257,23 @@ export const taskEventSchema = z.discriminatedUnion("eventName", [ taskId: z.number().optional(), }), + // Query Responses + z.object({ + eventName: z.literal(RooCodeEventName.CommandsResponse), + payload: rooCodeEventsSchema.shape[RooCodeEventName.CommandsResponse], + taskId: z.number().optional(), + }), + z.object({ + eventName: z.literal(RooCodeEventName.ModesResponse), + payload: rooCodeEventsSchema.shape[RooCodeEventName.ModesResponse], + taskId: z.number().optional(), + }), + z.object({ + eventName: z.literal(RooCodeEventName.ModelsResponse), + payload: rooCodeEventsSchema.shape[RooCodeEventName.ModelsResponse], + taskId: z.number().optional(), + }), + // Evals z.object({ eventName: z.literal(RooCodeEventName.EvalPass), diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index f71b923d6a..f968698e4e 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -13,6 +13,7 @@ import { experimentsSchema } from "./experiment.js" import { telemetrySettingsSchema } from "./telemetry.js" import { modeConfigSchema } from "./mode.js" import { customModePromptsSchema, customSupportPromptsSchema } from "./mode.js" +import { toolNamesSchema } from "./tool.js" import { languagesSchema } from "./vscode.js" /** @@ -166,6 +167,7 @@ export const globalSettingsSchema = z.object({ ttsSpeed: z.number().optional(), soundEnabled: z.boolean().optional(), soundVolume: z.number().optional(), + taskHeaderHighlightEnabled: z.boolean().optional(), maxOpenTabsContext: z.number().optional(), maxWorkspaceFiles: z.number().optional(), @@ -238,6 +240,12 @@ export const globalSettingsSchema = z.object({ * @default true */ showWorktreesInHomeScreen: z.boolean().optional(), + + /** + * List of native tool names to globally disable. + * Tools in this list will be excluded from prompt generation and rejected at execution time. + */ + disabledTools: z.array(toolNamesSchema).optional(), }) export type GlobalSettings = z.infer @@ -266,19 +274,13 @@ export const SECRET_STATE_KEYS = [ "ollamaApiKey", "geminiApiKey", "openAiNativeApiKey", - "cerebrasApiKey", "deepSeekApiKey", - "doubaoApiKey", "moonshotApiKey", "mistralApiKey", "minimaxApiKey", - "unboundApiKey", "requestyApiKey", "xaiApiKey", - "groqApiKey", - "chutesApiKey", "litellmApiKey", - "deepInfraApiKey", "codeIndexOpenAiKey", "codeIndexQdrantApiKey", "codebaseIndexOpenAiCompatibleApiKey", @@ -286,14 +288,12 @@ export const SECRET_STATE_KEYS = [ "codebaseIndexMistralApiKey", "codebaseIndexVercelAiGatewayApiKey", "codebaseIndexOpenRouterApiKey", - "huggingFaceApiKey", "sambaNovaApiKey", "zaiApiKey", "fireworksApiKey", - "featherlessApiKey", - "ioIntelligenceApiKey", "vercelAiGatewayApiKey", "basetenApiKey", + "azureApiKey", ] as const // Global secrets that are part of GlobalSettings (not ProviderSettings) @@ -367,6 +367,7 @@ export const EVALS_SETTINGS: RooCodeSettings = { ttsSpeed: 1, soundEnabled: false, soundVolume: 0.5, + taskHeaderHighlightEnabled: false, terminalShellIntegrationTimeout: 30000, terminalCommandDelay: 0, diff --git a/packages/types/src/ipc.ts b/packages/types/src/ipc.ts index 4e1b1ac355..90a1478a4d 100644 --- a/packages/types/src/ipc.ts +++ b/packages/types/src/ipc.ts @@ -46,6 +46,9 @@ export enum TaskCommandName { CloseTask = "CloseTask", ResumeTask = "ResumeTask", SendMessage = "SendMessage", + GetCommands = "GetCommands", + GetModes = "GetModes", + GetModels = "GetModels", } /** @@ -64,11 +67,9 @@ export const taskCommandSchema = z.discriminatedUnion("commandName", [ }), z.object({ commandName: z.literal(TaskCommandName.CancelTask), - data: z.string(), }), z.object({ commandName: z.literal(TaskCommandName.CloseTask), - data: z.string(), }), z.object({ commandName: z.literal(TaskCommandName.ResumeTask), @@ -81,6 +82,15 @@ export const taskCommandSchema = z.discriminatedUnion("commandName", [ images: z.array(z.string()).optional(), }), }), + z.object({ + commandName: z.literal(TaskCommandName.GetCommands), + }), + z.object({ + commandName: z.literal(TaskCommandName.GetModes), + }), + z.object({ + commandName: z.literal(TaskCommandName.GetModels), + }), ]) export type TaskCommand = z.infer diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 0c5965f7ff..bf3364d38d 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -6,14 +6,9 @@ import { anthropicModels, basetenModels, bedrockModels, - cerebrasModels, deepSeekModels, - doubaoModels, - featherlessModels, fireworksModels, geminiModels, - groqModels, - ioIntelligenceModels, mistralModels, moonshotModels, openAiCodexModels, @@ -39,18 +34,7 @@ export const DEFAULT_CONSECUTIVE_MISTAKE_LIMIT = 3 * Dynamic provider requires external API calls in order to get the model list. */ -export const dynamicProviders = [ - "openrouter", - "vercel-ai-gateway", - "huggingface", - "litellm", - "deepinfra", - "io-intelligence", - "requesty", - "unbound", - "roo", - "chutes", -] as const +export const dynamicProviders = ["openrouter", "vercel-ai-gateway", "litellm", "requesty", "roo"] as const export type DynamicProvider = (typeof dynamicProviders)[number] @@ -119,16 +103,13 @@ export const providerNames = [ ...customProviders, ...fauxProviders, "anthropic", + "azure", "bedrock", "baseten", - "cerebras", - "doubao", "deepseek", - "featherless", "fireworks", "gemini", "gemini-cli", - "groq", "mistral", "moonshot", "minimax", @@ -149,6 +130,33 @@ export type ProviderName = z.infer export const isProviderName = (key: unknown): key is ProviderName => typeof key === "string" && providerNames.includes(key as ProviderName) +/** + * RetiredProviderName + */ + +export const retiredProviderNames = [ + "cerebras", + "chutes", + "deepinfra", + "doubao", + "featherless", + "groq", + "huggingface", + "io-intelligence", + "unbound", +] as const + +export const retiredProviderNamesSchema = z.enum(retiredProviderNames) + +export type RetiredProviderName = z.infer + +export const isRetiredProvider = (value: string): value is RetiredProviderName => + retiredProviderNames.includes(value as RetiredProviderName) + +export const providerNamesWithRetiredSchema = z.union([providerNamesSchema, retiredProviderNamesSchema]) + +export type ProviderNameWithRetired = z.infer + /** * ProviderSettingsEntry */ @@ -156,7 +164,7 @@ export const isProviderName = (key: unknown): key is ProviderName => export const providerSettingsEntrySchema = z.object({ id: z.string(), name: z.string(), - apiProvider: providerNamesSchema.optional(), + apiProvider: providerNamesWithRetiredSchema.optional(), modelId: z.string().optional(), }) @@ -227,8 +235,6 @@ const vertexSchema = apiModelIdProviderModelSchema.extend({ vertexJsonCredentials: z.string().optional(), vertexProjectId: z.string().optional(), vertexRegion: z.string().optional(), - enableUrlContext: z.boolean().optional(), - enableGrounding: z.boolean().optional(), vertex1MContext: z.boolean().optional(), // Enable 'context-1m-2025-08-07' beta for 1M context window. }) @@ -273,8 +279,6 @@ const lmStudioSchema = baseProviderSettingsSchema.extend({ const geminiSchema = apiModelIdProviderModelSchema.extend({ geminiApiKey: z.string().optional(), googleGeminiBaseUrl: z.string().optional(), - enableUrlContext: z.boolean().optional(), - enableGrounding: z.boolean().optional(), }) const geminiCliSchema = apiModelIdProviderModelSchema.extend({ @@ -304,17 +308,6 @@ const deepSeekSchema = apiModelIdProviderModelSchema.extend({ deepSeekApiKey: z.string().optional(), }) -const deepInfraSchema = apiModelIdProviderModelSchema.extend({ - deepInfraBaseUrl: z.string().optional(), - deepInfraApiKey: z.string().optional(), - deepInfraModelId: z.string().optional(), -}) - -const doubaoSchema = apiModelIdProviderModelSchema.extend({ - doubaoBaseUrl: z.string().optional(), - doubaoApiKey: z.string().optional(), -}) - const moonshotSchema = apiModelIdProviderModelSchema.extend({ moonshotBaseUrl: z .union([z.literal("https://api.moonshot.ai/v1"), z.literal("https://api.moonshot.cn/v1")]) @@ -329,11 +322,6 @@ const minimaxSchema = apiModelIdProviderModelSchema.extend({ minimaxApiKey: z.string().optional(), }) -const unboundSchema = baseProviderSettingsSchema.extend({ - unboundApiKey: z.string().optional(), - unboundModelId: z.string().optional(), -}) - const requestySchema = baseProviderSettingsSchema.extend({ requestyBaseUrl: z.string().optional(), requestyApiKey: z.string().optional(), @@ -348,20 +336,6 @@ const xaiSchema = apiModelIdProviderModelSchema.extend({ xaiApiKey: z.string().optional(), }) -const groqSchema = apiModelIdProviderModelSchema.extend({ - groqApiKey: z.string().optional(), -}) - -const huggingFaceSchema = baseProviderSettingsSchema.extend({ - huggingFaceApiKey: z.string().optional(), - huggingFaceModelId: z.string().optional(), - huggingFaceInferenceProvider: z.string().optional(), -}) - -const chutesSchema = apiModelIdProviderModelSchema.extend({ - chutesApiKey: z.string().optional(), -}) - const litellmSchema = baseProviderSettingsSchema.extend({ litellmBaseUrl: z.string().optional(), litellmApiKey: z.string().optional(), @@ -369,10 +343,6 @@ const litellmSchema = baseProviderSettingsSchema.extend({ litellmUsePromptCache: z.boolean().optional(), }) -const cerebrasSchema = apiModelIdProviderModelSchema.extend({ - cerebrasApiKey: z.string().optional(), -}) - const sambaNovaSchema = apiModelIdProviderModelSchema.extend({ sambaNovaApiKey: z.string().optional(), }) @@ -390,15 +360,6 @@ const fireworksSchema = apiModelIdProviderModelSchema.extend({ fireworksApiKey: z.string().optional(), }) -const featherlessSchema = apiModelIdProviderModelSchema.extend({ - featherlessApiKey: z.string().optional(), -}) - -const ioIntelligenceSchema = apiModelIdProviderModelSchema.extend({ - ioIntelligenceModelId: z.string().optional(), - ioIntelligenceApiKey: z.string().optional(), -}) - const qwenCodeSchema = apiModelIdProviderModelSchema.extend({ qwenCodeOauthPath: z.string().optional(), }) @@ -417,12 +378,20 @@ const basetenSchema = apiModelIdProviderModelSchema.extend({ basetenApiKey: z.string().optional(), }) +const azureSchema = apiModelIdProviderModelSchema.extend({ + azureApiKey: z.string().optional(), + azureResourceName: z.string().optional(), + azureDeploymentName: z.string().optional(), + azureApiVersion: z.string().optional(), +}) + const defaultSchema = z.object({ apiProvider: z.undefined(), }) export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProvider", [ anthropicSchema.merge(z.object({ apiProvider: z.literal("anthropic") })), + azureSchema.merge(z.object({ apiProvider: z.literal("azure") })), openRouterSchema.merge(z.object({ apiProvider: z.literal("openrouter") })), bedrockSchema.merge(z.object({ apiProvider: z.literal("bedrock") })), vertexSchema.merge(z.object({ apiProvider: z.literal("vertex") })), @@ -436,25 +405,16 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv openAiNativeSchema.merge(z.object({ apiProvider: z.literal("openai-native") })), mistralSchema.merge(z.object({ apiProvider: z.literal("mistral") })), deepSeekSchema.merge(z.object({ apiProvider: z.literal("deepseek") })), - deepInfraSchema.merge(z.object({ apiProvider: z.literal("deepinfra") })), - doubaoSchema.merge(z.object({ apiProvider: z.literal("doubao") })), moonshotSchema.merge(z.object({ apiProvider: z.literal("moonshot") })), minimaxSchema.merge(z.object({ apiProvider: z.literal("minimax") })), - unboundSchema.merge(z.object({ apiProvider: z.literal("unbound") })), requestySchema.merge(z.object({ apiProvider: z.literal("requesty") })), fakeAiSchema.merge(z.object({ apiProvider: z.literal("fake-ai") })), xaiSchema.merge(z.object({ apiProvider: z.literal("xai") })), - groqSchema.merge(z.object({ apiProvider: z.literal("groq") })), basetenSchema.merge(z.object({ apiProvider: z.literal("baseten") })), - huggingFaceSchema.merge(z.object({ apiProvider: z.literal("huggingface") })), - chutesSchema.merge(z.object({ apiProvider: z.literal("chutes") })), litellmSchema.merge(z.object({ apiProvider: z.literal("litellm") })), - cerebrasSchema.merge(z.object({ apiProvider: z.literal("cerebras") })), sambaNovaSchema.merge(z.object({ apiProvider: z.literal("sambanova") })), zaiSchema.merge(z.object({ apiProvider: z.literal("zai") })), fireworksSchema.merge(z.object({ apiProvider: z.literal("fireworks") })), - featherlessSchema.merge(z.object({ apiProvider: z.literal("featherless") })), - ioIntelligenceSchema.merge(z.object({ apiProvider: z.literal("io-intelligence") })), qwenCodeSchema.merge(z.object({ apiProvider: z.literal("qwen-code") })), rooSchema.merge(z.object({ apiProvider: z.literal("roo") })), vercelAiGatewaySchema.merge(z.object({ apiProvider: z.literal("vercel-ai-gateway") })), @@ -462,8 +422,9 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv ]) export const providerSettingsSchema = z.object({ - apiProvider: providerNamesSchema.optional(), + apiProvider: providerNamesWithRetiredSchema.optional(), ...anthropicSchema.shape, + ...azureSchema.shape, ...openRouterSchema.shape, ...bedrockSchema.shape, ...vertexSchema.shape, @@ -477,25 +438,16 @@ export const providerSettingsSchema = z.object({ ...openAiNativeSchema.shape, ...mistralSchema.shape, ...deepSeekSchema.shape, - ...deepInfraSchema.shape, - ...doubaoSchema.shape, ...moonshotSchema.shape, ...minimaxSchema.shape, - ...unboundSchema.shape, ...requestySchema.shape, ...fakeAiSchema.shape, ...xaiSchema.shape, - ...groqSchema.shape, ...basetenSchema.shape, - ...huggingFaceSchema.shape, - ...chutesSchema.shape, ...litellmSchema.shape, - ...cerebrasSchema.shape, ...sambaNovaSchema.shape, ...zaiSchema.shape, ...fireworksSchema.shape, - ...featherlessSchema.shape, - ...ioIntelligenceSchema.shape, ...qwenCodeSchema.shape, ...rooSchema.shape, ...vercelAiGatewaySchema.shape, @@ -525,13 +477,9 @@ export const modelIdKeys = [ "ollamaModelId", "lmStudioModelId", "lmStudioDraftModelId", - "unboundModelId", "requestyModelId", "litellmModelId", - "huggingFaceModelId", - "ioIntelligenceModelId", "vercelAiGatewayModelId", - "deepInfraModelId", ] as const satisfies readonly (keyof ProviderSettings)[] export type ModelIdKey = (typeof modelIdKeys)[number] @@ -552,6 +500,7 @@ export const isTypicalProvider = (key: unknown): key is TypicalProvider => export const modelIdKeysByProvider: Record = { anthropic: "apiModelId", + azure: "apiModelId", openrouter: "openRouterModelId", bedrock: "apiModelId", vertex: "apiModelId", @@ -565,23 +514,14 @@ export const modelIdKeysByProvider: Record = { moonshot: "apiModelId", minimax: "apiModelId", deepseek: "apiModelId", - deepinfra: "deepInfraModelId", - doubao: "apiModelId", "qwen-code": "apiModelId", - unbound: "unboundModelId", requesty: "requestyModelId", xai: "apiModelId", - groq: "apiModelId", baseten: "apiModelId", - chutes: "apiModelId", litellm: "litellmModelId", - huggingface: "huggingFaceModelId", - cerebras: "apiModelId", sambanova: "apiModelId", zai: "apiModelId", fireworks: "apiModelId", - featherless: "apiModelId", - "io-intelligence": "ioIntelligenceModelId", roo: "apiModelId", "vercel-ai-gateway": "vercelAiGatewayModelId", } @@ -628,27 +568,22 @@ export const MODELS_BY_PROVIDER: Record< label: "Anthropic", models: Object.keys(anthropicModels), }, + azure: { + id: "azure", + label: "Azure AI Foundry", + // Azure uses deployment names configured by the user (not a fixed upstream model ID list) + models: [], + }, bedrock: { id: "bedrock", label: "Amazon Bedrock", models: Object.keys(bedrockModels), }, - cerebras: { - id: "cerebras", - label: "Cerebras", - models: Object.keys(cerebrasModels), - }, deepseek: { id: "deepseek", label: "DeepSeek", models: Object.keys(deepSeekModels), }, - doubao: { id: "doubao", label: "Doubao", models: Object.keys(doubaoModels) }, - featherless: { - id: "featherless", - label: "Featherless", - models: Object.keys(featherlessModels), - }, fireworks: { id: "fireworks", label: "Fireworks", @@ -659,12 +594,6 @@ export const MODELS_BY_PROVIDER: Record< label: "Google Gemini", models: Object.keys(geminiModels), }, - groq: { id: "groq", label: "Groq", models: Object.keys(groqModels) }, - "io-intelligence": { - id: "io-intelligence", - label: "IO Intelligence", - models: Object.keys(ioIntelligenceModels), - }, mistral: { id: "mistral", label: "Mistral", @@ -712,14 +641,10 @@ export const MODELS_BY_PROVIDER: Record< baseten: { id: "baseten", label: "Baseten", models: Object.keys(basetenModels) }, // Dynamic providers; models pulled from remote APIs. - huggingface: { id: "huggingface", label: "Hugging Face", models: [] }, litellm: { id: "litellm", label: "LiteLLM", models: [] }, openrouter: { id: "openrouter", label: "OpenRouter", models: [] }, requesty: { id: "requesty", label: "Requesty", models: [] }, - unbound: { id: "unbound", label: "Unbound", models: [] }, - deepinfra: { id: "deepinfra", label: "DeepInfra", models: [] }, "vercel-ai-gateway": { id: "vercel-ai-gateway", label: "Vercel AI Gateway", models: [] }, - chutes: { id: "chutes", label: "Chutes AI", models: [] }, // Local providers; models discovered from localhost endpoints. lmstudio: { id: "lmstudio", label: "LM Studio", models: [] }, diff --git a/packages/types/src/providers/anthropic.ts b/packages/types/src/providers/anthropic.ts index 883b6eb716..62e377c7e5 100644 --- a/packages/types/src/providers/anthropic.ts +++ b/packages/types/src/providers/anthropic.ts @@ -1,6 +1,7 @@ import type { ModelInfo } from "../model.js" // https://docs.anthropic.com/en/docs/about-claude/models +// https://platform.claude.com/docs/en/about-claude/pricing export type AnthropicModelId = keyof typeof anthropicModels export const anthropicDefaultModelId: AnthropicModelId = "claude-sonnet-4-5" @@ -48,6 +49,27 @@ export const anthropicModels = { }, ], }, + "claude-opus-4-6": { + maxTokens: 128_000, // Overridden to 8k if `enableReasoningEffort` is false. + contextWindow: 200_000, // Default 200K, extendable to 1M with beta flag + supportsImages: true, + supportsPromptCache: true, + inputPrice: 5.0, // $5 per million input tokens (≤200K context) + outputPrice: 25.0, // $25 per million output tokens (≤200K context) + cacheWritesPrice: 6.25, // $6.25 per million tokens + cacheReadsPrice: 0.5, // $0.50 per million tokens + supportsReasoningBudget: true, + // Tiered pricing for extended context (requires beta flag) + tiers: [ + { + contextWindow: 1_000_000, // 1M tokens with beta flag + inputPrice: 10.0, // $10 per million input tokens (>200K context) + outputPrice: 37.5, // $37.50 per million output tokens (>200K context) + cacheWritesPrice: 12.5, // $12.50 per million tokens (>200K context) + cacheReadsPrice: 1.0, // $1.00 per million tokens (>200K context) + }, + ], + }, "claude-opus-4-5-20251101": { maxTokens: 32_000, // Overridden to 8k if `enableReasoningEffort` is false. contextWindow: 200_000, diff --git a/packages/types/src/providers/azure.ts b/packages/types/src/providers/azure.ts new file mode 100644 index 0000000000..8ba3480070 --- /dev/null +++ b/packages/types/src/providers/azure.ts @@ -0,0 +1,403 @@ +import type { ModelInfo } from "../model.js" + +/** + * Azure AI Foundry model metadata. + * + * NOTE: + * - Azure AI Foundry uses *deployment names* at runtime, but Roo still needs underlying model + * capabilities (maxTokens/contextWindow/etc.) for validation and parameter shaping. + * - This list is derived from https://models.dev/api.json (provider: "azure") and intentionally + * restricted to OpenAI/Azure OpenAI-style IDs (gpt-*, o*, codex-*). + */ +export const azureModels = { + "codex-mini": { + maxTokens: 100_000, + contextWindow: 200_000, + supportsImages: false, + supportsPromptCache: true, + inputPrice: 1.5, + outputPrice: 6, + cacheReadsPrice: 0.375, + supportsTemperature: false, + description: + "Codex Mini: Cloud-based software engineering agent powered by codex-1, a version of o3 optimized for coding tasks", + }, + "gpt-4": { + maxTokens: 8_192, + contextWindow: 8_192, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 60, + outputPrice: 120, + supportsTemperature: true, + description: "GPT-4", + }, + "gpt-4-32k": { + maxTokens: 32_768, + contextWindow: 32_768, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 60, + outputPrice: 120, + supportsTemperature: true, + description: "GPT-4 32K", + }, + "gpt-4-turbo": { + maxTokens: 4_096, + contextWindow: 128_000, + supportsImages: true, + supportsPromptCache: false, + inputPrice: 10, + outputPrice: 30, + supportsTemperature: true, + description: "GPT-4 Turbo", + }, + "gpt-4-turbo-vision": { + maxTokens: 4_096, + contextWindow: 128_000, + supportsImages: true, + supportsPromptCache: false, + inputPrice: 10, + outputPrice: 30, + supportsTemperature: true, + description: "GPT-4 Turbo Vision", + }, + "gpt-4.1": { + maxTokens: 32_768, + contextWindow: 1_047_576, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 2, + outputPrice: 8, + cacheReadsPrice: 0.5, + supportsTemperature: true, + description: "GPT-4.1", + }, + "gpt-4.1-mini": { + maxTokens: 32_768, + contextWindow: 1_047_576, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 0.4, + outputPrice: 1.6, + cacheReadsPrice: 0.1, + supportsTemperature: true, + description: "GPT-4.1 mini", + }, + "gpt-4.1-nano": { + maxTokens: 32_768, + contextWindow: 1_047_576, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 0.1, + outputPrice: 0.4, + cacheReadsPrice: 0.03, + supportsTemperature: true, + description: "GPT-4.1 nano", + }, + "gpt-4o": { + maxTokens: 16_384, + contextWindow: 128_000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 2.5, + outputPrice: 10, + cacheReadsPrice: 1.25, + supportsTemperature: true, + description: "GPT-4o", + }, + "gpt-4o-mini": { + maxTokens: 16_384, + contextWindow: 128_000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 0.15, + outputPrice: 0.6, + cacheReadsPrice: 0.08, + supportsTemperature: true, + description: "GPT-4o mini", + }, + "gpt-5": { + maxTokens: 128_000, + contextWindow: 272_000, + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["minimal", "low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 1.25, + outputPrice: 10, + cacheReadsPrice: 0.13, + supportsVerbosity: true, + supportsTemperature: false, + description: "GPT-5: The best model for coding and agentic tasks across domains", + }, + "gpt-5-codex": { + maxTokens: 128_000, + contextWindow: 400_000, + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 1.25, + outputPrice: 10, + cacheReadsPrice: 0.13, + supportsTemperature: false, + description: "GPT-5-Codex: A version of GPT-5 optimized for agentic coding in Codex", + }, + "gpt-5-mini": { + maxTokens: 128_000, + contextWindow: 272_000, + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["minimal", "low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 0.25, + outputPrice: 2, + cacheReadsPrice: 0.03, + supportsVerbosity: true, + supportsTemperature: false, + description: "GPT-5 Mini: A faster, more cost-efficient version of GPT-5 for well-defined tasks", + }, + "gpt-5-nano": { + maxTokens: 128_000, + contextWindow: 272_000, + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["minimal", "low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 0.05, + outputPrice: 0.4, + cacheReadsPrice: 0.01, + supportsVerbosity: true, + supportsTemperature: false, + description: "GPT-5 Nano: Fastest, most cost-efficient version of GPT-5", + }, + "gpt-5-pro": { + maxTokens: 272_000, + contextWindow: 400_000, + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: false, + supportsReasoningEffort: ["minimal", "low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 15, + outputPrice: 120, + supportsVerbosity: true, + supportsTemperature: false, + description: "GPT-5 Pro", + }, + "gpt-5.1": { + maxTokens: 128_000, + contextWindow: 272_000, + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + promptCacheRetention: "24h", + supportsReasoningEffort: ["none", "low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 1.25, + outputPrice: 10, + cacheReadsPrice: 0.125, + supportsVerbosity: true, + supportsTemperature: false, + description: "GPT-5.1: The best model for coding and agentic tasks across domains", + }, + "gpt-5.1-chat": { + maxTokens: 16_384, + contextWindow: 128_000, + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + promptCacheRetention: "24h", + inputPrice: 1.25, + outputPrice: 10, + cacheReadsPrice: 0.125, + supportsTemperature: false, + description: "GPT-5.1 Chat: Optimized for conversational AI and chat use cases", + }, + "gpt-5.1-codex": { + maxTokens: 128_000, + contextWindow: 400_000, + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + promptCacheRetention: "24h", + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 1.25, + outputPrice: 10, + cacheReadsPrice: 0.125, + supportsTemperature: false, + description: "GPT-5.1 Codex: A version of GPT-5.1 optimized for agentic coding in Codex", + }, + "gpt-5.1-codex-max": { + maxTokens: 128_000, + contextWindow: 400_000, + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + promptCacheRetention: "24h", + supportsReasoningEffort: ["low", "medium", "high", "xhigh"], + reasoningEffort: "medium", + inputPrice: 1.25, + outputPrice: 10, + cacheReadsPrice: 0.125, + supportsTemperature: false, + description: + "GPT-5.1 Codex Max: Our most intelligent coding model optimized for long-horizon, agentic coding tasks", + }, + "gpt-5.1-codex-mini": { + maxTokens: 128_000, + contextWindow: 400_000, + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + promptCacheRetention: "24h", + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 0.25, + outputPrice: 2, + cacheReadsPrice: 0.025, + supportsTemperature: false, + description: "GPT-5.1 Codex mini: A version of GPT-5.1 optimized for agentic coding in Codex", + }, + "gpt-5.2": { + maxTokens: 128_000, + contextWindow: 400_000, + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + promptCacheRetention: "24h", + supportsReasoningEffort: ["none", "low", "medium", "high", "xhigh"], + reasoningEffort: "medium", + inputPrice: 1.75, + outputPrice: 14, + cacheReadsPrice: 0.125, + supportsVerbosity: true, + supportsTemperature: false, + description: "GPT-5.2: Our flagship model for coding and agentic tasks across industries", + }, + "gpt-5.2-chat": { + maxTokens: 16_384, + contextWindow: 128_000, + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + inputPrice: 1.75, + outputPrice: 14, + cacheReadsPrice: 0.175, + supportsTemperature: false, + description: "GPT-5.2 Chat: Optimized for conversational AI and chat use cases", + }, + "gpt-5.2-codex": { + maxTokens: 128_000, + contextWindow: 400_000, + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + promptCacheRetention: "24h", + supportsReasoningEffort: ["low", "medium", "high", "xhigh"], + reasoningEffort: "medium", + inputPrice: 1.75, + outputPrice: 14, + cacheReadsPrice: 0.175, + supportsTemperature: false, + description: + "GPT-5.2 Codex: Our most intelligent coding model optimized for long-horizon, agentic coding tasks", + }, + o1: { + maxTokens: 100_000, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 15, + outputPrice: 60, + cacheReadsPrice: 7.5, + supportsTemperature: false, + description: "o1", + }, + "o1-mini": { + maxTokens: 65_536, + contextWindow: 128_000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 1.1, + outputPrice: 4.4, + cacheReadsPrice: 0.55, + supportsTemperature: false, + description: "o1-mini", + }, + "o1-preview": { + maxTokens: 32_768, + contextWindow: 128_000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 16.5, + outputPrice: 66, + cacheReadsPrice: 8.25, + supportsTemperature: false, + description: "o1-preview", + }, + o3: { + maxTokens: 100_000, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 2, + outputPrice: 8, + cacheReadsPrice: 0.5, + supportsTemperature: false, + description: "o3", + }, + "o3-mini": { + maxTokens: 100_000, + contextWindow: 200_000, + supportsImages: false, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 1.1, + outputPrice: 4.4, + cacheReadsPrice: 0.55, + supportsTemperature: false, + description: "o3-mini", + }, + "o4-mini": { + maxTokens: 100_000, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 1.1, + outputPrice: 4.4, + cacheReadsPrice: 0.28, + supportsTemperature: false, + description: "o4-mini", + }, +} as const satisfies Record + +export type AzureModelId = keyof typeof azureModels + +export const azureDefaultModelId: AzureModelId = "gpt-4o" + +export const azureDefaultModelInfo: ModelInfo = azureModels[azureDefaultModelId] diff --git a/packages/types/src/providers/bedrock.ts b/packages/types/src/providers/bedrock.ts index 1a95cf33c5..008961b301 100644 --- a/packages/types/src/providers/bedrock.ts +++ b/packages/types/src/providers/bedrock.ts @@ -119,6 +119,30 @@ export const bedrockModels = { maxCachePoints: 4, cachableFields: ["system", "messages", "tools"], }, + "anthropic.claude-opus-4-6-v1": { + maxTokens: 8192, + contextWindow: 200_000, // Default 200K, extendable to 1M with beta flag 'context-1m-2025-08-07' + supportsImages: true, + supportsPromptCache: true, + supportsReasoningBudget: true, + inputPrice: 5.0, // $5 per million input tokens (≤200K context) + outputPrice: 25.0, // $25 per million output tokens (≤200K context) + cacheWritesPrice: 6.25, // $6.25 per million tokens + cacheReadsPrice: 0.5, // $0.50 per million tokens + minTokensPerCachePoint: 1024, + maxCachePoints: 4, + cachableFields: ["system", "messages", "tools"], + // Tiered pricing for extended context (requires beta flag 'context-1m-2025-08-07') + tiers: [ + { + contextWindow: 1_000_000, // 1M tokens with beta flag + inputPrice: 10.0, // $10 per million input tokens (>200K context) + outputPrice: 37.5, // $37.50 per million output tokens (>200K context) + cacheWritesPrice: 12.5, // $12.50 per million tokens (>200K context) + cacheReadsPrice: 1.0, // $1.00 per million tokens (>200K context) + }, + ], + }, "anthropic.claude-opus-4-5-20251101-v1:0": { maxTokens: 8192, contextWindow: 200_000, @@ -475,6 +499,7 @@ export const BEDROCK_REGIONS = [ export const BEDROCK_1M_CONTEXT_MODEL_IDS = [ "anthropic.claude-sonnet-4-20250514-v1:0", "anthropic.claude-sonnet-4-5-20250929-v1:0", + "anthropic.claude-opus-4-6-v1", ] as const // Amazon Bedrock models that support Global Inference profiles @@ -483,11 +508,13 @@ export const BEDROCK_1M_CONTEXT_MODEL_IDS = [ // - Claude Sonnet 4.5 // - Claude Haiku 4.5 // - Claude Opus 4.5 +// - Claude Opus 4.6 export const BEDROCK_GLOBAL_INFERENCE_MODEL_IDS = [ "anthropic.claude-sonnet-4-20250514-v1:0", "anthropic.claude-sonnet-4-5-20250929-v1:0", "anthropic.claude-haiku-4-5-20251001-v1:0", "anthropic.claude-opus-4-5-20251101-v1:0", + "anthropic.claude-opus-4-6-v1", ] as const // Amazon Bedrock Service Tier types diff --git a/packages/types/src/providers/cerebras.ts b/packages/types/src/providers/cerebras.ts deleted file mode 100644 index 2e9fccaa9d..0000000000 --- a/packages/types/src/providers/cerebras.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { ModelInfo } from "../model.js" - -// https://inference-docs.cerebras.ai/api-reference/chat-completions -export type CerebrasModelId = keyof typeof cerebrasModels - -export const cerebrasDefaultModelId: CerebrasModelId = "gpt-oss-120b" - -export const cerebrasModels = { - "zai-glm-4.7": { - maxTokens: 16384, // Conservative default to avoid premature rate limiting (Cerebras reserves quota upfront) - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: true, - supportsTemperature: true, - defaultTemperature: 1.0, - inputPrice: 0, - outputPrice: 0, - description: - "Highly capable general-purpose model on Cerebras (up to 1,000 tokens/s), competitive with leading proprietary models on coding tasks.", - }, - "qwen-3-235b-a22b-instruct-2507": { - maxTokens: 16384, // Conservative default to avoid premature rate limiting - contextWindow: 64000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Intelligent model with ~1400 tokens/s", - }, - "llama-3.3-70b": { - maxTokens: 16384, // Conservative default to avoid premature rate limiting - contextWindow: 64000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Powerful model with ~2600 tokens/s", - }, - "qwen-3-32b": { - maxTokens: 16384, // Conservative default to avoid premature rate limiting - contextWindow: 64000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "SOTA coding performance with ~2500 tokens/s", - }, - "gpt-oss-120b": { - maxTokens: 16384, // Conservative default to avoid premature rate limiting - contextWindow: 64000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: - "OpenAI GPT OSS model with ~2800 tokens/s\n\n• 64K context window\n• Excels at efficient reasoning across science, math, and coding", - }, -} as const satisfies Record diff --git a/packages/types/src/providers/chutes.ts b/packages/types/src/providers/chutes.ts deleted file mode 100644 index 69e6b2e68b..0000000000 --- a/packages/types/src/providers/chutes.ts +++ /dev/null @@ -1,421 +0,0 @@ -import type { ModelInfo } from "../model.js" - -// https://llm.chutes.ai/v1 (OpenAI compatible) -export type ChutesModelId = - | "deepseek-ai/DeepSeek-R1-0528" - | "deepseek-ai/DeepSeek-R1" - | "deepseek-ai/DeepSeek-V3" - | "deepseek-ai/DeepSeek-V3.1" - | "deepseek-ai/DeepSeek-V3.1-Terminus" - | "deepseek-ai/DeepSeek-V3.1-turbo" - | "deepseek-ai/DeepSeek-V3.2-Exp" - | "unsloth/Llama-3.3-70B-Instruct" - | "chutesai/Llama-4-Scout-17B-16E-Instruct" - | "unsloth/Mistral-Nemo-Instruct-2407" - | "unsloth/gemma-3-12b-it" - | "NousResearch/DeepHermes-3-Llama-3-8B-Preview" - | "unsloth/gemma-3-4b-it" - | "nvidia/Llama-3_3-Nemotron-Super-49B-v1" - | "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1" - | "chutesai/Llama-4-Maverick-17B-128E-Instruct-FP8" - | "deepseek-ai/DeepSeek-V3-Base" - | "deepseek-ai/DeepSeek-R1-Zero" - | "deepseek-ai/DeepSeek-V3-0324" - | "Qwen/Qwen3-235B-A22B" - | "Qwen/Qwen3-235B-A22B-Instruct-2507" - | "Qwen/Qwen3-32B" - | "Qwen/Qwen3-30B-A3B" - | "Qwen/Qwen3-14B" - | "Qwen/Qwen3-8B" - | "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8" - | "microsoft/MAI-DS-R1-FP8" - | "tngtech/DeepSeek-R1T-Chimera" - | "zai-org/GLM-4.5-Air" - | "zai-org/GLM-4.5-FP8" - | "zai-org/GLM-4.5-turbo" - | "zai-org/GLM-4.6-FP8" - | "zai-org/GLM-4.6-turbo" - | "meituan-longcat/LongCat-Flash-Thinking-FP8" - | "moonshotai/Kimi-K2-Instruct-75k" - | "moonshotai/Kimi-K2-Instruct-0905" - | "Qwen/Qwen3-235B-A22B-Thinking-2507" - | "Qwen/Qwen3-Next-80B-A3B-Instruct" - | "Qwen/Qwen3-Next-80B-A3B-Thinking" - | "Qwen/Qwen3-VL-235B-A22B-Thinking" - -export const chutesDefaultModelId: ChutesModelId = "deepseek-ai/DeepSeek-R1-0528" - -export const chutesModels = { - "deepseek-ai/DeepSeek-R1-0528": { - maxTokens: 32768, - contextWindow: 163840, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "DeepSeek R1 0528 model.", - }, - "deepseek-ai/DeepSeek-R1": { - maxTokens: 32768, - contextWindow: 163840, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "DeepSeek R1 model.", - }, - "deepseek-ai/DeepSeek-V3": { - maxTokens: 32768, - contextWindow: 163840, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "DeepSeek V3 model.", - }, - "deepseek-ai/DeepSeek-V3.1": { - maxTokens: 32768, - contextWindow: 163840, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "DeepSeek V3.1 model.", - }, - "deepseek-ai/DeepSeek-V3.1-Terminus": { - maxTokens: 163840, - contextWindow: 163840, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.23, - outputPrice: 0.9, - description: - "DeepSeek‑V3.1‑Terminus is an update to V3.1 that improves language consistency by reducing CN/EN mix‑ups and eliminating random characters, while strengthening agent capabilities with notably better Code Agent and Search Agent performance.", - }, - "deepseek-ai/DeepSeek-V3.1-turbo": { - maxTokens: 32768, - contextWindow: 163840, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 1.0, - outputPrice: 3.0, - description: - "DeepSeek-V3.1-turbo is an FP8, speculative-decoding turbo variant optimized for ultra-fast single-shot queries (~200 TPS), with outputs close to the originals and solid function calling/reasoning/structured output, priced at $1/M input and $3/M output tokens, using 2× quota per request and not intended for bulk workloads.", - }, - "deepseek-ai/DeepSeek-V3.2-Exp": { - maxTokens: 163840, - contextWindow: 163840, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.25, - outputPrice: 0.35, - description: - "DeepSeek-V3.2-Exp is an experimental LLM that introduces DeepSeek Sparse Attention to improve long‑context training and inference efficiency while maintaining performance comparable to V3.1‑Terminus.", - }, - "unsloth/Llama-3.3-70B-Instruct": { - maxTokens: 32768, // From Groq - contextWindow: 131072, // From Groq - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Unsloth Llama 3.3 70B Instruct model.", - }, - "chutesai/Llama-4-Scout-17B-16E-Instruct": { - maxTokens: 32768, - contextWindow: 512000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "ChutesAI Llama 4 Scout 17B Instruct model, 512K context.", - }, - "unsloth/Mistral-Nemo-Instruct-2407": { - maxTokens: 32768, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Unsloth Mistral Nemo Instruct model.", - }, - "unsloth/gemma-3-12b-it": { - maxTokens: 32768, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Unsloth Gemma 3 12B IT model.", - }, - "NousResearch/DeepHermes-3-Llama-3-8B-Preview": { - maxTokens: 32768, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Nous DeepHermes 3 Llama 3 8B Preview model.", - }, - "unsloth/gemma-3-4b-it": { - maxTokens: 32768, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Unsloth Gemma 3 4B IT model.", - }, - "nvidia/Llama-3_3-Nemotron-Super-49B-v1": { - maxTokens: 32768, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Nvidia Llama 3.3 Nemotron Super 49B model.", - }, - "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1": { - maxTokens: 32768, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Nvidia Llama 3.1 Nemotron Ultra 253B model.", - }, - "chutesai/Llama-4-Maverick-17B-128E-Instruct-FP8": { - maxTokens: 32768, - contextWindow: 256000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "ChutesAI Llama 4 Maverick 17B Instruct FP8 model.", - }, - "deepseek-ai/DeepSeek-V3-Base": { - maxTokens: 32768, - contextWindow: 163840, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "DeepSeek V3 Base model.", - }, - "deepseek-ai/DeepSeek-R1-Zero": { - maxTokens: 32768, - contextWindow: 163840, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "DeepSeek R1 Zero model.", - }, - "deepseek-ai/DeepSeek-V3-0324": { - maxTokens: 32768, - contextWindow: 163840, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "DeepSeek V3 (0324) model.", - }, - "Qwen/Qwen3-235B-A22B-Instruct-2507": { - maxTokens: 32768, - contextWindow: 262144, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Qwen3 235B A22B Instruct 2507 model with 262K context window.", - }, - "Qwen/Qwen3-235B-A22B": { - maxTokens: 32768, - contextWindow: 40960, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Qwen3 235B A22B model.", - }, - "Qwen/Qwen3-32B": { - maxTokens: 32768, - contextWindow: 40960, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Qwen3 32B model.", - }, - "Qwen/Qwen3-30B-A3B": { - maxTokens: 32768, - contextWindow: 40960, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Qwen3 30B A3B model.", - }, - "Qwen/Qwen3-14B": { - maxTokens: 32768, - contextWindow: 40960, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Qwen3 14B model.", - }, - "Qwen/Qwen3-8B": { - maxTokens: 32768, - contextWindow: 40960, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Qwen3 8B model.", - }, - "microsoft/MAI-DS-R1-FP8": { - maxTokens: 32768, - contextWindow: 163840, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Microsoft MAI-DS-R1 FP8 model.", - }, - "tngtech/DeepSeek-R1T-Chimera": { - maxTokens: 32768, - contextWindow: 163840, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "TNGTech DeepSeek R1T Chimera model.", - }, - "zai-org/GLM-4.5-Air": { - maxTokens: 32768, - contextWindow: 151329, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: - "GLM-4.5-Air model with 151,329 token context window and 106B total parameters with 12B activated.", - }, - "zai-org/GLM-4.5-FP8": { - maxTokens: 32768, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: - "GLM-4.5-FP8 model with 128k token context window, optimized for agent-based applications with MoE architecture.", - }, - "zai-org/GLM-4.5-turbo": { - maxTokens: 32768, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 1, - outputPrice: 3, - description: "GLM-4.5-turbo model with 128K token context window, optimized for fast inference.", - }, - "zai-org/GLM-4.6-FP8": { - maxTokens: 32768, - contextWindow: 202752, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: - "GLM-4.6 introduces major upgrades over GLM-4.5, including a longer 200K-token context window for complex tasks, stronger coding performance in benchmarks and real-world tools (such as Claude Code, Cline, Roo Code, and Kilo Code), improved reasoning with tool use during inference, more capable and efficient agent integration, and refined writing that better matches human style, readability, and natural role-play scenarios.", - }, - "zai-org/GLM-4.6-turbo": { - maxTokens: 202752, // From Chutes /v1/models: max_output_length - contextWindow: 202752, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 1.15, - outputPrice: 3.25, - description: "GLM-4.6-turbo model with 200K-token context window, optimized for fast inference.", - }, - "meituan-longcat/LongCat-Flash-Thinking-FP8": { - maxTokens: 32768, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: - "LongCat Flash Thinking FP8 model with 128K context window, optimized for complex reasoning and coding tasks.", - }, - "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": { - maxTokens: 32768, - contextWindow: 262144, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Qwen3 Coder 480B A35B Instruct FP8 model, optimized for coding tasks.", - }, - "moonshotai/Kimi-K2-Instruct-75k": { - maxTokens: 32768, - contextWindow: 75000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.1481, - outputPrice: 0.5926, - description: "Moonshot AI Kimi K2 Instruct model with 75k context window.", - }, - "moonshotai/Kimi-K2-Instruct-0905": { - maxTokens: 32768, - contextWindow: 262144, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.1999, - outputPrice: 0.8001, - description: "Moonshot AI Kimi K2 Instruct 0905 model with 256k context window.", - }, - "Qwen/Qwen3-235B-A22B-Thinking-2507": { - maxTokens: 32768, - contextWindow: 262144, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.077968332, - outputPrice: 0.31202496, - description: "Qwen3 235B A22B Thinking 2507 model with 262K context window.", - }, - "Qwen/Qwen3-Next-80B-A3B-Instruct": { - maxTokens: 32768, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: - "Fast, stable instruction-tuned model optimized for complex tasks, RAG, and tool use without thinking traces.", - }, - "Qwen/Qwen3-Next-80B-A3B-Thinking": { - maxTokens: 32768, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: - "Reasoning-first model with structured thinking traces for multi-step problems, math proofs, and code synthesis.", - }, - "Qwen/Qwen3-VL-235B-A22B-Thinking": { - maxTokens: 262144, - contextWindow: 262144, - supportsImages: true, - supportsPromptCache: false, - inputPrice: 0.16, - outputPrice: 0.65, - description: - "Qwen3‑VL‑235B‑A22B‑Thinking is an open‑weight MoE vision‑language model (235B total, ~22B activated) optimized for deliberate multi‑step reasoning with strong text‑image‑video understanding and long‑context capabilities.", - }, -} as const satisfies Record - -export const chutesDefaultModelInfo: ModelInfo = chutesModels[chutesDefaultModelId] diff --git a/packages/types/src/providers/deepinfra.ts b/packages/types/src/providers/deepinfra.ts deleted file mode 100644 index 9a430b3789..0000000000 --- a/packages/types/src/providers/deepinfra.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ModelInfo } from "../model.js" - -// Default fallback values for DeepInfra when model metadata is not yet loaded. -export const deepInfraDefaultModelId = "Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo" - -export const deepInfraDefaultModelInfo: ModelInfo = { - maxTokens: 16384, - contextWindow: 262144, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.3, - outputPrice: 1.2, - description: "Qwen 3 Coder 480B A35B Instruct Turbo model, 256K context.", -} diff --git a/packages/types/src/providers/doubao.ts b/packages/types/src/providers/doubao.ts deleted file mode 100644 index f948450bc4..0000000000 --- a/packages/types/src/providers/doubao.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { ModelInfo } from "../model.js" - -export const doubaoDefaultModelId = "doubao-seed-1-6-250615" - -export const doubaoModels = { - "doubao-seed-1-6-250615": { - maxTokens: 32_768, - contextWindow: 128_000, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 0.0001, // $0.0001 per million tokens (cache miss) - outputPrice: 0.0004, // $0.0004 per million tokens - cacheWritesPrice: 0.0001, // $0.0001 per million tokens (cache miss) - cacheReadsPrice: 0.00002, // $0.00002 per million tokens (cache hit) - description: `Doubao Seed 1.6 is a powerful model designed for high-performance tasks with extensive context handling.`, - }, - "doubao-seed-1-6-thinking-250715": { - maxTokens: 32_768, - contextWindow: 128_000, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 0.0002, // $0.0002 per million tokens - outputPrice: 0.0008, // $0.0008 per million tokens - cacheWritesPrice: 0.0002, // $0.0002 per million - cacheReadsPrice: 0.00004, // $0.00004 per million tokens (cache hit) - description: `Doubao Seed 1.6 Thinking is optimized for reasoning tasks, providing enhanced performance in complex problem-solving scenarios.`, - }, - "doubao-seed-1-6-flash-250715": { - maxTokens: 32_768, - contextWindow: 128_000, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 0.00015, // $0.00015 per million tokens - outputPrice: 0.0006, // $0.0006 per million tokens - cacheWritesPrice: 0.00015, // $0.00015 per million - cacheReadsPrice: 0.00003, // $0.00003 per million tokens (cache hit) - description: `Doubao Seed 1.6 Flash is tailored for speed and efficiency, making it ideal for applications requiring rapid responses.`, - }, -} as const satisfies Record - -export const doubaoDefaultModelInfo: ModelInfo = doubaoModels[doubaoDefaultModelId] - -export const DOUBAO_API_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3" -export const DOUBAO_API_CHAT_PATH = "/chat/completions" diff --git a/packages/types/src/providers/featherless.ts b/packages/types/src/providers/featherless.ts deleted file mode 100644 index 20cfe96654..0000000000 --- a/packages/types/src/providers/featherless.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { ModelInfo } from "../model.js" - -export type FeatherlessModelId = - | "deepseek-ai/DeepSeek-V3-0324" - | "deepseek-ai/DeepSeek-R1-0528" - | "moonshotai/Kimi-K2-Instruct" - | "openai/gpt-oss-120b" - | "Qwen/Qwen3-Coder-480B-A35B-Instruct" - -export const featherlessModels = { - "deepseek-ai/DeepSeek-V3-0324": { - maxTokens: 4096, - contextWindow: 32678, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "DeepSeek V3 0324 model.", - }, - "deepseek-ai/DeepSeek-R1-0528": { - maxTokens: 4096, - contextWindow: 32678, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "DeepSeek R1 0528 model.", - }, - "moonshotai/Kimi-K2-Instruct": { - maxTokens: 4096, - contextWindow: 32678, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Kimi K2 Instruct model.", - }, - "openai/gpt-oss-120b": { - maxTokens: 4096, - contextWindow: 32678, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "GPT-OSS 120B model.", - }, - "Qwen/Qwen3-Coder-480B-A35B-Instruct": { - maxTokens: 4096, - contextWindow: 32678, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Qwen3 Coder 480B A35B Instruct model.", - }, -} as const satisfies Record - -export const featherlessDefaultModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct" diff --git a/packages/types/src/providers/fireworks.ts b/packages/types/src/providers/fireworks.ts index 1642424045..c9017c54cd 100644 --- a/packages/types/src/providers/fireworks.ts +++ b/packages/types/src/providers/fireworks.ts @@ -4,6 +4,7 @@ export type FireworksModelId = | "accounts/fireworks/models/kimi-k2-instruct" | "accounts/fireworks/models/kimi-k2-instruct-0905" | "accounts/fireworks/models/kimi-k2-thinking" + | "accounts/fireworks/models/kimi-k2p5" | "accounts/fireworks/models/minimax-m2" | "accounts/fireworks/models/minimax-m2p1" | "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507" @@ -60,6 +61,17 @@ export const fireworksModels = { description: "The kimi-k2-thinking model is a general-purpose agentic reasoning model developed by Moonshot AI. Thanks to its strength in deep reasoning and multi-turn tool use, it can solve even the hardest problems.", }, + "accounts/fireworks/models/kimi-k2p5": { + maxTokens: 16384, + contextWindow: 262144, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 0.6, + outputPrice: 3.0, + cacheReadsPrice: 0.1, + description: + "Kimi K2.5 is Moonshot AI's flagship agentic model and a new SOTA open model. It unifies vision and text, thinking and non-thinking modes, and single-agent and multi-agent execution into one model. Fireworks enables users to control the reasoning behavior and inspect its reasoning history for greater transparency.", + }, "accounts/fireworks/models/minimax-m2": { maxTokens: 4096, contextWindow: 204800, diff --git a/packages/types/src/providers/groq.ts b/packages/types/src/providers/groq.ts deleted file mode 100644 index 30e7c42ca1..0000000000 --- a/packages/types/src/providers/groq.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { ModelInfo } from "../model.js" - -// https://console.groq.com/docs/models -export type GroqModelId = - | "llama-3.1-8b-instant" - | "llama-3.3-70b-versatile" - | "meta-llama/llama-4-scout-17b-16e-instruct" - | "qwen/qwen3-32b" - | "moonshotai/kimi-k2-instruct-0905" - | "openai/gpt-oss-120b" - | "openai/gpt-oss-20b" - -export const groqDefaultModelId: GroqModelId = "moonshotai/kimi-k2-instruct-0905" - -export const groqModels = { - // Models based on API response: https://api.groq.com/openai/v1/models - "llama-3.1-8b-instant": { - maxTokens: 8192, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.05, - outputPrice: 0.08, - description: "Meta Llama 3.1 8B Instant model, 128K context.", - }, - "llama-3.3-70b-versatile": { - maxTokens: 8192, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.59, - outputPrice: 0.79, - description: "Meta Llama 3.3 70B Versatile model, 128K context.", - }, - "meta-llama/llama-4-scout-17b-16e-instruct": { - maxTokens: 8192, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.11, - outputPrice: 0.34, - description: "Meta Llama 4 Scout 17B Instruct model, 128K context.", - }, - "qwen/qwen3-32b": { - maxTokens: 8192, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.29, - outputPrice: 0.59, - description: "Alibaba Qwen 3 32B model, 128K context.", - }, - "moonshotai/kimi-k2-instruct-0905": { - maxTokens: 16384, - contextWindow: 262144, - supportsImages: false, - supportsPromptCache: true, - inputPrice: 0.6, - outputPrice: 2.5, - cacheReadsPrice: 0.15, - description: - "Kimi K2 model gets a new version update: Agentic coding: more accurate, better generalization across scaffolds. Frontend coding: improved aesthetics and functionalities on web, 3d, and other tasks. Context length: extended from 128k to 256k, providing better long-horizon support.", - }, - "openai/gpt-oss-120b": { - maxTokens: 32766, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.15, - outputPrice: 0.75, - description: - "GPT-OSS 120B is OpenAI's flagship open source model, built on a Mixture-of-Experts (MoE) architecture with 20 billion parameters and 128 experts.", - }, - "openai/gpt-oss-20b": { - maxTokens: 32768, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.1, - outputPrice: 0.5, - description: - "GPT-OSS 20B is OpenAI's flagship open source model, built on a Mixture-of-Experts (MoE) architecture with 20 billion parameters and 32 experts.", - }, -} as const satisfies Record diff --git a/packages/types/src/providers/huggingface.ts b/packages/types/src/providers/huggingface.ts deleted file mode 100644 index d2571a073e..0000000000 --- a/packages/types/src/providers/huggingface.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * HuggingFace provider constants - */ - -// Default values for HuggingFace models -export const HUGGINGFACE_DEFAULT_MAX_TOKENS = 2048 -export const HUGGINGFACE_MAX_TOKENS_FALLBACK = 8192 -export const HUGGINGFACE_DEFAULT_CONTEXT_WINDOW = 128_000 - -// UI constants -export const HUGGINGFACE_SLIDER_STEP = 256 -export const HUGGINGFACE_SLIDER_MIN = 1 -export const HUGGINGFACE_TEMPERATURE_MAX_VALUE = 2 - -// API constants -export const HUGGINGFACE_API_URL = "https://router.huggingface.co/v1/models?collection=roocode" -export const HUGGINGFACE_CACHE_DURATION = 1000 * 60 * 60 // 1 hour diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 2018954bbd..321e67c5f6 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -1,16 +1,10 @@ export * from "./anthropic.js" +export * from "./azure.js" export * from "./baseten.js" export * from "./bedrock.js" -export * from "./cerebras.js" -export * from "./chutes.js" export * from "./deepseek.js" -export * from "./doubao.js" -export * from "./featherless.js" export * from "./fireworks.js" export * from "./gemini.js" -export * from "./groq.js" -export * from "./huggingface.js" -export * from "./io-intelligence.js" export * from "./lite-llm.js" export * from "./lm-studio.js" export * from "./mistral.js" @@ -24,27 +18,20 @@ export * from "./qwen-code.js" export * from "./requesty.js" export * from "./roo.js" export * from "./sambanova.js" -export * from "./unbound.js" export * from "./vertex.js" export * from "./vscode-llm.js" export * from "./xai.js" export * from "./vercel-ai-gateway.js" export * from "./zai.js" -export * from "./deepinfra.js" export * from "./minimax.js" import { anthropicDefaultModelId } from "./anthropic.js" +import { azureDefaultModelId } from "./azure.js" import { basetenDefaultModelId } from "./baseten.js" import { bedrockDefaultModelId } from "./bedrock.js" -import { cerebrasDefaultModelId } from "./cerebras.js" -import { chutesDefaultModelId } from "./chutes.js" import { deepSeekDefaultModelId } from "./deepseek.js" -import { doubaoDefaultModelId } from "./doubao.js" -import { featherlessDefaultModelId } from "./featherless.js" import { fireworksDefaultModelId } from "./fireworks.js" import { geminiDefaultModelId } from "./gemini.js" -import { groqDefaultModelId } from "./groq.js" -import { ioIntelligenceDefaultModelId } from "./io-intelligence.js" import { litellmDefaultModelId } from "./lite-llm.js" import { mistralDefaultModelId } from "./mistral.js" import { moonshotDefaultModelId } from "./moonshot.js" @@ -54,13 +41,11 @@ import { qwenCodeDefaultModelId } from "./qwen-code.js" import { requestyDefaultModelId } from "./requesty.js" import { rooDefaultModelId } from "./roo.js" import { sambaNovaDefaultModelId } from "./sambanova.js" -import { unboundDefaultModelId } from "./unbound.js" import { vertexDefaultModelId } from "./vertex.js" import { vscodeLlmDefaultModelId } from "./vscode-llm.js" import { xaiDefaultModelId } from "./xai.js" import { vercelAiGatewayDefaultModelId } from "./vercel-ai-gateway.js" import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId } from "./zai.js" -import { deepInfraDefaultModelId } from "./deepinfra.js" import { minimaxDefaultModelId } from "./minimax.js" // Import the ProviderName type from provider-settings to avoid duplication @@ -80,18 +65,10 @@ export function getProviderDefaultModelId( return openRouterDefaultModelId case "requesty": return requestyDefaultModelId - case "unbound": - return unboundDefaultModelId case "litellm": return litellmDefaultModelId case "xai": return xaiDefaultModelId - case "groq": - return groqDefaultModelId - case "huggingface": - return "meta-llama/Llama-3.3-70B-Instruct" - case "chutes": - return chutesDefaultModelId case "baseten": return basetenDefaultModelId case "bedrock": @@ -102,8 +79,6 @@ export function getProviderDefaultModelId( return geminiDefaultModelId case "deepseek": return deepSeekDefaultModelId - case "doubao": - return doubaoDefaultModelId case "moonshot": return moonshotDefaultModelId case "minimax": @@ -122,26 +97,20 @@ export function getProviderDefaultModelId( return "" // Ollama uses dynamic model selection case "lmstudio": return "" // LMStudio uses dynamic model selection - case "deepinfra": - return deepInfraDefaultModelId case "vscode-lm": return vscodeLlmDefaultModelId - case "cerebras": - return cerebrasDefaultModelId case "sambanova": return sambaNovaDefaultModelId case "fireworks": return fireworksDefaultModelId - case "featherless": - return featherlessDefaultModelId - case "io-intelligence": - return ioIntelligenceDefaultModelId case "roo": return rooDefaultModelId case "qwen-code": return qwenCodeDefaultModelId case "vercel-ai-gateway": return vercelAiGatewayDefaultModelId + case "azure": + return azureDefaultModelId case "anthropic": case "gemini-cli": case "fake-ai": diff --git a/packages/types/src/providers/io-intelligence.ts b/packages/types/src/providers/io-intelligence.ts deleted file mode 100644 index a9b845393f..0000000000 --- a/packages/types/src/providers/io-intelligence.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { ModelInfo } from "../model.js" - -export type IOIntelligenceModelId = - | "deepseek-ai/DeepSeek-R1-0528" - | "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8" - | "Intel/Qwen3-Coder-480B-A35B-Instruct-int4-mixed-ar" - | "openai/gpt-oss-120b" - -export const ioIntelligenceDefaultModelId: IOIntelligenceModelId = "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8" - -export const ioIntelligenceDefaultBaseUrl = "https://api.intelligence.io.solutions/api/v1" - -export const IO_INTELLIGENCE_CACHE_DURATION = 1000 * 60 * 60 // 1 hour - -export const ioIntelligenceModels = { - "deepseek-ai/DeepSeek-R1-0528": { - maxTokens: 8192, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - description: "DeepSeek R1 reasoning model", - }, - "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": { - maxTokens: 8192, - contextWindow: 430000, - supportsImages: true, - supportsPromptCache: false, - description: "Llama 4 Maverick 17B model", - }, - "Intel/Qwen3-Coder-480B-A35B-Instruct-int4-mixed-ar": { - maxTokens: 8192, - contextWindow: 106000, - supportsImages: false, - supportsPromptCache: false, - description: "Qwen3 Coder 480B specialized for coding", - }, - "openai/gpt-oss-120b": { - maxTokens: 8192, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - description: "OpenAI GPT-OSS 120B model", - }, -} as const satisfies Record diff --git a/packages/types/src/providers/openai-codex.ts b/packages/types/src/providers/openai-codex.ts index 7722c84814..72b909591a 100644 --- a/packages/types/src/providers/openai-codex.ts +++ b/packages/types/src/providers/openai-codex.ts @@ -16,7 +16,7 @@ import type { ModelInfo } from "../model.js" export type OpenAiCodexModelId = keyof typeof openAiCodexModels -export const openAiCodexDefaultModelId: OpenAiCodexModelId = "gpt-5.2-codex" +export const openAiCodexDefaultModelId: OpenAiCodexModelId = "gpt-5.3-codex" /** * Models available through the Codex OAuth flow. @@ -54,6 +54,20 @@ export const openAiCodexModels = { supportsTemperature: false, description: "GPT-5.1 Codex: GPT-5.1 optimized for agentic coding via ChatGPT subscription", }, + "gpt-5.3-codex": { + maxTokens: 128000, + contextWindow: 400000, + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high", "xhigh"], + reasoningEffort: "medium", + inputPrice: 0, + outputPrice: 0, + supportsTemperature: false, + description: "GPT-5.3 Codex: OpenAI's flagship coding model via ChatGPT subscription", + }, "gpt-5.2-codex": { maxTokens: 128000, contextWindow: 400000, diff --git a/packages/types/src/providers/openai.ts b/packages/types/src/providers/openai.ts index af9a1ff759..b7532f9c95 100644 --- a/packages/types/src/providers/openai.ts +++ b/packages/types/src/providers/openai.ts @@ -506,9 +506,8 @@ export const openAiModelInfoSaneDefaults: ModelInfo = { outputPrice: 0, } -// https://learn.microsoft.com/en-us/azure/ai-services/openai/api-version-deprecation -// https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#api-specs -export const azureOpenAiDefaultApiVersion = "2024-08-01-preview" +// https://learn.microsoft.com/en-us/azure/ai-foundry/openai/api-version-lifecycle +export const azureOpenAiDefaultApiVersion = "2025-04-01-preview" export const OPENAI_NATIVE_DEFAULT_TEMPERATURE = 0 diff --git a/packages/types/src/providers/openrouter.ts b/packages/types/src/providers/openrouter.ts index f3fb13baa9..c8168e6024 100644 --- a/packages/types/src/providers/openrouter.ts +++ b/packages/types/src/providers/openrouter.ts @@ -40,8 +40,9 @@ export const OPEN_ROUTER_PROMPT_CACHING_MODELS = new Set([ "anthropic/claude-sonnet-4.5", "anthropic/claude-opus-4", "anthropic/claude-opus-4.1", - "anthropic/claude-haiku-4.5", "anthropic/claude-opus-4.5", + "anthropic/claude-opus-4.6", + "anthropic/claude-haiku-4.5", "google/gemini-2.5-flash-preview", "google/gemini-2.5-flash-preview:thinking", "google/gemini-2.5-flash-preview-05-20", @@ -70,9 +71,10 @@ export const OPEN_ROUTER_REASONING_BUDGET_MODELS = new Set([ "anthropic/claude-3.7-sonnet:beta", "anthropic/claude-opus-4", "anthropic/claude-opus-4.1", + "anthropic/claude-opus-4.5", + "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4", "anthropic/claude-sonnet-4.5", - "anthropic/claude-opus-4.5", "anthropic/claude-haiku-4.5", "google/gemini-2.5-pro-preview", "google/gemini-2.5-pro", diff --git a/packages/types/src/providers/unbound.ts b/packages/types/src/providers/unbound.ts deleted file mode 100644 index 9715b835c9..0000000000 --- a/packages/types/src/providers/unbound.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ModelInfo } from "../model.js" - -export const unboundDefaultModelId = "anthropic/claude-sonnet-4-5" - -export const unboundDefaultModelInfo: ModelInfo = { - maxTokens: 8192, - contextWindow: 200_000, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 3.0, - outputPrice: 15.0, - cacheWritesPrice: 3.75, - cacheReadsPrice: 0.3, -} diff --git a/packages/types/src/providers/vercel-ai-gateway.ts b/packages/types/src/providers/vercel-ai-gateway.ts index 875b87bf8b..43a94a0697 100644 --- a/packages/types/src/providers/vercel-ai-gateway.ts +++ b/packages/types/src/providers/vercel-ai-gateway.ts @@ -11,6 +11,8 @@ export const VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS = new Set([ "anthropic/claude-3.7-sonnet", "anthropic/claude-opus-4", "anthropic/claude-opus-4.1", + "anthropic/claude-opus-4.5", + "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4", "openai/gpt-4.1", "openai/gpt-4.1-mini", @@ -50,6 +52,8 @@ export const VERCEL_AI_GATEWAY_VISION_AND_TOOLS_MODELS = new Set([ "anthropic/claude-3.7-sonnet", "anthropic/claude-opus-4", "anthropic/claude-opus-4.1", + "anthropic/claude-opus-4.5", + "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4", "google/gemini-1.5-flash", "google/gemini-1.5-pro", diff --git a/packages/types/src/providers/vertex.ts b/packages/types/src/providers/vertex.ts index b81f985d3b..55e5648011 100644 --- a/packages/types/src/providers/vertex.ts +++ b/packages/types/src/providers/vertex.ts @@ -274,6 +274,27 @@ export const vertexModels = { cacheReadsPrice: 0.1, supportsReasoningBudget: true, }, + "claude-opus-4-6": { + maxTokens: 8192, + contextWindow: 200_000, // Default 200K, extendable to 1M with beta flag 'context-1m-2025-08-07' + supportsImages: true, + supportsPromptCache: true, + inputPrice: 5.0, // $5 per million input tokens (≤200K context) + outputPrice: 25.0, // $25 per million output tokens (≤200K context) + cacheWritesPrice: 6.25, // $6.25 per million tokens + cacheReadsPrice: 0.5, // $0.50 per million tokens + supportsReasoningBudget: true, + // Tiered pricing for extended context (requires beta flag 'context-1m-2025-08-07') + tiers: [ + { + contextWindow: 1_000_000, // 1M tokens with beta flag + inputPrice: 10.0, // $10 per million input tokens (>200K context) + outputPrice: 37.5, // $37.50 per million output tokens (>200K context) + cacheWritesPrice: 12.5, // $12.50 per million tokens (>200K context) + cacheReadsPrice: 1.0, // $1.00 per million tokens (>200K context) + }, + ], + }, "claude-opus-4-5@20251101": { maxTokens: 8192, contextWindow: 200_000, @@ -467,7 +488,11 @@ export const vertexModels = { // Vertex AI models that support 1M context window beta // Uses the same beta header 'context-1m-2025-08-07' as Anthropic and Bedrock -export const VERTEX_1M_CONTEXT_MODEL_IDS = ["claude-sonnet-4@20250514", "claude-sonnet-4-5@20250929"] as const +export const VERTEX_1M_CONTEXT_MODEL_IDS = [ + "claude-sonnet-4@20250514", + "claude-sonnet-4-5@20250929", + "claude-opus-4-6", +] as const export const VERTEX_REGIONS = [ { value: "global", label: "global" }, diff --git a/packages/types/src/providers/xai.ts b/packages/types/src/providers/xai.ts index 37e0f2d12e..2954888d73 100644 --- a/packages/types/src/providers/xai.ts +++ b/packages/types/src/providers/xai.ts @@ -30,6 +30,8 @@ export const xaiModels = { cacheReadsPrice: 0.05, description: "xAI's Grok 4.1 Fast model with 2M context window, optimized for high-performance agentic tool calling with reasoning", + supportsReasoningEffort: ["low", "high"], + reasoningEffort: "low", includedTools: ["search_replace"], excludedTools: ["apply_diff"], }, @@ -58,6 +60,8 @@ export const xaiModels = { cacheReadsPrice: 0.05, description: "xAI's Grok 4 Fast model with 2M context window, optimized for high-performance agentic tool calling with reasoning", + supportsReasoningEffort: ["low", "high"], + reasoningEffort: "low", includedTools: ["search_replace"], excludedTools: ["apply_diff"], }, diff --git a/packages/types/src/skills.ts b/packages/types/src/skills.ts index b50b4e6d47..3e856612bc 100644 --- a/packages/types/src/skills.ts +++ b/packages/types/src/skills.ts @@ -7,7 +7,17 @@ export interface SkillMetadata { description: string // Required: when to use this skill path: string // Absolute path to SKILL.md (or "" for built-in skills) source: "global" | "project" | "built-in" // Where the skill was discovered - mode?: string // If set, skill is only available in this mode + /** + * @deprecated Use modeSlugs instead. Kept for backward compatibility. + * If set, skill is only available in this mode. + */ + mode?: string + /** + * Mode slugs where this skill is available. + * - undefined or empty array means the skill is available in all modes ("Any mode"). + * - An array with one or more mode slugs restricts the skill to those modes. + */ + modeSlugs?: string[] } /** diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index 00751837c2..55b442cca0 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -95,6 +95,9 @@ export interface CreateTaskOptions { initialTodos?: TodoItem[] /** Initial status for the task's history item (e.g., "active" for child tasks) */ initialStatus?: "active" | "delegated" | "completed" + /** Whether to start the task loop immediately (default: true). + * When false, the caller must invoke `task.start()` manually. */ + startTask?: boolean } export enum TaskStatus { @@ -154,6 +157,7 @@ export type TaskEvents = { [RooCodeEventName.TaskModeSwitched]: [taskId: string, mode: string] [RooCodeEventName.TaskAskResponded]: [] [RooCodeEventName.TaskUserMessage]: [taskId: string] + [RooCodeEventName.QueuedMessagesUpdated]: [taskId: string, messages: QueuedMessage[]] // Task Analytics [RooCodeEventName.TaskToolFailed]: [taskId: string, tool: ToolName, error: string] diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index f34102e22c..f2cddee819 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -47,7 +47,6 @@ export interface ExtensionMessage { | "ollamaModels" | "lmStudioModels" | "vsCodeLmModels" - | "huggingFaceModels" | "vsCodeLmApiAvailable" | "updatePrompt" | "systemPrompt" @@ -144,23 +143,6 @@ export interface ExtensionMessage { ollamaModels?: ModelRecord lmStudioModels?: ModelRecord vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[] - huggingFaceModels?: Array<{ - id: string - object: string - created: number - owned_by: string - providers: Array<{ - provider: string - status: "live" | "staging" | "error" - supports_tools?: boolean - supports_structured_output?: boolean - context_length?: number - pricing?: { - input: number - output: number - } - }> - }> mcpServers?: McpServer[] commits?: GitCommit[] listApiConfig?: ProviderSettingsEntry[] @@ -303,6 +285,7 @@ export type ExtensionState = Pick< | "ttsSpeed" | "soundEnabled" | "soundVolume" + | "taskHeaderHighlightEnabled" | "terminalOutputPreviewSize" | "terminalShellIntegrationTimeout" | "terminalShellIntegrationDisabled" @@ -335,7 +318,9 @@ export type ExtensionState = Pick< | "maxGitStatusFiles" | "requestDelaySeconds" | "showWorktreesInHomeScreen" + | "disabledTools" > & { + lockApiConfigAcrossModes?: boolean version: string clineMessages: ClineMessage[] currentTaskItem?: HistoryItem @@ -467,7 +452,6 @@ export interface WebviewMessage { | "requestRooModels" | "requestRooCreditBalance" | "requestVsCodeLmModels" - | "requestHuggingFaceModels" | "openImage" | "saveImage" | "openFile" @@ -524,6 +508,7 @@ export interface WebviewMessage { | "searchFiles" | "toggleApiConfigPin" | "hasOpenedModeSelector" + | "lockApiConfigAcrossModes" | "clearCloudAuthSkipModel" | "cloudButtonClicked" | "rooCloudSignIn" @@ -606,6 +591,7 @@ export interface WebviewMessage { | "createSkill" | "deleteSkill" | "moveSkill" + | "updateSkillModes" | "openSkillFile" text?: string editedMessageContent?: string @@ -642,9 +628,15 @@ export interface WebviewMessage { payload?: WebViewMessagePayload source?: "global" | "project" | "built-in" skillName?: string // For skill operations (createSkill, deleteSkill, moveSkill, openSkillFile) + /** @deprecated Use skillModeSlugs instead */ skillMode?: string // For skill operations (current mode restriction) + /** @deprecated Use newSkillModeSlugs instead */ newSkillMode?: string // For moveSkill (target mode) skillDescription?: string // For createSkill (skill description) + /** Mode slugs for skill operations. undefined/empty = any mode */ + skillModeSlugs?: string[] // For skill operations (mode restrictions) + /** Target mode slugs for updateSkillModes */ + newSkillModeSlugs?: string[] // For updateSkillModes (new mode restrictions) requestId?: string ids?: string[] hasSystemPromptOverride?: boolean @@ -839,6 +831,12 @@ export interface ClineSayTool { startLine?: number }> }> + batchDirs?: Array<{ + path: string + recursive: boolean + isOutsideWorkspace?: boolean + key: string + }> question?: string imageData?: string // Base64 encoded image data for generated images // Properties for runSlashCommand tool diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01f1fb5f41..304c654ef7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -244,8 +244,8 @@ importers: specifier: workspace:^ version: link:../../packages/evals '@roo-code/types': - specifier: ^1.108.0 - version: 1.108.0 + specifier: workspace:^ + version: link:../../packages/types '@tanstack/react-query': specifier: ^5.69.0 version: 5.76.1(react@18.3.1) @@ -746,27 +746,42 @@ importers: src: dependencies: - '@ai-sdk/cerebras': - specifier: ^1.0.0 - version: 1.0.35(zod@3.25.76) + '@ai-sdk/amazon-bedrock': + specifier: ^4.0.51 + version: 4.0.51(zod@3.25.76) + '@ai-sdk/anthropic': + specifier: ^3.0.38 + version: 3.0.38(zod@3.25.76) + '@ai-sdk/azure': + specifier: ^2.0.6 + version: 2.0.91(zod@3.25.76) + '@ai-sdk/baseten': + specifier: ^1.0.31 + version: 1.0.31(zod@3.25.76) '@ai-sdk/deepseek': - specifier: ^2.0.14 - version: 2.0.14(zod@3.25.76) + specifier: ^2.0.18 + version: 2.0.18(zod@3.25.76) '@ai-sdk/fireworks': - specifier: ^2.0.26 - version: 2.0.26(zod@3.25.76) - '@ai-sdk/groq': + specifier: ^2.0.32 + version: 2.0.32(zod@3.25.76) + '@ai-sdk/google': + specifier: ^3.0.22 + version: 3.0.22(zod@3.25.76) + '@ai-sdk/google-vertex': + specifier: ^4.0.45 + version: 4.0.45(zod@3.25.76) + '@ai-sdk/mistral': specifier: ^3.0.19 version: 3.0.19(zod@3.25.76) - '@anthropic-ai/bedrock-sdk': - specifier: ^0.10.2 - version: 0.10.4 + '@ai-sdk/openai': + specifier: ^3.0.26 + version: 3.0.26(zod@3.25.76) + '@ai-sdk/xai': + specifier: ^3.0.48 + version: 3.0.48(zod@3.25.76) '@anthropic-ai/sdk': specifier: ^0.37.0 version: 0.37.0 - '@anthropic-ai/vertex-sdk': - specifier: ^0.7.0 - version: 0.7.0 '@aws-sdk/client-bedrock-runtime': specifier: ^3.922.0 version: 3.922.0 @@ -854,9 +869,6 @@ importers: global-agent: specifier: ^3.0.0 version: 3.0.0 - google-auth-library: - specifier: ^9.15.1 - version: 9.15.1 gray-matter: specifier: ^4.0.3 version: 4.0.3 @@ -935,6 +947,9 @@ importers: safe-stable-stringify: specifier: ^2.5.0 version: 2.5.0 + sambanova-ai-provider: + specifier: ^1.2.2 + version: 1.2.2(zod@3.25.76) sanitize-filename: specifier: ^1.6.3 version: 1.6.3 @@ -1001,16 +1016,19 @@ importers: yaml: specifier: ^2.8.0 version: 2.8.0 + zhipu-ai-provider: + specifier: ^0.2.2 + version: 0.2.2(zod@3.25.76) zod: specifier: 3.25.76 version: 3.25.76 devDependencies: '@ai-sdk/openai-compatible': - specifier: ^1.0.0 - version: 1.0.31(zod@3.25.76) + specifier: ^2.0.28 + version: 2.0.28(zod@3.25.76) '@openrouter/ai-sdk-provider': - specifier: ^2.0.4 - version: 2.1.1(ai@6.0.57(zod@3.25.76))(zod@3.25.76) + specifier: ^2.1.1 + version: 2.1.1(ai@6.0.77(zod@3.25.76))(zod@3.25.76) '@roo-code/build': specifier: workspace:^ version: link:../packages/build @@ -1084,8 +1102,8 @@ importers: specifier: 3.3.2 version: 3.3.2 ai: - specifier: ^6.0.0 - version: 6.0.57(zod@3.25.76) + specifier: ^6.0.75 + version: 6.0.77(zod@3.25.76) esbuild-wasm: specifier: ^0.25.0 version: 0.25.12 @@ -1402,44 +1420,86 @@ packages: '@adobe/css-tools@4.4.2': resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==} - '@ai-sdk/cerebras@1.0.35': - resolution: {integrity: sha512-JrNdMYptrOUjNthibgBeAcBjZ/H+fXb49sSrWhOx5Aq8eUcrYvwQ2DtSAi8VraHssZu78NAnBMrgFWSUOTXFxw==} + '@ai-sdk/amazon-bedrock@4.0.51': + resolution: {integrity: sha512-r2vDm4XiGUoxWiLQzhbfqYtVUdPvaBIJFKaeYXpIr+kfFIHD+ksMHMZJb687epcJ+bCQ1TpQxFbMkfP3YZUvDg==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/deepseek@2.0.14': - resolution: {integrity: sha512-1vXh8sVwRJYd1JO57qdy1rACucaNLDoBRCwOER3EbPgSF2vNVPcdJywGutA01Bhn7Cta+UJQ+k5y/yzMAIpP2w==} + '@ai-sdk/anthropic@3.0.38': + resolution: {integrity: sha512-9MchyPRPni0WzrFeIGNevZpQVfWxaS+MQFupIXYQo9VgHnuO1Vyrp9SBmjkkuoAdBs7GomsWqLZCcNMJAVbdFA==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/fireworks@2.0.26': - resolution: {integrity: sha512-vBqSSksHhDGrSNYnmEmVGvLicHFjL4yAxFZfCb6ydrg+qgnlW2bdyTQDMI69BKG4spNZ1/iHMxRNIQpx19Yf6w==} + '@ai-sdk/azure@2.0.91': + resolution: {integrity: sha512-9tznVSs6LGQNKKxb8pKd7CkBV9yk+a/ENpFicHCj2CmBUKefxzwJ9JbUqrlK3VF6dGZw3LXq0dWxt7/Yekaj1w==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/gateway@3.0.25': - resolution: {integrity: sha512-j0AQeA7hOVqwImykQlganf/Euj3uEXf0h3G0O4qKTDpEwE+EZGIPnVimCWht5W91lAetPZSfavDyvfpuPDd2PQ==} + '@ai-sdk/baseten@1.0.31': + resolution: {integrity: sha512-tGbV96WBb5nnfyUYFrPyBxrhw53YlKSJbMC+rH3HhQlUaIs8+m/Bm4M0isrek9owIIf4MmmSDZ5VZL08zz7eFQ==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/groq@3.0.19': - resolution: {integrity: sha512-WAeGVnp9rvU3RUvu6S1HiD8hAjKgNlhq+z3m4j5Z1fIKRXqcKjOscVZGwL36If8qxsqXNVCtG3ltXawM5UAa8w==} + '@ai-sdk/deepseek@2.0.18': + resolution: {integrity: sha512-AwtmFm7acnCsz3z82Yu5QKklSZz+cBwtxrc2hbw47tPF/38xr1zX3Vf/pP627EHwWkLV18UWivIxg0SHPP2w3A==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/openai-compatible@1.0.31': - resolution: {integrity: sha512-znBvaVHM0M6yWNerIEy3hR+O8ZK2sPcE7e2cxfb6kYLEX3k//JH5VDnRnajseVofg7LXtTCFFdjsB7WLf1BdeQ==} + '@ai-sdk/fireworks@2.0.32': + resolution: {integrity: sha512-2qOEvocoRxUND086pjgliSBFKTyy6LUKbHZvXr++zlHm8ZbMT4dES78f5MHbOP9UVvRCPfTKmlPsUFUP/EVhJQ==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/openai-compatible@2.0.24': - resolution: {integrity: sha512-3QrCKpQCn3g6sIMoFGuEroaqk7Xg+qfsohRp4dKszjto5stjBg4SdtOKqHg+CpE3X4woj2O62w2qr5dSekMZeQ==} + '@ai-sdk/gateway@3.0.39': + resolution: {integrity: sha512-SeCZBAdDNbWpVUXiYgOAqis22p5MEYfrjRw0hiBa5hM+7sDGYQpMinUjkM8kbPXMkY+AhKLrHleBl+SuqpzlgA==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + + '@ai-sdk/google-vertex@4.0.45': + resolution: {integrity: sha512-KkOsYd9DiyNatqxr/dSKzC6qrxwxOXZ63vu6Yfz2A7bPCsrwKzcN9SQRuhbVkBa1j0C78YiSDKuQvclfOk/0Kw==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + + '@ai-sdk/google@3.0.22': + resolution: {integrity: sha512-g1N5P/jfTiH4qwdv4WT3hkKzzAbITFz457NomtBfjP8Q3SCzdbU9oPK5ACBMG8RN5mc2QPL6DLtM3Hf5T8KPmw==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + + '@ai-sdk/mistral@3.0.19': + resolution: {integrity: sha512-yd0OJ3fm2YKdwxh1pd9m720sENVVcylAD+Bki8C80QqVpUxGNL1/C4N4JJGb56eCCWr6VU/3gHFe9PKui9n/Hg==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + + '@ai-sdk/openai-compatible@1.0.11': + resolution: {integrity: sha512-eRD6dZviy31KYz4YvxAR/c6UEYx3p4pCiWZeDdYdAHj0rn8xZlGVxtQRs1qynhz6IYGOo4aLBf9zVW5w0tI/Uw==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + + '@ai-sdk/openai-compatible@2.0.28': + resolution: {integrity: sha512-WzDnU0B13FMSSupDtm2lksFZvWGXnOfhG5S0HoPI0pkX5uVkr6N1UTATMyVaxLCG0MRkMhXCjkg4NXgEbb330Q==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + + '@ai-sdk/openai@2.0.89': + resolution: {integrity: sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + + '@ai-sdk/openai@3.0.26': + resolution: {integrity: sha512-W/hiwxIfG29IO0Fob1HwWpFssMsNrxWoX8A7DwNGOtKArDBmJNuGzQeU/k0Fnh8WyvZEnfxkjO4oXkSXfVBayg==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 @@ -1450,29 +1510,35 @@ packages: peerDependencies: zod: 3.25.76 - '@ai-sdk/provider-utils@4.0.10': - resolution: {integrity: sha512-VeDAiCH+ZK8Xs4hb9Cw7pHlujWNL52RKe8TExOkrw6Ir1AmfajBZTb9XUdKOZO08RwQElIKA8+Ltm+Gqfo8djQ==} + '@ai-sdk/provider-utils@3.0.5': + resolution: {integrity: sha512-HliwB/yzufw3iwczbFVE2Fiwf1XqROB/I6ng8EKUsPM5+2wnIa8f4VbljZcDx+grhFrPV+PnRZH7zBqi8WZM7Q==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/provider-utils@4.0.11': - resolution: {integrity: sha512-y/WOPpcZaBjvNaogy83mBsCRPvbtaK0y1sY9ckRrrbTGMvG2HC/9Y/huqNXKnLAxUIME2PGa2uvF2CDwIsxoXQ==} + '@ai-sdk/provider-utils@4.0.14': + resolution: {integrity: sha512-7bzKd9lgiDeXM7O4U4nQ8iTxguAOkg8LZGD9AfDVZYjO5cKYRwBPwVjboFcVrxncRHu0tYxZtXZtiLKpG4pEng==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 + '@ai-sdk/provider@2.0.0': + resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==} + engines: {node: '>=18'} + '@ai-sdk/provider@2.0.1': resolution: {integrity: sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==} engines: {node: '>=18'} - '@ai-sdk/provider@3.0.5': - resolution: {integrity: sha512-2Xmoq6DBJqmSl80U6V9z5jJSJP7ehaJJQMy2iFUqTay06wdCqTnPVBBQbtEL8RCChenL+q5DC5H5WzU3vV3v8w==} + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} engines: {node: '>=18'} - '@ai-sdk/provider@3.0.6': - resolution: {integrity: sha512-hSfoJtLtpMd7YxKM+iTqlJ0ZB+kJ83WESMiWuWrNVey3X8gg97x0OdAAaeAeclZByCX3UdPOTqhvJdK8qYA3ww==} + '@ai-sdk/xai@3.0.48': + resolution: {integrity: sha512-fUefjg7TwngHUtv0s+8j+GSPBiQRSETOPpICpaubz0CDNj0inBw/bZ6DKskQol7O20BIcoz0eKweedtC+F5iyQ==} engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 '@alcalzone/ansi-tokenize@0.2.3': resolution: {integrity: sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==} @@ -1492,21 +1558,12 @@ packages: '@antfu/utils@8.1.1': resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==} - '@anthropic-ai/bedrock-sdk@0.10.4': - resolution: {integrity: sha512-szduEHbMli6XL934xrraYg5cFuKL/1oMyj/iZuEVjtddQ7eD5cXObzWobsv5mTLWijQmSzMfFD+JAUHDPHlQ/Q==} - '@anthropic-ai/sdk@0.37.0': resolution: {integrity: sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==} - '@anthropic-ai/vertex-sdk@0.7.0': - resolution: {integrity: sha512-zNm3hUXgYmYDTyveIxOyxbcnh5VXFkrLo4bSnG6LAfGzW7k3k2iCNDSVKtR9qZrK2BCid7JtVu7jsEKaZ/9dSw==} - '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} - '@aws-crypto/crc32@3.0.0': - resolution: {integrity: sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA==} - '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -1514,9 +1571,6 @@ packages: '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} - '@aws-crypto/sha256-js@4.0.0': - resolution: {integrity: sha512-MHGJyjE7TX9aaqXj7zk2ppnFUOhaDs5sP+HtNS0evOxn72c+5njUmyJmpGd7TfyoDznZlHMmdo/xGUdu2NIjNQ==} - '@aws-crypto/sha256-js@5.2.0': resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} engines: {node: '>=16.0.0'} @@ -1524,12 +1578,6 @@ packages: '@aws-crypto/supports-web-crypto@5.2.0': resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} - '@aws-crypto/util@3.0.0': - resolution: {integrity: sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==} - - '@aws-crypto/util@4.0.0': - resolution: {integrity: sha512-2EnmPy2gsFZ6m8bwUQN4jq+IyXV3quHAcwPOS6ZA3k+geujiqI8aRokO2kFJe+idJ/P3v4qWI186rVMo0+zLDQ==} - '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} @@ -1625,14 +1673,6 @@ packages: resolution: {integrity: sha512-/inmPnjZE0ZBE16zaCowAvouSx05FJ7p6BQYuzlJ8vxEU0sS0Hf8fvhuiRnN9V9eDUPIBY+/5EjbMWygXL4wlQ==} engines: {node: '>=18.0.0'} - '@aws-sdk/types@3.804.0': - resolution: {integrity: sha512-A9qnsy9zQ8G89vrPPlNG9d1d8QcKRGqJKqwyGgS0dclJpwy6d1EWgQLIolKPl6vcFpLoe6avLOLxr+h8ur5wpg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/types@3.840.0': - resolution: {integrity: sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA==} - engines: {node: '>=18.0.0'} - '@aws-sdk/types@3.922.0': resolution: {integrity: sha512-eLA6XjVobAUAMivvM7DBL79mnHyrm+32TkXNWZua5mnxF+6kQCfblKKJvxMZLGosO53/Ex46ogim8IY5Nbqv2w==} engines: {node: '>=18.0.0'} @@ -1661,9 +1701,6 @@ packages: aws-crt: optional: true - '@aws-sdk/util-utf8-browser@3.259.0': - resolution: {integrity: sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==} - '@aws-sdk/xml-builder@3.921.0': resolution: {integrity: sha512-LVHg0jgjyicKKvpNIEMXIMr1EBViESxcPkqfOlT+X1FkmUMTNZEEVF18tOJg4m4hV5vxtkWcqtr4IEeWa1C41Q==} engines: {node: '>=18.0.0'} @@ -1811,6 +1848,93 @@ packages: resolution: {integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==} engines: {node: '>=6.9.0'} + '@basetenlabs/performance-client-android-arm-eabi@0.0.10': + resolution: {integrity: sha512-gwDZ6GDJA0AAmQAHxt2vaCz0tYTaLjxJKZnoYt+0Eji4gy231JZZFAwvbAqNdQCrGEQ9lXnk7SNM1Apet4NlYg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@basetenlabs/performance-client-android-arm64@0.0.10': + resolution: {integrity: sha512-oGRB/6hH89majhsmoVmj1IAZv4C7F2aLeTSebevBelmdYO4CFkn5qewxLzU1pDkkmxVVk2k+TRpYa1Dt4B96qQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@basetenlabs/performance-client-darwin-arm64@0.0.10': + resolution: {integrity: sha512-QpBOUjeO05tWgFWkDw2RUQZa3BMplX5jNiBBTi5mH1lIL/m1sm2vkxoc0iorEESp1mMPstYFS/fr4ssBuO7wyA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@basetenlabs/performance-client-darwin-universal@0.0.10': + resolution: {integrity: sha512-CBM38GAhekjylrlf7jW/0WNyFAGnAMBCNHZxaPnAjjhDNzJh1tcrwhvtOs66XbAqCOjO/tkt5Pdu6mg2Ui2Pjw==} + engines: {node: '>= 10'} + os: [darwin] + + '@basetenlabs/performance-client-darwin-x64@0.0.10': + resolution: {integrity: sha512-R+NsA72Axclh1CUpmaWOCLTWCqXn5/tFMj2z9BnHVSRTelx/pYFlx6ZngVTB1HYp1n21m3upPXGo8CHF8R7Itw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@basetenlabs/performance-client-linux-arm-gnueabihf@0.0.10': + resolution: {integrity: sha512-96kEo0Eas4GVQdFkxIB1aAv6dy5Ga57j+RIg5l0Yiawv+AYIEmgk9BsGkqcwayp8Iiu6LN22Z+AUsGY2gstNrg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@basetenlabs/performance-client-linux-arm-musleabihf@0.0.10': + resolution: {integrity: sha512-lzEHeu+/BWDl2q+QZcqCkg1rDGF4MeyM3HgYwX+07t+vGZoqtM2we9vEV68wXMpl6ToEHQr7ML2KHA1Gb6ogxg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@basetenlabs/performance-client-linux-arm64-gnu@0.0.10': + resolution: {integrity: sha512-MnY2cIRY/cQOYERWIHhh5CoaS2wgmmXtGDVGSLYyZvjwizrXZvjkEz7Whv2jaQ21T5S56VER67RABjz2TItrHQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@basetenlabs/performance-client-linux-riscv64-gnu@0.0.10': + resolution: {integrity: sha512-2KUvdK4wuoZdIqNnJhx7cu6ybXCwtiwGAtlrEvhai3FOkUQ3wE2Xa+TQ33mNGSyFbw6wAvLawYtKVFmmw27gJw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@basetenlabs/performance-client-linux-x64-gnu@0.0.10': + resolution: {integrity: sha512-9jjQPjHLiVOGwUPlmhnBl7OmmO7hQ8WMt+v3mJuxkS5JTNDmVOngfmgGlbN9NjBhQMENjdcMUVOquVo7HeybGQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@basetenlabs/performance-client-linux-x64-musl@0.0.10': + resolution: {integrity: sha512-bjYB8FKcPvEa251Ep2Gm3tvywADL9eavVjZsikdf0AvJ1K5pT+vLLvJBU9ihBsTPWnbF4pJgxVjwS6UjVObsQA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@basetenlabs/performance-client-win32-arm64-msvc@0.0.10': + resolution: {integrity: sha512-Vxq5UXEmfh3C3hpwXdp3Daaf0dnLR9zFH2x8MJ1Hf/TcilmOP1clneewNpIv0e7MrnT56Z4pM6P3d8VFMZqBKg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@basetenlabs/performance-client-win32-ia32-msvc@0.0.10': + resolution: {integrity: sha512-KJrm7CgZdP/UDC5+tHtqE6w9XMfY5YUfMOxJfBZGSsLMqS2OGsakQsaF0a55k+58l29X5w/nAkjHrI1BcQO03w==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@basetenlabs/performance-client-win32-x64-msvc@0.0.10': + resolution: {integrity: sha512-M/mhvfTItUcUX+aeXRb5g5MbRlndfg6yelV7tSYfLU4YixMIe5yoGaAP3iDilpFJjcC99f+EU4l4+yLbPtpXig==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@basetenlabs/performance-client@0.0.10': + resolution: {integrity: sha512-H6bpd1JcDbuJsOS2dNft+CCGLzBqHJO/ST/4mMKhLAW641J6PpVJUw1szYsk/dTetdedbWxHpMkvFObOKeP8nw==} + engines: {node: '>= 10'} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -3796,10 +3920,6 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@smithy/abort-controller@2.2.0': - resolution: {integrity: sha512-wRlta7GuLWpTqtFfGo+nZyOO1vEvewdNR1R4rTxpC8XU6vG/NDyrFBhwLZsqg1NUoR1noVaXJPC/7ZK47QCySw==} - engines: {node: '>=14.0.0'} - '@smithy/abort-controller@4.2.4': resolution: {integrity: sha512-Z4DUr/AkgyFf1bOThW2HwzREagee0sB5ycl+hDiSZOfRLW8ZgrOjDi6g8mHH19yyU5E2A/64W3z6SMIf5XiUSQ==} engines: {node: '>=18.0.0'} @@ -3816,9 +3936,6 @@ packages: resolution: {integrity: sha512-YVNMjhdz2pVto5bRdux7GMs0x1m0Afz3OcQy/4Yf9DH4fWOtroGH7uLvs7ZmDyoBJzLdegtIPpXrpJOZWvUXdw==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-codec@2.2.0': - resolution: {integrity: sha512-8janZoJw85nJmQZc4L8TuePp2pk1nxLgkxIR0TUjKJ5Dkj5oelB9WtiSSGXCQvNsJl0VSTvK/2ueMXxvpa9GVw==} - '@smithy/eventstream-codec@4.2.4': resolution: {integrity: sha512-aV8blR9RBDKrOlZVgjOdmOibTC2sBXNiT7WA558b4MPdsLTV6sbyc1WIE9QiIuYMJjYtnPLciefoqSW8Gi+MZQ==} engines: {node: '>=18.0.0'} @@ -3831,25 +3948,14 @@ packages: resolution: {integrity: sha512-lxfDT0UuSc1HqltOGsTEAlZ6H29gpfDSdEPTapD5G63RbnYToZ+ezjzdonCCH90j5tRRCw3aLXVbiZaBW3VRVg==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-node@2.2.0': - resolution: {integrity: sha512-zpQMtJVqCUMn+pCSFcl9K/RPNtQE0NuMh8sKpCdEHafhwRsjP50Oq/4kMmvxSRy6d8Jslqd8BLvDngrUtmN9iA==} - engines: {node: '>=14.0.0'} - '@smithy/eventstream-serde-node@4.2.4': resolution: {integrity: sha512-TPhiGByWnYyzcpU/K3pO5V7QgtXYpE0NaJPEZBCa1Y5jlw5SjqzMSbFiLb+ZkJhqoQc0ImGyVINqnq1ze0ZRcQ==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-universal@2.2.0': - resolution: {integrity: sha512-pvoe/vvJY0mOpuF84BEtyZoYfbehiFj8KKWk1ds2AT0mTLYFVs+7sBJZmioOFdBXKd48lfrx1vumdPdmGlCLxA==} - engines: {node: '>=14.0.0'} - '@smithy/eventstream-serde-universal@4.2.4': resolution: {integrity: sha512-GNI/IXaY/XBB1SkGBFmbW033uWA0tj085eCxYih0eccUe/PFR7+UBQv9HNDk2fD9TJu7UVsCWsH99TkpEPSOzQ==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@2.5.0': - resolution: {integrity: sha512-BOWEBeppWhLn/no/JxUL/ghTfANTjT7kg3Ww2rPqTUY9R4yHPXxJ9JhMe3Z03LN3aPwiwlpDIUcVw1xDyHqEhw==} - '@smithy/fetch-http-handler@5.3.5': resolution: {integrity: sha512-mg83SM3FLI8Sa2ooTJbsh5MFfyMTyNRwxqpKHmE0ICRIa66Aodv80DMsTQI02xBLVJ0hckwqTRr5IGAbbWuFLQ==} engines: {node: '>=18.0.0'} @@ -3866,10 +3972,6 @@ packages: resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} - '@smithy/is-array-buffer@3.0.0': - resolution: {integrity: sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ==} - engines: {node: '>=16.0.0'} - '@smithy/is-array-buffer@4.2.0': resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} engines: {node: '>=18.0.0'} @@ -3878,10 +3980,6 @@ packages: resolution: {integrity: sha512-hJRZuFS9UsElX4DJSJfoX4M1qXRH+VFiLMUnhsWvtOOUWRNvvOfDaUSdlNbjwv1IkpVjj/Rd/O59Jl3nhAcxow==} engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@2.5.1': - resolution: {integrity: sha512-1/8kFp6Fl4OsSIVTWHnNjLnTL8IqpIb/D3sTSczrKFnrE9VMNWxnrRKNvpUHOJ6zpGD5f62TPm7+17ilTJpiCQ==} - engines: {node: '>=14.0.0'} - '@smithy/middleware-endpoint@4.3.6': resolution: {integrity: sha512-PXehXofGMFpDqr933rxD8RGOcZ0QBAWtuzTgYRAHAL2BnKawHDEdf/TnGpcmfPJGwonhginaaeJIKluEojiF/w==} engines: {node: '>=18.0.0'} @@ -3890,66 +3988,34 @@ packages: resolution: {integrity: sha512-OhLx131znrEDxZPAvH/OYufR9d1nB2CQADyYFN4C3V/NQS7Mg4V6uvxHC/Dr96ZQW8IlHJTJ+vAhKt6oxWRndA==} engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@2.3.0': - resolution: {integrity: sha512-sIADe7ojwqTyvEQBe1nc/GXB9wdHhi9UwyX0lTyttmUWDJLP655ZYE1WngnNyXREme8I27KCaUhyhZWRXL0q7Q==} - engines: {node: '>=14.0.0'} - '@smithy/middleware-serde@4.2.4': resolution: {integrity: sha512-jUr3x2CDhV15TOX2/Uoz4gfgeqLrRoTQbYAuhLS7lcVKNev7FeYSJ1ebEfjk+l9kbb7k7LfzIR/irgxys5ZTOg==} engines: {node: '>=18.0.0'} - '@smithy/middleware-stack@2.2.0': - resolution: {integrity: sha512-Qntc3jrtwwrsAC+X8wms8zhrTr0sFXnyEGhZd9sLtsJ/6gGQKFzNB+wWbOcpJd7BR8ThNCoKt76BuQahfMvpeA==} - engines: {node: '>=14.0.0'} - '@smithy/middleware-stack@4.2.4': resolution: {integrity: sha512-Gy3TKCOnm9JwpFooldwAboazw+EFYlC+Bb+1QBsSi5xI0W5lX81j/P5+CXvD/9ZjtYKRgxq+kkqd/KOHflzvgA==} engines: {node: '>=18.0.0'} - '@smithy/node-config-provider@2.3.0': - resolution: {integrity: sha512-0elK5/03a1JPWMDPaS726Iw6LpQg80gFut1tNpPfxFuChEEklo2yL823V94SpTZTxmKlXFtFgsP55uh3dErnIg==} - engines: {node: '>=14.0.0'} - '@smithy/node-config-provider@4.3.4': resolution: {integrity: sha512-3X3w7qzmo4XNNdPKNS4nbJcGSwiEMsNsRSunMA92S4DJLLIrH5g1AyuOA2XKM9PAPi8mIWfqC+fnfKNsI4KvHw==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@2.5.0': - resolution: {integrity: sha512-mVGyPBzkkGQsPoxQUbxlEfRjrj6FPyA3u3u2VXGr9hT8wilsoQdZdvKpMBFMB8Crfhv5dNkKHIW0Yyuc7eABqA==} - engines: {node: '>=14.0.0'} - '@smithy/node-http-handler@4.4.4': resolution: {integrity: sha512-VXHGfzCXLZeKnFp6QXjAdy+U8JF9etfpUXD1FAbzY1GzsFJiDQRQIt2CnMUvUdz3/YaHNqT3RphVWMUpXTIODA==} engines: {node: '>=18.0.0'} - '@smithy/property-provider@2.2.0': - resolution: {integrity: sha512-+xiil2lFhtTRzXkx8F053AV46QnIw6e7MV8od5Mi68E1ICOjCeCHw2XfLnDEUHnT9WGUIkwcqavXjfwuJbGlpg==} - engines: {node: '>=14.0.0'} - '@smithy/property-provider@4.2.4': resolution: {integrity: sha512-g2DHo08IhxV5GdY3Cpt/jr0mkTlAD39EJKN27Jb5N8Fb5qt8KG39wVKTXiTRCmHHou7lbXR8nKVU14/aRUf86w==} engines: {node: '>=18.0.0'} - '@smithy/protocol-http@3.3.0': - resolution: {integrity: sha512-Xy5XK1AFWW2nlY/biWZXu6/krgbaf2dg0q492D8M5qthsnU2H+UgFeZLbM76FnH7s6RO/xhQRkj+T6KBO3JzgQ==} - engines: {node: '>=14.0.0'} - '@smithy/protocol-http@5.3.4': resolution: {integrity: sha512-3sfFd2MAzVt0Q/klOmjFi3oIkxczHs0avbwrfn1aBqtc23WqQSmjvk77MBw9WkEQcwbOYIX5/2z4ULj8DuxSsw==} engines: {node: '>=18.0.0'} - '@smithy/querystring-builder@2.2.0': - resolution: {integrity: sha512-L1kSeviUWL+emq3CUVSgdogoM/D9QMFaqxL/dd0X7PCNWmPXqt+ExtrBjqT0V7HLN03Vs9SuiLrG3zy3JGnE5A==} - engines: {node: '>=14.0.0'} - '@smithy/querystring-builder@4.2.4': resolution: {integrity: sha512-KQ1gFXXC+WsbPFnk7pzskzOpn4s+KheWgO3dzkIEmnb6NskAIGp/dGdbKisTPJdtov28qNDohQrgDUKzXZBLig==} engines: {node: '>=18.0.0'} - '@smithy/querystring-parser@2.2.0': - resolution: {integrity: sha512-BvHCDrKfbG5Yhbpj4vsbuPV2GgcpHiAkLeIlcA1LtfpMz3jrqizP1+OguSNSj1MwBHEiN+jwNisXLGdajGDQJA==} - engines: {node: '>=14.0.0'} - '@smithy/querystring-parser@4.2.4': resolution: {integrity: sha512-aHb5cqXZocdzEkZ/CvhVjdw5l4r1aU/9iMEyoKzH4eXMowT6M0YjBpp7W/+XjkBnY8Xh0kVd55GKjnPKlCwinQ==} engines: {node: '>=18.0.0'} @@ -3958,57 +4024,26 @@ packages: resolution: {integrity: sha512-fdWuhEx4+jHLGeew9/IvqVU/fxT/ot70tpRGuOLxE3HzZOyKeTQfYeV1oaBXpzi93WOk668hjMuuagJ2/Qs7ng==} engines: {node: '>=18.0.0'} - '@smithy/shared-ini-file-loader@2.4.0': - resolution: {integrity: sha512-WyujUJL8e1B6Z4PBfAqC/aGY1+C7T0w20Gih3yrvJSk97gpiVfB+y7c46T4Nunk+ZngLq0rOIdeVeIklk0R3OA==} - engines: {node: '>=14.0.0'} - '@smithy/shared-ini-file-loader@4.3.4': resolution: {integrity: sha512-y5ozxeQ9omVjbnJo9dtTsdXj9BEvGx2X8xvRgKnV+/7wLBuYJQL6dOa/qMY6omyHi7yjt1OA97jZLoVRYi8lxA==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@3.1.2': - resolution: {integrity: sha512-3BcPylEsYtD0esM4Hoyml/+s7WP2LFhcM3J2AGdcL2vx9O60TtfpDOL72gjb4lU8NeRPeKAwR77YNyyGvMbuEA==} - engines: {node: '>=16.0.0'} - '@smithy/signature-v4@5.3.4': resolution: {integrity: sha512-ScDCpasxH7w1HXHYbtk3jcivjvdA1VICyAdgvVqKhKKwxi+MTwZEqFw0minE+oZ7F07oF25xh4FGJxgqgShz0A==} engines: {node: '>=18.0.0'} - '@smithy/smithy-client@2.5.1': - resolution: {integrity: sha512-jrbSQrYCho0yDaaf92qWgd+7nAeap5LtHTI51KXqmpIFCceKU3K9+vIVTUH72bOJngBMqa4kyu1VJhRcSrk/CQ==} - engines: {node: '>=14.0.0'} - '@smithy/smithy-client@4.9.2': resolution: {integrity: sha512-gZU4uAFcdrSi3io8U99Qs/FvVdRxPvIMToi+MFfsy/DN9UqtknJ1ais+2M9yR8e0ASQpNmFYEKeIKVcMjQg3rg==} engines: {node: '>=18.0.0'} - '@smithy/types@2.12.0': - resolution: {integrity: sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==} - engines: {node: '>=14.0.0'} - - '@smithy/types@3.7.2': - resolution: {integrity: sha512-bNwBYYmN8Eh9RyjS1p2gW6MIhSO2rl7X9QeLM8iTdcGRP+eDiIWDt66c9IysCc22gefKszZv+ubV9qZc7hdESg==} - engines: {node: '>=16.0.0'} - - '@smithy/types@4.3.1': - resolution: {integrity: sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==} - engines: {node: '>=18.0.0'} - '@smithy/types@4.8.1': resolution: {integrity: sha512-N0Zn0OT1zc+NA+UVfkYqQzviRh5ucWwO7mBV3TmHHprMnfcJNfhlPicDkBHi0ewbh+y3evR6cNAW0Raxvb01NA==} engines: {node: '>=18.0.0'} - '@smithy/url-parser@2.2.0': - resolution: {integrity: sha512-hoA4zm61q1mNTpksiSWp2nEl1dt3j726HdRhiNgVJQMj7mLp7dprtF57mOB6JvEk/x9d2bsuL5hlqZbBuHQylQ==} - '@smithy/url-parser@4.2.4': resolution: {integrity: sha512-w/N/Iw0/PTwJ36PDqU9PzAwVElo4qXxCC0eCTlUtIz/Z5V/2j/cViMHi0hPukSBHp4DVwvUlUhLgCzqSJ6plrg==} engines: {node: '>=18.0.0'} - '@smithy/util-base64@2.3.0': - resolution: {integrity: sha512-s3+eVwNeJuXUwuMbusncZNViuhv2LjVJ1nMwTqSA0XAC7gjKhqqxRdJPhR8+YrkoZ9IiIbFk/yK6ACe/xlF+hw==} - engines: {node: '>=14.0.0'} - '@smithy/util-base64@4.3.0': resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} engines: {node: '>=18.0.0'} @@ -4025,10 +4060,6 @@ packages: resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} - '@smithy/util-buffer-from@3.0.0': - resolution: {integrity: sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA==} - engines: {node: '>=16.0.0'} - '@smithy/util-buffer-from@4.2.0': resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} engines: {node: '>=18.0.0'} @@ -4049,26 +4080,10 @@ packages: resolution: {integrity: sha512-f+nBDhgYRCmUEDKEQb6q0aCcOTXRDqH5wWaFHJxt4anB4pKHlgGoYP3xtioKXH64e37ANUkzWf6p4Mnv1M5/Vg==} engines: {node: '>=18.0.0'} - '@smithy/util-hex-encoding@2.2.0': - resolution: {integrity: sha512-7iKXR+/4TpLK194pVjKiasIyqMtTYJsgKgM242Y9uzt5dhHnUDvMNb+3xIhRJ9QhvqGii/5cRUt4fJn3dtXNHQ==} - engines: {node: '>=14.0.0'} - - '@smithy/util-hex-encoding@3.0.0': - resolution: {integrity: sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ==} - engines: {node: '>=16.0.0'} - '@smithy/util-hex-encoding@4.2.0': resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} engines: {node: '>=18.0.0'} - '@smithy/util-middleware@2.2.0': - resolution: {integrity: sha512-L1qpleXf9QD6LwLCJ5jddGkgWyuSvWBkJwWAZ6kFkdifdso+sk3L3O1HdmPvCdnCK3IS4qWyPxev01QMnfHSBw==} - engines: {node: '>=14.0.0'} - - '@smithy/util-middleware@3.0.11': - resolution: {integrity: sha512-dWpyc1e1R6VoXrwLoLDd57U1z6CwNSdkM69Ie4+6uYh2GC7Vg51Qtan7ITzczuVpqezdDTKJGJB95fFvvjU/ow==} - engines: {node: '>=16.0.0'} - '@smithy/util-middleware@4.2.4': resolution: {integrity: sha512-fKGQAPAn8sgV0plRikRVo6g6aR0KyKvgzNrPuM74RZKy/wWVzx3BMk+ZWEueyN3L5v5EDg+P582mKU+sH5OAsg==} engines: {node: '>=18.0.0'} @@ -4077,22 +4092,10 @@ packages: resolution: {integrity: sha512-yQncJmj4dtv/isTXxRb4AamZHy4QFr4ew8GxS6XLWt7sCIxkPxPzINWd7WLISEFPsIan14zrKgvyAF+/yzfwoA==} engines: {node: '>=18.0.0'} - '@smithy/util-stream@2.2.0': - resolution: {integrity: sha512-17faEXbYWIRst1aU9SvPZyMdWmqIrduZjVOqCPMIsWFNxs5yQQgFrJL6b2SdiCzyW9mJoDjFtgi53xx7EH+BXA==} - engines: {node: '>=14.0.0'} - '@smithy/util-stream@4.5.5': resolution: {integrity: sha512-7M5aVFjT+HPilPOKbOmQfCIPchZe4DSBc1wf1+NvHvSoFTiFtauZzT+onZvCj70xhXd0AEmYnZYmdJIuwxOo4w==} engines: {node: '>=18.0.0'} - '@smithy/util-uri-escape@2.2.0': - resolution: {integrity: sha512-jtmJMyt1xMD/d8OtbVJ2gFZOSKc+ueYJZPW20ULW1GOp/q/YIM0wNh+u8ZFao9UaIGz4WoPW8hC64qlWLIfoDA==} - engines: {node: '>=14.0.0'} - - '@smithy/util-uri-escape@3.0.0': - resolution: {integrity: sha512-LqR7qYLgZTD7nWLBecUi4aqolw8Mhza9ArpNEQ881MJJIU2sE5iHCK6TdyqqzcDLy0OPe10IY4T8ctVdtynubg==} - engines: {node: '>=16.0.0'} - '@smithy/util-uri-escape@4.2.0': resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} engines: {node: '>=18.0.0'} @@ -4101,10 +4104,6 @@ packages: resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} - '@smithy/util-utf8@3.0.0': - resolution: {integrity: sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==} - engines: {node: '>=16.0.0'} - '@smithy/util-utf8@4.2.0': resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} engines: {node: '>=18.0.0'} @@ -4892,8 +4891,8 @@ packages: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} - ai@6.0.57: - resolution: {integrity: sha512-5wYcMQmOaNU71wGv4XX1db3zvn4uLjLbTKIo6cQZPWOJElA0882XI7Eawx6TCd5jbjOvKMIP+KLWbpVomAFT2g==} + ai@6.0.77: + resolution: {integrity: sha512-tyyhrRpCRFVlivdNIFLK8cexSBB2jwTqO0z1qJQagk+UxZ+MW8h5V8xsvvb+xdKDY482Y8KAm0mr7TDnPKvvlw==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 @@ -5063,6 +5062,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + aws4fetch@1.0.20: + resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + axios@1.12.0: resolution: {integrity: sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==} @@ -6057,6 +6059,10 @@ packages: resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} engines: {node: '>=12'} + dotenv@16.4.5: + resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} + engines: {node: '>=12'} + dotenv@16.5.0: resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} engines: {node: '>=12'} @@ -6488,10 +6494,6 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - eventsource-parser@3.0.2: - resolution: {integrity: sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA==} - engines: {node: '>=18.0.0'} - eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -6804,18 +6806,10 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} deprecated: This package is no longer supported. - gaxios@6.7.1: - resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} - engines: {node: '>=14'} - gaxios@7.1.3: resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} engines: {node: '>=18'} - gcp-metadata@6.1.1: - resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} - engines: {node: '>=14'} - gcp-metadata@8.1.2: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} @@ -6936,14 +6930,6 @@ packages: resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} engines: {node: '>=18'} - google-auth-library@9.15.1: - resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} - engines: {node: '>=14'} - - google-logging-utils@0.0.2: - resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} - engines: {node: '>=14'} - google-logging-utils@1.1.3: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} @@ -6962,10 +6948,6 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} - gtoken@7.1.0: - resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} - engines: {node: '>=14.0.0'} - gtoken@8.0.0: resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} engines: {node: '>=18'} @@ -9511,6 +9493,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sambanova-ai-provider@1.2.2: + resolution: {integrity: sha512-MU/D+9GCg6me0guDRPw/x0N8cnpkOkv03FR7QXdrcinX0hprS7bsZXXTYEz81Svc+oVwXDZwh0v+Sd5pUxV3mg==} + sanitize-filename@1.6.3: resolution: {integrity: sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==} @@ -10525,10 +10510,6 @@ packages: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true - uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} - hasBin: true - v8-to-istanbul@9.3.0: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} @@ -10991,6 +10972,10 @@ packages: yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zhipu-ai-provider@0.2.2: + resolution: {integrity: sha512-UjX1ho4DI9ICUv/mrpAnzmrRe5/LXrGkS5hF6h4WDY2aup5GketWWopFzWYCqsbArXAM5wbzzdH9QzZusgGiBg==} + engines: {node: '>=18'} + zip-stream@4.1.1: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} @@ -11044,49 +11029,102 @@ snapshots: '@adobe/css-tools@4.4.2': {} - '@ai-sdk/cerebras@1.0.35(zod@3.25.76)': + '@ai-sdk/amazon-bedrock@4.0.51(zod@3.25.76)': dependencies: - '@ai-sdk/openai-compatible': 1.0.31(zod@3.25.76) + '@ai-sdk/anthropic': 3.0.38(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + '@smithy/eventstream-codec': 4.2.4 + '@smithy/util-utf8': 4.2.0 + aws4fetch: 1.0.20 + zod: 3.25.76 + + '@ai-sdk/anthropic@3.0.38(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + zod: 3.25.76 + + '@ai-sdk/azure@2.0.91(zod@3.25.76)': + dependencies: + '@ai-sdk/openai': 2.0.89(zod@3.25.76) '@ai-sdk/provider': 2.0.1 '@ai-sdk/provider-utils': 3.0.20(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/deepseek@2.0.14(zod@3.25.76)': + '@ai-sdk/baseten@1.0.31(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 3.0.5 - '@ai-sdk/provider-utils': 4.0.10(zod@3.25.76) + '@ai-sdk/openai-compatible': 2.0.28(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + '@basetenlabs/performance-client': 0.0.10 zod: 3.25.76 - '@ai-sdk/fireworks@2.0.26(zod@3.25.76)': + '@ai-sdk/deepseek@2.0.18(zod@3.25.76)': dependencies: - '@ai-sdk/openai-compatible': 2.0.24(zod@3.25.76) - '@ai-sdk/provider': 3.0.6 - '@ai-sdk/provider-utils': 4.0.11(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/gateway@3.0.25(zod@3.25.76)': + '@ai-sdk/fireworks@2.0.32(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 3.0.5 - '@ai-sdk/provider-utils': 4.0.10(zod@3.25.76) + '@ai-sdk/openai-compatible': 2.0.28(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + zod: 3.25.76 + + '@ai-sdk/gateway@3.0.39(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) '@vercel/oidc': 3.1.0 zod: 3.25.76 - '@ai-sdk/groq@3.0.19(zod@3.25.76)': + '@ai-sdk/google-vertex@4.0.45(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 3.0.6 - '@ai-sdk/provider-utils': 4.0.11(zod@3.25.76) + '@ai-sdk/anthropic': 3.0.38(zod@3.25.76) + '@ai-sdk/google': 3.0.22(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + google-auth-library: 10.5.0 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@ai-sdk/google@3.0.22(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/openai-compatible@1.0.31(zod@3.25.76)': + '@ai-sdk/mistral@3.0.19(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + zod: 3.25.76 + + '@ai-sdk/openai-compatible@1.0.11(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.5(zod@3.25.76) + zod: 3.25.76 + + '@ai-sdk/openai-compatible@2.0.28(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + zod: 3.25.76 + + '@ai-sdk/openai@2.0.89(zod@3.25.76)': dependencies: '@ai-sdk/provider': 2.0.1 '@ai-sdk/provider-utils': 3.0.20(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/openai-compatible@2.0.24(zod@3.25.76)': + '@ai-sdk/openai@3.0.26(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 3.0.6 - '@ai-sdk/provider-utils': 4.0.11(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) zod: 3.25.76 '@ai-sdk/provider-utils@3.0.20(zod@3.25.76)': @@ -11096,31 +11134,39 @@ snapshots: eventsource-parser: 3.0.6 zod: 3.25.76 - '@ai-sdk/provider-utils@4.0.10(zod@3.25.76)': + '@ai-sdk/provider-utils@3.0.5(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 3.0.5 + '@ai-sdk/provider': 2.0.0 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 3.25.76 + zod-to-json-schema: 3.24.5(zod@3.25.76) + + '@ai-sdk/provider-utils@4.0.14(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.0.6 zod: 3.25.76 - '@ai-sdk/provider-utils@4.0.11(zod@3.25.76)': + '@ai-sdk/provider@2.0.0': dependencies: - '@ai-sdk/provider': 3.0.6 - '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.0.6 - zod: 3.25.76 + json-schema: 0.4.0 '@ai-sdk/provider@2.0.1': dependencies: json-schema: 0.4.0 - '@ai-sdk/provider@3.0.5': + '@ai-sdk/provider@3.0.8': dependencies: json-schema: 0.4.0 - '@ai-sdk/provider@3.0.6': + '@ai-sdk/xai@3.0.48(zod@3.25.76)': dependencies: - json-schema: 0.4.0 + '@ai-sdk/openai-compatible': 2.0.28(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + zod: 3.25.76 '@alcalzone/ansi-tokenize@0.2.3': dependencies: @@ -11141,23 +11187,6 @@ snapshots: '@antfu/utils@8.1.1': {} - '@anthropic-ai/bedrock-sdk@0.10.4': - dependencies: - '@anthropic-ai/sdk': 0.37.0 - '@aws-crypto/sha256-js': 4.0.0 - '@aws-sdk/client-bedrock-runtime': 3.922.0 - '@aws-sdk/credential-providers': 3.922.0 - '@smithy/eventstream-serde-node': 2.2.0 - '@smithy/fetch-http-handler': 2.5.0 - '@smithy/protocol-http': 3.3.0 - '@smithy/signature-v4': 3.1.2 - '@smithy/smithy-client': 2.5.1 - '@smithy/types': 2.12.0 - '@smithy/util-base64': 2.3.0 - transitivePeerDependencies: - - aws-crt - - encoding - '@anthropic-ai/sdk@0.37.0': dependencies: '@types/node': 18.19.100 @@ -11170,14 +11199,6 @@ snapshots: transitivePeerDependencies: - encoding - '@anthropic-ai/vertex-sdk@0.7.0': - dependencies: - '@anthropic-ai/sdk': 0.37.0 - google-auth-library: 9.15.1 - transitivePeerDependencies: - - encoding - - supports-color - '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -11186,12 +11207,6 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 - '@aws-crypto/crc32@3.0.0': - dependencies: - '@aws-crypto/util': 3.0.0 - '@aws-sdk/types': 3.840.0 - tslib: 1.14.1 - '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -11208,12 +11223,6 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-crypto/sha256-js@4.0.0': - dependencies: - '@aws-crypto/util': 4.0.0 - '@aws-sdk/types': 3.804.0 - tslib: 1.14.1 - '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -11224,18 +11233,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@aws-crypto/util@3.0.0': - dependencies: - '@aws-sdk/types': 3.840.0 - '@aws-sdk/util-utf8-browser': 3.259.0 - tslib: 1.14.1 - - '@aws-crypto/util@4.0.0': - dependencies: - '@aws-sdk/types': 3.840.0 - '@aws-sdk/util-utf8-browser': 3.259.0 - tslib: 1.14.1 - '@aws-crypto/util@5.2.0': dependencies: '@aws-sdk/types': 3.922.0 @@ -11643,16 +11640,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/types@3.804.0': - dependencies: - '@smithy/types': 4.3.1 - tslib: 2.8.1 - - '@aws-sdk/types@3.840.0': - dependencies: - '@smithy/types': 4.3.1 - tslib: 2.8.1 - '@aws-sdk/types@3.922.0': dependencies: '@smithy/types': 4.8.1 @@ -11692,10 +11679,6 @@ snapshots: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@aws-sdk/util-utf8-browser@3.259.0': - dependencies: - tslib: 2.8.1 - '@aws-sdk/xml-builder@3.921.0': dependencies: '@smithy/types': 4.8.1 @@ -11905,6 +11888,65 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@basetenlabs/performance-client-android-arm-eabi@0.0.10': + optional: true + + '@basetenlabs/performance-client-android-arm64@0.0.10': + optional: true + + '@basetenlabs/performance-client-darwin-arm64@0.0.10': + optional: true + + '@basetenlabs/performance-client-darwin-universal@0.0.10': + optional: true + + '@basetenlabs/performance-client-darwin-x64@0.0.10': + optional: true + + '@basetenlabs/performance-client-linux-arm-gnueabihf@0.0.10': + optional: true + + '@basetenlabs/performance-client-linux-arm-musleabihf@0.0.10': + optional: true + + '@basetenlabs/performance-client-linux-arm64-gnu@0.0.10': + optional: true + + '@basetenlabs/performance-client-linux-riscv64-gnu@0.0.10': + optional: true + + '@basetenlabs/performance-client-linux-x64-gnu@0.0.10': + optional: true + + '@basetenlabs/performance-client-linux-x64-musl@0.0.10': + optional: true + + '@basetenlabs/performance-client-win32-arm64-msvc@0.0.10': + optional: true + + '@basetenlabs/performance-client-win32-ia32-msvc@0.0.10': + optional: true + + '@basetenlabs/performance-client-win32-x64-msvc@0.0.10': + optional: true + + '@basetenlabs/performance-client@0.0.10': + optionalDependencies: + '@basetenlabs/performance-client-android-arm-eabi': 0.0.10 + '@basetenlabs/performance-client-android-arm64': 0.0.10 + '@basetenlabs/performance-client-darwin-arm64': 0.0.10 + '@basetenlabs/performance-client-darwin-universal': 0.0.10 + '@basetenlabs/performance-client-darwin-x64': 0.0.10 + '@basetenlabs/performance-client-linux-arm-gnueabihf': 0.0.10 + '@basetenlabs/performance-client-linux-arm-musleabihf': 0.0.10 + '@basetenlabs/performance-client-linux-arm64-gnu': 0.0.10 + '@basetenlabs/performance-client-linux-riscv64-gnu': 0.0.10 + '@basetenlabs/performance-client-linux-x64-gnu': 0.0.10 + '@basetenlabs/performance-client-linux-x64-musl': 0.0.10 + '@basetenlabs/performance-client-win32-arm64-msvc': 0.0.10 + '@basetenlabs/performance-client-win32-ia32-msvc': 0.0.10 + '@basetenlabs/performance-client-win32-x64-msvc': 0.0.10 + '@bcoe/v8-coverage@0.2.3': {} '@braintree/sanitize-url@7.1.1': {} @@ -12834,9 +12876,9 @@ snapshots: '@open-draft/until@2.1.0': {} - '@openrouter/ai-sdk-provider@2.1.1(ai@6.0.57(zod@3.25.76))(zod@3.25.76)': + '@openrouter/ai-sdk-provider@2.1.1(ai@6.0.77(zod@3.25.76))(zod@3.25.76)': dependencies: - ai: 6.0.57(zod@3.25.76) + ai: 6.0.77(zod@3.25.76) zod: 3.25.76 '@opentelemetry/api-logs@0.208.0': @@ -13878,11 +13920,6 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@smithy/abort-controller@2.2.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/abort-controller@4.2.4': dependencies: '@smithy/types': 4.8.1 @@ -13918,13 +13955,6 @@ snapshots: '@smithy/url-parser': 4.2.4 tslib: 2.8.1 - '@smithy/eventstream-codec@2.2.0': - dependencies: - '@aws-crypto/crc32': 3.0.0 - '@smithy/types': 2.12.0 - '@smithy/util-hex-encoding': 2.2.0 - tslib: 2.8.1 - '@smithy/eventstream-codec@4.2.4': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -13943,38 +13973,18 @@ snapshots: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/eventstream-serde-node@2.2.0': - dependencies: - '@smithy/eventstream-serde-universal': 2.2.0 - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/eventstream-serde-node@4.2.4': dependencies: '@smithy/eventstream-serde-universal': 4.2.4 '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/eventstream-serde-universal@2.2.0': - dependencies: - '@smithy/eventstream-codec': 2.2.0 - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/eventstream-serde-universal@4.2.4': dependencies: '@smithy/eventstream-codec': 4.2.4 '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/fetch-http-handler@2.5.0': - dependencies: - '@smithy/protocol-http': 3.3.0 - '@smithy/querystring-builder': 2.2.0 - '@smithy/types': 2.12.0 - '@smithy/util-base64': 2.3.0 - tslib: 2.8.1 - '@smithy/fetch-http-handler@5.3.5': dependencies: '@smithy/protocol-http': 5.3.4 @@ -13999,10 +14009,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@smithy/is-array-buffer@3.0.0': - dependencies: - tslib: 2.8.1 - '@smithy/is-array-buffer@4.2.0': dependencies: tslib: 2.8.1 @@ -14013,16 +14019,6 @@ snapshots: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/middleware-endpoint@2.5.1': - dependencies: - '@smithy/middleware-serde': 2.3.0 - '@smithy/node-config-provider': 2.3.0 - '@smithy/shared-ini-file-loader': 2.4.0 - '@smithy/types': 2.12.0 - '@smithy/url-parser': 2.2.0 - '@smithy/util-middleware': 2.2.0 - tslib: 2.8.1 - '@smithy/middleware-endpoint@4.3.6': dependencies: '@smithy/core': 3.17.2 @@ -14046,34 +14042,17 @@ snapshots: '@smithy/uuid': 1.1.0 tslib: 2.8.1 - '@smithy/middleware-serde@2.3.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/middleware-serde@4.2.4': dependencies: '@smithy/protocol-http': 5.3.4 '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/middleware-stack@2.2.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/middleware-stack@4.2.4': dependencies: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/node-config-provider@2.3.0': - dependencies: - '@smithy/property-provider': 2.2.0 - '@smithy/shared-ini-file-loader': 2.4.0 - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/node-config-provider@4.3.4': dependencies: '@smithy/property-provider': 4.2.4 @@ -14081,14 +14060,6 @@ snapshots: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/node-http-handler@2.5.0': - dependencies: - '@smithy/abort-controller': 2.2.0 - '@smithy/protocol-http': 3.3.0 - '@smithy/querystring-builder': 2.2.0 - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/node-http-handler@4.4.4': dependencies: '@smithy/abort-controller': 4.2.4 @@ -14097,43 +14068,22 @@ snapshots: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/property-provider@2.2.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/property-provider@4.2.4': dependencies: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/protocol-http@3.3.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/protocol-http@5.3.4': dependencies: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/querystring-builder@2.2.0': - dependencies: - '@smithy/types': 2.12.0 - '@smithy/util-uri-escape': 2.2.0 - tslib: 2.8.1 - '@smithy/querystring-builder@4.2.4': dependencies: '@smithy/types': 4.8.1 '@smithy/util-uri-escape': 4.2.0 tslib: 2.8.1 - '@smithy/querystring-parser@2.2.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/querystring-parser@4.2.4': dependencies: '@smithy/types': 4.8.1 @@ -14143,26 +14093,11 @@ snapshots: dependencies: '@smithy/types': 4.8.1 - '@smithy/shared-ini-file-loader@2.4.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/shared-ini-file-loader@4.3.4': dependencies: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/signature-v4@3.1.2': - dependencies: - '@smithy/is-array-buffer': 3.0.0 - '@smithy/types': 3.7.2 - '@smithy/util-hex-encoding': 3.0.0 - '@smithy/util-middleware': 3.0.11 - '@smithy/util-uri-escape': 3.0.0 - '@smithy/util-utf8': 3.0.0 - tslib: 2.8.1 - '@smithy/signature-v4@5.3.4': dependencies: '@smithy/is-array-buffer': 4.2.0 @@ -14174,15 +14109,6 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@smithy/smithy-client@2.5.1': - dependencies: - '@smithy/middleware-endpoint': 2.5.1 - '@smithy/middleware-stack': 2.2.0 - '@smithy/protocol-http': 3.3.0 - '@smithy/types': 2.12.0 - '@smithy/util-stream': 2.2.0 - tslib: 2.8.1 - '@smithy/smithy-client@4.9.2': dependencies: '@smithy/core': 3.17.2 @@ -14193,40 +14119,16 @@ snapshots: '@smithy/util-stream': 4.5.5 tslib: 2.8.1 - '@smithy/types@2.12.0': - dependencies: - tslib: 2.8.1 - - '@smithy/types@3.7.2': - dependencies: - tslib: 2.8.1 - - '@smithy/types@4.3.1': - dependencies: - tslib: 2.8.1 - '@smithy/types@4.8.1': dependencies: tslib: 2.8.1 - '@smithy/url-parser@2.2.0': - dependencies: - '@smithy/querystring-parser': 2.2.0 - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/url-parser@4.2.4': dependencies: '@smithy/querystring-parser': 4.2.4 '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/util-base64@2.3.0': - dependencies: - '@smithy/util-buffer-from': 2.2.0 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - '@smithy/util-base64@4.3.0': dependencies: '@smithy/util-buffer-from': 4.2.0 @@ -14246,11 +14148,6 @@ snapshots: '@smithy/is-array-buffer': 2.2.0 tslib: 2.8.1 - '@smithy/util-buffer-from@3.0.0': - dependencies: - '@smithy/is-array-buffer': 3.0.0 - tslib: 2.8.1 - '@smithy/util-buffer-from@4.2.0': dependencies: '@smithy/is-array-buffer': 4.2.0 @@ -14283,28 +14180,10 @@ snapshots: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/util-hex-encoding@2.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/util-hex-encoding@3.0.0': - dependencies: - tslib: 2.8.1 - '@smithy/util-hex-encoding@4.2.0': dependencies: tslib: 2.8.1 - '@smithy/util-middleware@2.2.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 - - '@smithy/util-middleware@3.0.11': - dependencies: - '@smithy/types': 3.7.2 - tslib: 2.8.1 - '@smithy/util-middleware@4.2.4': dependencies: '@smithy/types': 4.8.1 @@ -14316,17 +14195,6 @@ snapshots: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/util-stream@2.2.0': - dependencies: - '@smithy/fetch-http-handler': 2.5.0 - '@smithy/node-http-handler': 2.5.0 - '@smithy/types': 2.12.0 - '@smithy/util-base64': 2.3.0 - '@smithy/util-buffer-from': 2.2.0 - '@smithy/util-hex-encoding': 2.2.0 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - '@smithy/util-stream@4.5.5': dependencies: '@smithy/fetch-http-handler': 5.3.5 @@ -14338,14 +14206,6 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@smithy/util-uri-escape@2.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/util-uri-escape@3.0.0': - dependencies: - tslib: 2.8.1 - '@smithy/util-uri-escape@4.2.0': dependencies: tslib: 2.8.1 @@ -14355,11 +14215,6 @@ snapshots: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 - '@smithy/util-utf8@3.0.0': - dependencies: - '@smithy/util-buffer-from': 3.0.0 - tslib: 2.8.1 - '@smithy/util-utf8@4.2.0': dependencies: '@smithy/util-buffer-from': 4.2.0 @@ -15098,7 +14953,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -15248,11 +15103,11 @@ snapshots: dependencies: humanize-ms: 1.2.1 - ai@6.0.57(zod@3.25.76): + ai@6.0.77(zod@3.25.76): dependencies: - '@ai-sdk/gateway': 3.0.25(zod@3.25.76) - '@ai-sdk/provider': 3.0.5 - '@ai-sdk/provider-utils': 4.0.10(zod@3.25.76) + '@ai-sdk/gateway': 3.0.39(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) '@opentelemetry/api': 1.9.0 zod: 3.25.76 @@ -15464,6 +15319,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + aws4fetch@1.0.20: {} + axios@1.12.0: dependencies: follow-redirects: 1.15.11 @@ -16454,6 +16311,8 @@ snapshots: dotenv@16.0.3: {} + dotenv@16.4.5: {} + dotenv@16.5.0: {} drizzle-kit@0.31.4: @@ -16945,13 +16804,11 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.0.2: {} - eventsource-parser@3.0.6: {} eventsource@3.0.7: dependencies: - eventsource-parser: 3.0.2 + eventsource-parser: 3.0.6 exceljs@4.4.0: dependencies: @@ -17323,17 +17180,6 @@ snapshots: strip-ansi: 6.0.1 wide-align: 1.1.5 - gaxios@6.7.1: - dependencies: - extend: 3.0.2 - https-proxy-agent: 7.0.6 - is-stream: 2.0.1 - node-fetch: 2.7.0 - uuid: 9.0.1 - transitivePeerDependencies: - - encoding - - supports-color - gaxios@7.1.3: dependencies: extend: 3.0.2 @@ -17343,15 +17189,6 @@ snapshots: transitivePeerDependencies: - supports-color - gcp-metadata@6.1.1: - dependencies: - gaxios: 6.7.1 - google-logging-utils: 0.0.2 - json-bigint: 1.0.0 - transitivePeerDependencies: - - encoding - - supports-color - gcp-metadata@8.1.2: dependencies: gaxios: 7.1.3 @@ -17500,20 +17337,6 @@ snapshots: transitivePeerDependencies: - supports-color - google-auth-library@9.15.1: - dependencies: - base64-js: 1.5.1 - ecdsa-sig-formatter: 1.0.11 - gaxios: 6.7.1 - gcp-metadata: 6.1.1 - gtoken: 7.1.0 - jws: 4.0.0 - transitivePeerDependencies: - - encoding - - supports-color - - google-logging-utils@0.0.2: {} - google-logging-utils@1.1.3: {} gopd@1.2.0: {} @@ -17529,14 +17352,6 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 - gtoken@7.1.0: - dependencies: - gaxios: 6.7.1 - jws: 4.0.0 - transitivePeerDependencies: - - encoding - - supports-color - gtoken@8.0.0: dependencies: gaxios: 7.1.3 @@ -20607,6 +20422,15 @@ snapshots: safer-buffer@2.1.2: {} + sambanova-ai-provider@1.2.2(zod@3.25.76): + dependencies: + '@ai-sdk/openai-compatible': 1.0.11(zod@3.25.76) + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.5(zod@3.25.76) + dotenv: 16.4.5 + transitivePeerDependencies: + - zod + sanitize-filename@1.6.3: dependencies: truncate-utf8-bytes: 1.0.2 @@ -21734,8 +21558,6 @@ snapshots: uuid@8.3.2: {} - uuid@9.0.1: {} - v8-to-istanbul@9.3.0: dependencies: '@jridgewell/trace-mapping': 0.3.25 @@ -22378,6 +22200,13 @@ snapshots: yoga-layout@3.2.1: {} + zhipu-ai-provider@0.2.2(zod@3.25.76): + dependencies: + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.5(zod@3.25.76) + transitivePeerDependencies: + - zod + zip-stream@4.1.1: dependencies: archiver-utils: 3.0.4 diff --git a/releases/3.47.0-release.png b/releases/3.47.0-release.png new file mode 100644 index 0000000000..bc5460933e Binary files /dev/null and b/releases/3.47.0-release.png differ diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index f3256bd143..a78c41b7c0 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -387,6 +387,7 @@ describe("History resume delegation - parent metadata transitions", () => { it("reopenParentFromDelegation emits events in correct order: TaskDelegationCompleted → TaskDelegationResumed", async () => { const emitSpy = vi.fn() + const updateTaskHistory = vi.fn().mockResolvedValue([]) const provider = { contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, @@ -411,7 +412,7 @@ describe("History resume delegation - parent metadata transitions", () => { overwriteClineMessages: vi.fn().mockResolvedValue(undefined), overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), }), - updateTaskHistory: vi.fn().mockResolvedValue([]), + updateTaskHistory, } as unknown as ClineProvider vi.mocked(readTaskMessages).mockResolvedValue([]) @@ -433,6 +434,92 @@ describe("History resume delegation - parent metadata transitions", () => { const resumedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationResumed) expect(completedIdx).toBeGreaterThanOrEqual(0) expect(resumedIdx).toBeGreaterThan(completedIdx) + + // RPD-05: verify parent metadata persistence happens before TaskDelegationCompleted emit + const parentUpdateCallIdx = updateTaskHistory.mock.calls.findIndex((call) => { + const item = call[0] as { id?: string; status?: string } | undefined + return item?.id === "p3" && item.status === "active" + }) + expect(parentUpdateCallIdx).toBeGreaterThanOrEqual(0) + + const parentUpdateCallOrder = updateTaskHistory.mock.invocationCallOrder[parentUpdateCallIdx] + const completedEmitCallOrder = emitSpy.mock.invocationCallOrder[completedIdx] + expect(parentUpdateCallOrder).toBeLessThan(completedEmitCallOrder) + }) + + it("reopenParentFromDelegation continues when overwrite operations fail and still resumes/emits (RPD-06)", async () => { + const emitSpy = vi.fn() + const parentInstance = { + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockRejectedValue(new Error("ui overwrite failed")), + overwriteApiConversationHistory: vi.fn().mockRejectedValue(new Error("api overwrite failed")), + } + + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockImplementation(async (id: string) => { + if (id === "parent-rpd06") { + return { + historyItem: { + id: "parent-rpd06", + status: "delegated", + awaitingChildId: "child-rpd06", + childIds: ["child-rpd06"], + ts: 800, + task: "Parent RPD-06", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + } + } + + return { + historyItem: { + id: "child-rpd06", + status: "active", + ts: 801, + task: "Child RPD-06", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + } + }), + emit: emitSpy, + getCurrentTask: vi.fn(() => ({ taskId: "child-rpd06" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue(parentInstance), + updateTaskHistory: vi.fn().mockResolvedValue([]), + } as unknown as ClineProvider + + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await expect( + (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-rpd06", + childTaskId: "child-rpd06", + completionResultSummary: "Subtask finished despite overwrite failures", + }), + ).resolves.toBeUndefined() + + expect(parentInstance.overwriteClineMessages).toHaveBeenCalledTimes(1) + expect(parentInstance.overwriteApiConversationHistory).toHaveBeenCalledTimes(1) + expect(parentInstance.resumeAfterDelegation).toHaveBeenCalledTimes(1) + + expect(emitSpy).toHaveBeenCalledWith( + RooCodeEventName.TaskDelegationCompleted, + "parent-rpd06", + "child-rpd06", + "Subtask finished despite overwrite failures", + ) + expect(emitSpy).toHaveBeenCalledWith(RooCodeEventName.TaskDelegationResumed, "parent-rpd06", "child-rpd06") + + const completedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationCompleted) + const resumedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationResumed) + expect(completedIdx).toBeGreaterThanOrEqual(0) + expect(resumedIdx).toBeGreaterThan(completedIdx) }) it("reopenParentFromDelegation does NOT emit TaskPaused or TaskUnpaused (new flow only)", async () => { @@ -480,6 +567,162 @@ describe("History resume delegation - parent metadata transitions", () => { expect(eventNames).not.toContain(RooCodeEventName.TaskSpawned) }) + it("reopenParentFromDelegation skips child close when current task differs and still reopens parent (RPD-02)", async () => { + const parentInstance = { + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + } + + const updateTaskHistory = vi.fn().mockResolvedValue([]) + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + const createTaskWithHistoryItem = vi.fn().mockResolvedValue(parentInstance) + + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockImplementation(async (id: string) => { + if (id === "parent-rpd02") { + return { + historyItem: { + id: "parent-rpd02", + status: "delegated", + awaitingChildId: "child-rpd02", + childIds: ["child-rpd02"], + ts: 600, + task: "Parent RPD-02", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + } + } + return { + historyItem: { + id: "child-rpd02", + status: "active", + ts: 601, + task: "Child RPD-02", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + } + }), + emit: vi.fn(), + getCurrentTask: vi.fn(() => ({ taskId: "different-open-task" })), + removeClineFromStack, + createTaskWithHistoryItem, + updateTaskHistory, + } as unknown as ClineProvider + + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-rpd02", + childTaskId: "child-rpd02", + completionResultSummary: "Child done without being current", + }) + + expect(removeClineFromStack).not.toHaveBeenCalled() + expect(updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ + id: "child-rpd02", + status: "completed", + }), + ) + expect(createTaskWithHistoryItem).toHaveBeenCalledWith( + expect.objectContaining({ + id: "parent-rpd02", + status: "active", + completedByChildId: "child-rpd02", + }), + { startTask: false }, + ) + expect(parentInstance.resumeAfterDelegation).toHaveBeenCalledTimes(1) + }) + + it("reopenParentFromDelegation logs child status persistence failure and continues reopen flow (RPD-04)", async () => { + const logSpy = vi.fn() + const emitSpy = vi.fn() + const parentInstance = { + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + } + + const updateTaskHistory = vi.fn().mockImplementation(async (historyItem: { id?: string }) => { + if (historyItem.id === "child-rpd04") { + throw new Error("child status persist failed") + } + return [] + }) + + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockImplementation(async (id: string) => { + if (id === "parent-rpd04") { + return { + historyItem: { + id: "parent-rpd04", + status: "delegated", + awaitingChildId: "child-rpd04", + childIds: ["child-rpd04"], + ts: 700, + task: "Parent RPD-04", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + } + } + return { + historyItem: { + id: "child-rpd04", + status: "active", + ts: 701, + task: "Child RPD-04", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + } + }), + emit: emitSpy, + log: logSpy, + getCurrentTask: vi.fn(() => ({ taskId: "child-rpd04" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue(parentInstance), + updateTaskHistory, + } as unknown as ClineProvider + + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await expect( + (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-rpd04", + childTaskId: "child-rpd04", + completionResultSummary: "Child completion with persistence failure", + }), + ).resolves.toBeUndefined() + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining( + "[reopenParentFromDelegation] Failed to persist child completed status for child-rpd04:", + ), + ) + expect(updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ + id: "parent-rpd04", + status: "active", + completedByChildId: "child-rpd04", + }), + ) + expect(parentInstance.resumeAfterDelegation).toHaveBeenCalledTimes(1) + expect(emitSpy).toHaveBeenCalledWith(RooCodeEventName.TaskDelegationResumed, "parent-rpd04", "child-rpd04") + }) + it("handles empty history gracefully when injecting synthetic messages", async () => { const provider = { contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index 76cde6d386..4b04fb5bbb 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -9,9 +9,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { const providerEmit = vi.fn() const parentTask = { taskId: "parent-1", emit: vi.fn() } as any + const childStart = vi.fn() const updateTaskHistory = vi.fn() const removeClineFromStack = vi.fn().mockResolvedValue(undefined) - const createTask = vi.fn().mockResolvedValue({ taskId: "child-1" }) + const createTask = vi.fn().mockResolvedValue({ taskId: "child-1", start: childStart }) const handleModeSwitch = vi.fn().mockResolvedValue(undefined) const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { if (id === "parent-1") { @@ -62,10 +63,11 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { // Invariant: parent closed before child creation expect(removeClineFromStack).toHaveBeenCalledTimes(1) - // Child task is created with initialStatus: "active" to avoid race conditions + // Child task is created with startTask: false and initialStatus: "active" expect(createTask).toHaveBeenCalledWith("Do something", undefined, parentTask, { initialTodos: [], initialStatus: "active", + startTask: false, }) // Metadata persistence - parent gets "delegated" status (child status is set at creation via initialStatus) @@ -83,10 +85,61 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }), ) + // child.start() must be called AFTER parent metadata is persisted + expect(childStart).toHaveBeenCalledTimes(1) + // Event emission (provider-level) expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1") // Mode switch expect(handleModeSwitch).toHaveBeenCalledWith("code") }) + + it("calls child.start() only after parent metadata is persisted (no race condition)", async () => { + const callOrder: string[] = [] + + const parentTask = { taskId: "parent-1", emit: vi.fn() } as any + const childStart = vi.fn(() => callOrder.push("child.start")) + + const updateTaskHistory = vi.fn(async () => { + callOrder.push("updateTaskHistory") + }) + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + const createTask = vi.fn(async () => { + callOrder.push("createTask") + return { taskId: "child-1", start: childStart } + }) + const handleModeSwitch = vi.fn().mockResolvedValue(undefined) + const getTaskWithId = vi.fn().mockResolvedValue({ + historyItem: { + id: "parent-1", + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + childIds: [], + }, + }) + + const provider = { + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack, + createTask, + getTaskWithId, + updateTaskHistory, + handleModeSwitch, + log: vi.fn(), + } as unknown as ClineProvider + + await (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + + // Verify ordering: createTask → updateTaskHistory → child.start + expect(callOrder).toEqual(["createTask", "updateTaskHistory", "child.start"]) + }) }) diff --git a/src/api/index.ts b/src/api/index.ts index 30119b7dc7..53aff562cf 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,14 +1,14 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import type { ProviderSettings, ModelInfo } from "@roo-code/types" +import { isRetiredProvider, type ProviderSettings, type ModelInfo } from "@roo-code/types" import { ApiStream } from "./transform/stream" import { AnthropicHandler, AwsBedrockHandler, - CerebrasHandler, + AzureHandler, OpenRouterHandler, VertexHandler, AnthropicVertexHandler, @@ -21,24 +21,16 @@ import { MoonshotHandler, MistralHandler, VsCodeLmHandler, - UnboundHandler, RequestyHandler, FakeAIHandler, XAIHandler, - GroqHandler, - HuggingFaceHandler, - ChutesHandler, LiteLLMHandler, QwenCodeHandler, SambaNovaHandler, - IOIntelligenceHandler, - DoubaoHandler, ZAiHandler, FireworksHandler, RooHandler, - FeatherlessHandler, VercelAiGatewayHandler, - DeepInfraHandler, MiniMaxHandler, BasetenHandler, } from "./providers" @@ -51,16 +43,13 @@ export interface SingleCompletionHandler { export interface ApiHandlerCreateMessageMetadata { /** * Task ID used for tracking and provider-specific features: - * - DeepInfra: Used as prompt_cache_key for caching * - Roo: Sent as X-Roo-Task-ID header * - Requesty: Sent as trace_id - * - Unbound: Sent in unbound_metadata */ taskId: string /** * Current mode slug for provider-specific tracking: * - Requesty: Sent in extra metadata - * - Unbound: Sent in unbound_metadata */ mode?: string suppressPreviousResponseId?: boolean @@ -117,14 +106,31 @@ export interface ApiHandler { * @returns A promise resolving to the token count */ countTokens(content: Array): Promise + + /** + * Indicates whether this provider uses the Vercel AI SDK for streaming. + * AI SDK providers handle reasoning blocks differently and need to preserve + * them in conversation history for proper round-tripping. + * + * @returns true if the provider uses AI SDK, false otherwise + */ + isAiSdkProvider(): boolean } export function buildApiHandler(configuration: ProviderSettings): ApiHandler { const { apiProvider, ...options } = configuration + if (apiProvider && isRetiredProvider(apiProvider)) { + throw new Error( + `Sorry, this provider is no longer supported. We saw very few Roo users actually using it and we need to reduce the surface area of our codebase so we can keep shipping fast and serving our community well in this space. It was a really hard decision but it lets us focus on what matters most to you. It sucks, we know.\n\nPlease select a different provider in your API profile settings.`, + ) + } + switch (apiProvider) { case "anthropic": return new AnthropicHandler(options) + case "azure": + return new AzureHandler(options) case "openrouter": return new OpenRouterHandler(options) case "bedrock": @@ -147,8 +153,6 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new OpenAiNativeHandler(options) case "deepseek": return new DeepSeekHandler(options) - case "doubao": - return new DoubaoHandler(options) case "qwen-code": return new QwenCodeHandler(options) case "moonshot": @@ -157,40 +161,24 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new VsCodeLmHandler(options) case "mistral": return new MistralHandler(options) - case "unbound": - return new UnboundHandler(options) case "requesty": return new RequestyHandler(options) case "fake-ai": return new FakeAIHandler(options) case "xai": return new XAIHandler(options) - case "groq": - return new GroqHandler(options) - case "deepinfra": - return new DeepInfraHandler(options) - case "huggingface": - return new HuggingFaceHandler(options) - case "chutes": - return new ChutesHandler(options) case "litellm": return new LiteLLMHandler(options) - case "cerebras": - return new CerebrasHandler(options) case "sambanova": return new SambaNovaHandler(options) case "zai": return new ZAiHandler(options) case "fireworks": return new FireworksHandler(options) - case "io-intelligence": - return new IOIntelligenceHandler(options) case "roo": // Never throw exceptions from provider constructors // The provider-proxy server will handle authentication and return appropriate error codes return new RooHandler(options) - case "featherless": - return new FeatherlessHandler(options) case "vercel-ai-gateway": return new VercelAiGatewayHandler(options) case "minimax": diff --git a/src/api/providers/__tests__/anthropic-vertex.spec.ts b/src/api/providers/__tests__/anthropic-vertex.spec.ts index 3d9798fde9..3341a0f584 100644 --- a/src/api/providers/__tests__/anthropic-vertex.spec.ts +++ b/src/api/providers/__tests__/anthropic-vertex.spec.ts @@ -1,57 +1,97 @@ // npx vitest run src/api/providers/__tests__/anthropic-vertex.spec.ts -import { Anthropic } from "@anthropic-ai/sdk" -import { AnthropicVertex } from "@anthropic-ai/vertex-sdk" +import { AnthropicVertexHandler } from "../anthropic-vertex" +import { ApiHandlerOptions } from "../../../shared/api" import { VERTEX_1M_CONTEXT_MODEL_IDS } from "@roo-code/types" import { ApiStreamChunk } from "../../transform/stream" -import { AnthropicVertexHandler } from "../anthropic-vertex" - -vitest.mock("@anthropic-ai/vertex-sdk", () => ({ - AnthropicVertex: vitest.fn().mockImplementation(() => ({ - messages: { - create: vitest.fn().mockImplementation(async (options) => { - if (!options.stream) { - return { - id: "test-completion", - content: [{ type: "text", text: "Test response" }], - role: "assistant", - model: options.model, - usage: { - input_tokens: 10, - output_tokens: 5, - }, - } - } - return { - async *[Symbol.asyncIterator]() { - yield { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 5, - }, - }, - } - yield { - type: "content_block_start", - content_block: { - type: "text", - text: "Test response", - }, - } - }, - } - }), +// Mock TelemetryService +vitest.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: vitest.fn(), }, - })), + }, })) -describe("VertexHandler", () => { +// Mock the AI SDK +const mockStreamText = vitest.fn() +const mockGenerateText = vitest.fn() + +vitest.mock("ai", () => ({ + streamText: (...args: any[]) => mockStreamText(...args), + generateText: (...args: any[]) => mockGenerateText(...args), + tool: vitest.fn(), + jsonSchema: vitest.fn(), + ToolSet: {}, +})) + +// Mock the @ai-sdk/google-vertex/anthropic provider +const mockCreateVertexAnthropic = vitest.fn() + +vitest.mock("@ai-sdk/google-vertex/anthropic", () => ({ + createVertexAnthropic: (...args: any[]) => mockCreateVertexAnthropic(...args), +})) + +// Mock ai-sdk transform utilities +vitest.mock("../../transform/ai-sdk", () => ({ + convertToAiSdkMessages: vitest.fn().mockReturnValue([{ role: "user", content: [{ type: "text", text: "Hello" }] }]), + convertToolsForAiSdk: vitest.fn().mockReturnValue(undefined), + processAiSdkStreamPart: vitest.fn().mockImplementation(function* (part: any) { + if (part.type === "text-delta") { + yield { type: "text", text: part.text } + } else if (part.type === "reasoning-delta") { + yield { type: "reasoning", text: part.text } + } else if (part.type === "tool-input-start") { + yield { type: "tool_call_start", id: part.id, name: part.toolName } + } else if (part.type === "tool-input-delta") { + yield { type: "tool_call_delta", id: part.id, delta: part.delta } + } else if (part.type === "tool-input-end") { + yield { type: "tool_call_end", id: part.id } + } + }), + mapToolChoice: vitest.fn().mockReturnValue(undefined), + handleAiSdkError: vitest.fn().mockImplementation((error: any) => error), +})) + +// Import mocked modules +import { convertToAiSdkMessages, convertToolsForAiSdk, mapToolChoice } from "../../transform/ai-sdk" +import { Anthropic } from "@anthropic-ai/sdk" + +// Helper: create a mock provider function +function createMockProviderFn() { + const providerFn = vitest.fn().mockReturnValue("mock-model") + return providerFn +} + +// Helper: create a mock streamText result +function createMockStreamResult( + parts: any[], + usage?: { inputTokens: number; outputTokens: number }, + providerMetadata?: Record, +) { + return { + fullStream: (async function* () { + for (const part of parts) { + yield part + } + })(), + usage: Promise.resolve(usage ?? { inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve(providerMetadata ?? {}), + } +} + +describe("AnthropicVertexHandler", () => { let handler: AnthropicVertexHandler + let mockProviderFn: ReturnType + + beforeEach(() => { + mockProviderFn = createMockProviderFn() + mockCreateVertexAnthropic.mockReturnValue(mockProviderFn) + vitest.clearAllMocks() + }) describe("constructor", () => { it("should initialize with provided config for Claude", () => { @@ -61,10 +101,85 @@ describe("VertexHandler", () => { vertexRegion: "us-central1", }) - expect(AnthropicVertex).toHaveBeenCalledWith({ - projectId: "test-project", - region: "us-central1", + expect(mockCreateVertexAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + project: "test-project", + location: "us-central1", + }), + ) + }) + + it("should use JSON credentials when provided", () => { + const credentials = { client_email: "test@test.com", private_key: "test-key" } + handler = new AnthropicVertexHandler({ + apiModelId: "claude-3-5-sonnet-v2@20241022", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + vertexJsonCredentials: JSON.stringify(credentials), }) + + expect(mockCreateVertexAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + googleAuthOptions: { credentials }, + }), + ) + }) + + it("should use key file when provided", () => { + handler = new AnthropicVertexHandler({ + apiModelId: "claude-3-5-sonnet-v2@20241022", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + vertexKeyFile: "/path/to/key.json", + }) + + expect(mockCreateVertexAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + googleAuthOptions: { keyFile: "/path/to/key.json" }, + }), + ) + }) + + it("should use default values when project/region not provided", () => { + handler = new AnthropicVertexHandler({ + apiModelId: "claude-3-5-sonnet-v2@20241022", + }) + + expect(mockCreateVertexAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + project: "not-provided", + location: "us-east5", + }), + ) + }) + + it("should include anthropic-beta header when 1M context is enabled", () => { + handler = new AnthropicVertexHandler({ + apiModelId: VERTEX_1M_CONTEXT_MODEL_IDS[0], + vertexProjectId: "test-project", + vertexRegion: "us-central1", + vertex1MContext: true, + }) + + expect(mockCreateVertexAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + "anthropic-beta": "context-1m-2025-08-07", + }), + }), + ) + }) + + it("should not include anthropic-beta header when 1M context is disabled", () => { + handler = new AnthropicVertexHandler({ + apiModelId: VERTEX_1M_CONTEXT_MODEL_IDS[0], + vertexProjectId: "test-project", + vertexRegion: "us-central1", + vertex1MContext: false, + }) + + const calledHeaders = mockCreateVertexAnthropic.mock.calls[0][0].headers + expect(calledHeaders["anthropic-beta"]).toBeUndefined() }) }) @@ -82,57 +197,21 @@ describe("VertexHandler", () => { const systemPrompt = "You are a helpful assistant" - it("should handle streaming responses correctly for Claude", async () => { + beforeEach(() => { handler = new AnthropicVertexHandler({ apiModelId: "claude-3-5-sonnet-v2@20241022", vertexProjectId: "test-project", vertexRegion: "us-central1", }) + }) - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - }, - }, - }, - { - type: "content_block_start", - index: 0, - content_block: { - type: "text", - text: "Hello", - }, - }, - { - type: "content_block_delta", - delta: { - type: "text_delta", - text: " world!", - }, - }, - { - type: "message_delta", - usage: { - output_tokens: 5, - }, - }, + it("should handle streaming responses correctly for Claude", async () => { + const streamParts = [ + { type: "text-delta", text: "Hello" }, + { type: "text-delta", text: " world!" }, ] - // Setup async iterator for mock stream - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate + mockStreamText.mockReturnValue(createMockStreamResult(streamParts, { inputTokens: 10, outputTokens: 5 })) const stream = handler.createMessage(systemPrompt, mockMessages) const chunks: ApiStreamChunk[] = [] @@ -141,318 +220,131 @@ describe("VertexHandler", () => { chunks.push(chunk) } - expect(chunks.length).toBe(4) - expect(chunks[0]).toEqual({ + // Text chunks from processAiSdkStreamPart + final usage + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(2) + expect(textChunks[0]).toEqual({ type: "text", text: "Hello" }) + expect(textChunks[1]).toEqual({ type: "text", text: " world!" }) + + // Usage chunk at the end + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0]).toMatchObject({ type: "usage", inputTokens: 10, - outputTokens: 0, - }) - expect(chunks[1]).toEqual({ - type: "text", - text: "Hello", - }) - expect(chunks[2]).toEqual({ - type: "text", - text: " world!", - }) - expect(chunks[3]).toEqual({ - type: "usage", - inputTokens: 0, outputTokens: 5, }) - expect(mockCreate).toHaveBeenCalledWith( + // Verify streamText was called with correct params + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - model: "claude-3-5-sonnet-v2@20241022", - max_tokens: 8192, - temperature: 0, - thinking: undefined, - system: [ - { - type: "text", - text: "You are a helpful assistant", - cache_control: { type: "ephemeral" }, - }, - ], - messages: [ - { - role: "user", - content: [ - { - type: "text", - text: "Hello", - cache_control: { type: "ephemeral" }, - }, - ], - }, - { - role: "assistant", - content: "Hi there!", - }, - ], - stream: true, - // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS) - tools: expect.any(Array), - tool_choice: expect.any(Object), + model: "mock-model", + system: systemPrompt, }), - undefined, ) }) - it("should handle multiple content blocks with line breaks for Claude", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) + it("should call convertToAiSdkMessages with the messages", async () => { + mockStreamText.mockReturnValue(createMockStreamResult([])) - const mockStream = [ + const stream = handler.createMessage(systemPrompt, mockMessages) + for await (const _chunk of stream) { + // consume + } + + expect(convertToAiSdkMessages).toHaveBeenCalledWith(mockMessages) + }) + + it("should pass tools through AI SDK conversion pipeline", async () => { + mockStreamText.mockReturnValue(createMockStreamResult([])) + + const mockTools = [ { - type: "content_block_start", - index: 0, - content_block: { - type: "text", - text: "First line", - }, - }, - { - type: "content_block_start", - index: 1, - content_block: { - type: "text", - text: "Second line", + type: "function" as const, + function: { + name: "get_weather", + description: "Get the current weather", + parameters: { + type: "object", + properties: { location: { type: "string" } }, + required: ["location"], + }, }, }, ] - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, + const stream = handler.createMessage(systemPrompt, mockMessages, { + taskId: "test-task", + tools: mockTools, + }) + + for await (const _chunk of stream) { + // consume } - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate - - const stream = handler.createMessage(systemPrompt, mockMessages) - const chunks: ApiStreamChunk[] = [] - - for await (const chunk of stream) { - chunks.push(chunk) - } - - expect(chunks.length).toBe(3) - expect(chunks[0]).toEqual({ - type: "text", - text: "First line", - }) - expect(chunks[1]).toEqual({ - type: "text", - text: "\n", - }) - expect(chunks[2]).toEqual({ - type: "text", - text: "Second line", - }) + expect(convertToolsForAiSdk).toHaveBeenCalled() }) it("should handle API errors for Claude", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - const mockError = new Error("Vertex API error") - const mockCreate = vitest.fn().mockRejectedValue(mockError) - ;(handler["client"].messages as any).create = mockCreate + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "" } + throw mockError + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) const stream = handler.createMessage(systemPrompt, mockMessages) await expect(async () => { for await (const _chunk of stream) { - // Should throw before yielding any chunks + // Should throw before yielding meaningful chunks } - }).rejects.toThrow("Vertex API error") + }).rejects.toThrow() }) - it("should handle prompt caching for supported models for Claude", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - cache_creation_input_tokens: 3, - cache_read_input_tokens: 2, + it("should handle cache-related usage metrics from providerMetadata", async () => { + mockStreamText.mockReturnValue( + createMockStreamResult( + [{ type: "text-delta", text: "Hello" }], + { inputTokens: 10, outputTokens: 5 }, + { + anthropic: { + cacheCreationInputTokens: 3, + cacheReadInputTokens: 2, }, }, - }, - { - type: "content_block_start", - index: 0, - content_block: { - type: "text", - text: "Hello", - }, - }, - { - type: "content_block_delta", - delta: { - type: "text_delta", - text: " world!", - }, - }, - { - type: "message_delta", - usage: { - output_tokens: 5, - }, - }, - ] - - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate - - const stream = handler.createMessage(systemPrompt, [ - { - role: "user", - content: "First message", - }, - { - role: "assistant", - content: "Response", - }, - { - role: "user", - content: "Second message", - }, - ]) + ), + ) + const stream = handler.createMessage(systemPrompt, mockMessages) const chunks: ApiStreamChunk[] = [] + for await (const chunk of stream) { chunks.push(chunk) } - // Verify usage information - const usageChunks = chunks.filter((chunk) => chunk.type === "usage") - expect(usageChunks).toHaveLength(2) - expect(usageChunks[0]).toEqual({ + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0]).toMatchObject({ type: "usage", inputTokens: 10, - outputTokens: 0, + outputTokens: 5, cacheWriteTokens: 3, cacheReadTokens: 2, }) - expect(usageChunks[1]).toEqual({ - type: "usage", - inputTokens: 0, - outputTokens: 5, - }) - - // Verify text content - const textChunks = chunks.filter((chunk) => chunk.type === "text") - expect(textChunks).toHaveLength(2) - expect(textChunks[0].text).toBe("Hello") - expect(textChunks[1].text).toBe(" world!") - - // Verify cache control was added correctly - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - system: [ - { - type: "text", - text: "You are a helpful assistant", - cache_control: { type: "ephemeral" }, - }, - ], - messages: [ - expect.objectContaining({ - role: "user", - content: [ - { - type: "text", - text: "First message", - cache_control: { type: "ephemeral" }, - }, - ], - }), - expect.objectContaining({ - role: "assistant", - content: "Response", - }), - expect.objectContaining({ - role: "user", - content: [ - { - type: "text", - text: "Second message", - cache_control: { type: "ephemeral" }, - }, - ], - }), - ], - }), - undefined, - ) }) - it("should handle cache-related usage metrics for Claude", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - cache_creation_input_tokens: 5, - cache_read_input_tokens: 3, - }, - }, - }, - { - type: "content_block_start", - index: 0, - content_block: { - type: "text", - text: "Hello", - }, - }, + it("should handle reasoning/thinking stream events", async () => { + const streamParts = [ + { type: "reasoning-delta", text: "Let me think about this..." }, + { type: "reasoning-delta", text: " I need to consider all options." }, + { type: "text-delta", text: "Here's my answer:" }, ] - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate + mockStreamText.mockReturnValue(createMockStreamResult(streamParts)) const stream = handler.createMessage(systemPrompt, mockMessages) const chunks: ApiStreamChunk[] = [] @@ -461,368 +353,126 @@ describe("VertexHandler", () => { chunks.push(chunk) } - // Check for cache-related metrics in usage chunk - const usageChunks = chunks.filter((chunk) => chunk.type === "usage") - expect(usageChunks.length).toBeGreaterThan(0) - expect(usageChunks[0]).toHaveProperty("cacheWriteTokens", 5) - expect(usageChunks[0]).toHaveProperty("cacheReadTokens", 3) - }) - }) - - describe("thinking functionality", () => { - const mockMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello", - }, - ] - - const systemPrompt = "You are a helpful assistant" - - it("should handle thinking content blocks and deltas for Claude", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - }, - }, - }, - { - type: "content_block_start", - index: 0, - content_block: { - type: "thinking", - thinking: "Let me think about this...", - }, - }, - { - type: "content_block_delta", - delta: { - type: "thinking_delta", - thinking: " I need to consider all options.", - }, - }, - { - type: "content_block_start", - index: 1, - content_block: { - type: "text", - text: "Here's my answer:", - }, - }, - ] - - // Setup async iterator for mock stream - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate - - const stream = handler.createMessage(systemPrompt, mockMessages) - const chunks: ApiStreamChunk[] = [] - - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Verify thinking content is processed correctly - const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") + const reasoningChunks = chunks.filter((c) => c.type === "reasoning") expect(reasoningChunks).toHaveLength(2) expect(reasoningChunks[0].text).toBe("Let me think about this...") expect(reasoningChunks[1].text).toBe(" I need to consider all options.") - // Verify text content is processed correctly - const textChunks = chunks.filter((chunk) => chunk.type === "text") - expect(textChunks).toHaveLength(2) // One for the text block, one for the newline - expect(textChunks[0].text).toBe("\n") - expect(textChunks[1].text).toBe("Here's my answer:") + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Here's my answer:") }) - it("should handle multiple thinking blocks with line breaks for Claude", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockStream = [ + it("should capture thought signature from stream events", async () => { + const streamParts = [ { - type: "content_block_start", - index: 0, - content_block: { - type: "thinking", - thinking: "First thinking block", - }, - }, - { - type: "content_block_start", - index: 1, - content_block: { - type: "thinking", - thinking: "Second thinking block", + type: "reasoning-delta", + text: "thinking...", + providerMetadata: { + anthropic: { signature: "test-signature-abc123" }, }, }, + { type: "text-delta", text: "answer" }, ] - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate + mockStreamText.mockReturnValue(createMockStreamResult(streamParts)) const stream = handler.createMessage(systemPrompt, mockMessages) - const chunks: ApiStreamChunk[] = [] - - for await (const chunk of stream) { - chunks.push(chunk) + for await (const _chunk of stream) { + // consume } - expect(chunks.length).toBe(3) - expect(chunks[0]).toEqual({ - type: "reasoning", - text: "First thinking block", - }) - expect(chunks[1]).toEqual({ - type: "reasoning", - text: "\n", - }) - expect(chunks[2]).toEqual({ - type: "reasoning", - text: "Second thinking block", + expect(handler.getThoughtSignature()).toBe("test-signature-abc123") + }) + + it("should capture redacted thinking blocks from stream events", async () => { + const streamParts = [ + { + type: "reasoning-delta", + text: "", + providerMetadata: { + anthropic: { redactedData: "encrypted-redacted-data" }, + }, + }, + { type: "text-delta", text: "answer" }, + ] + + mockStreamText.mockReturnValue(createMockStreamResult(streamParts)) + + const stream = handler.createMessage(systemPrompt, mockMessages) + for await (const _chunk of stream) { + // consume + } + + const redactedBlocks = handler.getRedactedThinkingBlocks() + expect(redactedBlocks).toHaveLength(1) + expect(redactedBlocks![0]).toEqual({ + type: "redacted_thinking", + data: "encrypted-redacted-data", }) }) - it("should filter out internal reasoning blocks before sending to API", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", + it("should configure thinking providerOptions for thinking models", async () => { + const thinkingHandler = new AnthropicVertexHandler({ + apiModelId: "claude-3-7-sonnet@20250219:thinking", vertexProjectId: "test-project", vertexRegion: "us-central1", + modelMaxTokens: 16384, + modelMaxThinkingTokens: 4096, }) - const mockCreate = vitest.fn().mockImplementation(async (options) => { - return { - async *[Symbol.asyncIterator]() { - yield { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - }, - }, - } - yield { - type: "content_block_start", - index: 0, - content_block: { - type: "text", - text: "Response", - }, - } - }, - } - }) - ;(handler["client"].messages as any).create = mockCreate + mockStreamText.mockReturnValue(createMockStreamResult([])) - // Messages with internal reasoning blocks (from stored conversation history) - const messagesWithReasoning: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello", - }, - { - role: "assistant", - content: [ - { - type: "reasoning" as any, - text: "This is internal reasoning that should be filtered", - }, - { - type: "text", - text: "This is the response", - }, - ], - }, - { - role: "user", - content: "Continue", - }, - ] - - const stream = handler.createMessage(systemPrompt, messagesWithReasoning) - const chunks: ApiStreamChunk[] = [] - - for await (const chunk of stream) { - chunks.push(chunk) + const stream = thinkingHandler.createMessage(systemPrompt, [{ role: "user", content: "Hello" }]) + for await (const _chunk of stream) { + // consume } - // Verify the API was called with filtered messages (no reasoning blocks) - const calledMessages = mockCreate.mock.calls[0][0].messages - expect(calledMessages).toHaveLength(3) - - // Check user message 1 - expect(calledMessages[0]).toMatchObject({ - role: "user", - }) - - // Check assistant message - should have reasoning block filtered out - const assistantMessage = calledMessages.find((m: any) => m.role === "assistant") - expect(assistantMessage).toBeDefined() - expect(assistantMessage.content).toEqual([{ type: "text", text: "This is the response" }]) - - // Verify reasoning blocks were NOT sent to the API - expect(assistantMessage.content).not.toContainEqual(expect.objectContaining({ type: "reasoning" })) - }) - - it("should filter empty messages after removing all reasoning blocks", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockCreate = vitest.fn().mockImplementation(async (options) => { - return { - async *[Symbol.asyncIterator]() { - yield { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - }, + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: expect.objectContaining({ + anthropic: expect.objectContaining({ + thinking: { + type: "enabled", + budgetTokens: 4096, }, - } - }, - } - }) - ;(handler["client"].messages as any).create = mockCreate - - // Message with only reasoning content (should be completely filtered) - const messagesWithOnlyReasoning: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello", - }, - { - role: "assistant", - content: [ - { - type: "reasoning" as any, - text: "Only reasoning, no actual text", - }, - ], - }, - { - role: "user", - content: "Continue", - }, - ] - - const stream = handler.createMessage(systemPrompt, messagesWithOnlyReasoning) - const chunks: ApiStreamChunk[] = [] - - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Verify empty message was filtered out - const calledMessages = mockCreate.mock.calls[0][0].messages - expect(calledMessages).toHaveLength(2) // Only the two user messages - expect(calledMessages.every((m: any) => m.role === "user")).toBe(true) + }), + }), + }), + ) }) }) describe("completePrompt", () => { - it("should complete prompt successfully for Claude", async () => { + beforeEach(() => { handler = new AnthropicVertexHandler({ apiModelId: "claude-3-5-sonnet-v2@20241022", vertexProjectId: "test-project", vertexRegion: "us-central1", }) + }) + + it("should complete prompt successfully for Claude", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test response", + }) const result = await handler.completePrompt("Test prompt") expect(result).toBe("Test response") - expect(handler["client"].messages.create).toHaveBeenCalledWith({ - model: "claude-3-5-sonnet-v2@20241022", - max_tokens: 8192, - temperature: 0, - messages: [ - { - role: "user", - content: [{ type: "text", text: "Test prompt", cache_control: { type: "ephemeral" } }], - }, - ], - stream: false, - }) - }) - it("should handle API errors for Claude", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockError = new Error("Vertex API error") - const mockCreate = vitest.fn().mockRejectedValue(mockError) - ;(handler["client"].messages as any).create = mockCreate - - await expect(handler.completePrompt("Test prompt")).rejects.toThrow( - "Vertex completion error: Vertex API error", + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + model: "mock-model", + prompt: "Test prompt", + }), ) }) - it("should handle non-text content for Claude", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) + it("should handle API errors for Claude", async () => { + const mockError = new Error("Vertex API error") + mockGenerateText.mockRejectedValue(mockError) - const mockCreate = vitest.fn().mockResolvedValue({ - content: [{ type: "image" }], - }) - ;(handler["client"].messages as any).create = mockCreate - - const result = await handler.completePrompt("Test prompt") - expect(result).toBe("") - }) - - it("should handle empty response for Claude", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockCreate = vitest.fn().mockResolvedValue({ - content: [{ type: "text", text: "" }], - }) - ;(handler["client"].messages as any).create = mockCreate - - const result = await handler.completePrompt("Test prompt") - expect(result).toBe("") + await expect(handler.completePrompt("Test prompt")).rejects.toThrow() }) }) @@ -928,104 +578,6 @@ describe("VertexHandler", () => { }) }) - describe("1M context beta header", () => { - const mockMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello", - }, - ] - - const systemPrompt = "You are a helpful assistant" - - it("should include anthropic-beta header when 1M context is enabled", async () => { - const handler = new AnthropicVertexHandler({ - apiModelId: VERTEX_1M_CONTEXT_MODEL_IDS[0], - vertexProjectId: "test-project", - vertexRegion: "us-central1", - vertex1MContext: true, - }) - - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - }, - }, - }, - ] - - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate - - const stream = handler.createMessage(systemPrompt, mockMessages) - - for await (const _chunk of stream) { - // Just consume - } - - // Verify the API was called with the beta header - expect(mockCreate).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: { "anthropic-beta": "context-1m-2025-08-07" }, - }), - ) - }) - - it("should not include anthropic-beta header when 1M context is disabled", async () => { - const handler = new AnthropicVertexHandler({ - apiModelId: VERTEX_1M_CONTEXT_MODEL_IDS[0], - vertexProjectId: "test-project", - vertexRegion: "us-central1", - vertex1MContext: false, - }) - - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - }, - }, - }, - ] - - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate - - const stream = handler.createMessage(systemPrompt, mockMessages) - - for await (const _chunk of stream) { - // Just consume - } - - // Verify the API was called without the beta header - expect(mockCreate).toHaveBeenCalledWith(expect.anything(), undefined) - }) - }) - describe("thinking model configuration", () => { it("should configure thinking for models with :thinking suffix", () => { const thinkingHandler = new AnthropicVertexHandler({ @@ -1040,7 +592,7 @@ describe("VertexHandler", () => { expect(modelInfo.id).toBe("claude-3-7-sonnet@20250219") expect(modelInfo.reasoningBudget).toBe(4096) - expect(modelInfo.temperature).toBe(1.0) // Thinking requires temperature 1.0. + expect(modelInfo.temperature).toBe(1.0) }) it("should calculate thinking budget correctly", () => { @@ -1076,7 +628,7 @@ describe("VertexHandler", () => { expect(handlerWithSmallMaxTokens.getModel().reasoningBudget).toBe(1024) }) - it("should pass thinking configuration to API", async () => { + it("should pass thinking configuration to API via providerOptions", async () => { const thinkingHandler = new AnthropicVertexHandler({ apiModelId: "claude-3-7-sonnet@20250219:thinking", vertexProjectId: "test-project", @@ -1085,336 +637,87 @@ describe("VertexHandler", () => { modelMaxThinkingTokens: 4096, }) - const mockCreate = vitest.fn().mockImplementation(async (options) => { - if (!options.stream) { - return { - id: "test-completion", - content: [{ type: "text", text: "Test response" }], - role: "assistant", - model: options.model, - usage: { input_tokens: 10, output_tokens: 5 }, - } - } - return { - async *[Symbol.asyncIterator]() { - yield { type: "message_start", message: { usage: { input_tokens: 10, output_tokens: 5 } } } - }, - } - }) - ;(thinkingHandler["client"].messages as any).create = mockCreate + mockStreamText.mockReturnValue(createMockStreamResult([])) - await thinkingHandler - .createMessage("You are a helpful assistant", [{ role: "user", content: "Hello" }]) - .next() + const stream = thinkingHandler.createMessage("You are a helpful assistant", [ + { role: "user", content: "Hello" }, + ]) - expect(mockCreate).toHaveBeenCalledWith( + for await (const _chunk of stream) { + // consume + } + + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - thinking: { type: "enabled", budget_tokens: 4096 }, - temperature: 1.0, // Thinking requires temperature 1.0 + temperature: 1.0, + providerOptions: expect.objectContaining({ + anthropic: expect.objectContaining({ + thinking: { + type: "enabled", + budgetTokens: 4096, + }, + }), + }), }), - undefined, ) }) }) - describe("native tool calling", () => { - const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [{ type: "text" as const, text: "What's the weather in London?" }], - }, - ] - - const mockTools = [ - { - type: "function" as const, - function: { - name: "get_weather", - description: "Get the current weather", - parameters: { - type: "object", - properties: { - location: { type: "string" }, - }, - required: ["location"], - }, - }, - }, - ] - - it("should include tools in request when native protocol is used", async () => { + describe("isAiSdkProvider", () => { + it("should return true", () => { handler = new AnthropicVertexHandler({ apiModelId: "claude-3-5-sonnet-v2@20241022", vertexProjectId: "test-project", vertexRegion: "us-central1", }) - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - }, - }, - }, - ] + expect(handler.isAiSdkProvider()).toBe(true) + }) + }) - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate - - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: mockTools, + describe("thought signature and redacted thinking", () => { + beforeEach(() => { + handler = new AnthropicVertexHandler({ + apiModelId: "claude-3-5-sonnet-v2@20241022", + vertexProjectId: "test-project", + vertexRegion: "us-central1", }) + }) - // Consume the stream to trigger the API call - for await (const _chunk of stream) { - // Just consume - } + it("should return undefined for thought signature before any request", () => { + expect(handler.getThoughtSignature()).toBeUndefined() + }) - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - name: "get_weather", - description: "Get the current weather", - input_schema: expect.objectContaining({ - type: "object", - properties: expect.objectContaining({ - location: { type: "string" }, - }), - }), - }), - ]), - tool_choice: { type: "auto", disable_parallel_tool_use: false }, - }), - undefined, + it("should return undefined for redacted thinking blocks before any request", () => { + expect(handler.getRedactedThinkingBlocks()).toBeUndefined() + }) + + it("should reset thought signature on each createMessage call", async () => { + // First call with signature + mockStreamText.mockReturnValue( + createMockStreamResult([ + { + type: "reasoning-delta", + text: "thinking", + providerMetadata: { anthropic: { signature: "sig-1" } }, + }, + ]), ) - }) - it("should include tools when tools are provided", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - }, - }, - }, - ] - - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, + const stream1 = handler.createMessage("test", [{ role: "user", content: "Hello" }]) + for await (const _chunk of stream1) { + // consume } + expect(handler.getThoughtSignature()).toBe("sig-1") - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate + // Second call without signature + mockStreamText.mockReturnValue(createMockStreamResult([{ type: "text-delta", text: "just text" }])) - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: mockTools, - }) - - // Consume the stream to trigger the API call - for await (const _chunk of stream) { - // Just consume + const stream2 = handler.createMessage("test", [{ role: "user", content: "Hello again" }]) + for await (const _chunk of stream2) { + // consume } - - // Tool calling is request-driven: if tools are provided, we should include them. - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - name: "get_weather", - }), - ]), - }), - undefined, - ) - }) - - it("should handle tool_use blocks in stream and emit tool_call_partial", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }, - }, - { - type: "content_block_start", - index: 0, - content_block: { - type: "tool_use", - id: "toolu_123", - name: "get_weather", - }, - }, - ] - - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate - - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: mockTools, - }) - - const chunks: ApiStreamChunk[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Find the tool_call_partial chunk - const toolCallChunk = chunks.find((chunk) => chunk.type === "tool_call_partial") - expect(toolCallChunk).toBeDefined() - expect(toolCallChunk).toEqual({ - type: "tool_call_partial", - index: 0, - id: "toolu_123", - name: "get_weather", - arguments: undefined, - }) - }) - - it("should handle input_json_delta in stream and emit tool_call_partial arguments", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }, - }, - { - type: "content_block_start", - index: 0, - content_block: { - type: "tool_use", - id: "toolu_123", - name: "get_weather", - }, - }, - { - type: "content_block_delta", - index: 0, - delta: { - type: "input_json_delta", - partial_json: '{"location":', - }, - }, - { - type: "content_block_delta", - index: 0, - delta: { - type: "input_json_delta", - partial_json: '"London"}', - }, - }, - { - type: "content_block_stop", - index: 0, - }, - ] - - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate - - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: mockTools, - }) - - const chunks: ApiStreamChunk[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Find the tool_call_partial chunks - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") - expect(toolCallChunks).toHaveLength(3) - - // First chunk has id and name - expect(toolCallChunks[0]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "toolu_123", - name: "get_weather", - arguments: undefined, - }) - - // Subsequent chunks have arguments - expect(toolCallChunks[1]).toEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: '{"location":', - }) - - expect(toolCallChunks[2]).toEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: '"London"}', - }) + expect(handler.getThoughtSignature()).toBeUndefined() }) }) }) diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts index 7a107edbc8..b80dc205eb 100644 --- a/src/api/providers/__tests__/anthropic.spec.ts +++ b/src/api/providers/__tests__/anthropic.spec.ts @@ -12,79 +12,80 @@ vitest.mock("@roo-code/telemetry", () => ({ }, })) -const mockCreate = vitest.fn() +// Mock the AI SDK +const mockStreamText = vitest.fn() +const mockGenerateText = vitest.fn() -vitest.mock("@anthropic-ai/sdk", () => { - const mockAnthropicConstructor = vitest.fn().mockImplementation(() => ({ - messages: { - create: mockCreate.mockImplementation(async (options) => { - if (!options.stream) { - return { - id: "test-completion", - content: [{ type: "text", text: "Test response" }], - role: "assistant", - model: options.model, - usage: { - input_tokens: 10, - output_tokens: 5, - }, - } - } - return { - async *[Symbol.asyncIterator]() { - yield { - type: "message_start", - message: { - usage: { - input_tokens: 100, - output_tokens: 50, - cache_creation_input_tokens: 20, - cache_read_input_tokens: 10, - }, - }, - } - yield { - type: "content_block_start", - index: 0, - content_block: { - type: "text", - text: "Hello", - }, - } - yield { - type: "content_block_delta", - delta: { - type: "text_delta", - text: " world", - }, - } - }, - } - }), - }, - })) +vitest.mock("ai", () => ({ + streamText: (...args: any[]) => mockStreamText(...args), + generateText: (...args: any[]) => mockGenerateText(...args), + tool: vitest.fn(), + jsonSchema: vitest.fn(), + ToolSet: {}, +})) - return { - Anthropic: mockAnthropicConstructor, - } -}) +// Mock the @ai-sdk/anthropic provider +const mockCreateAnthropic = vitest.fn() -// Import after mock +vitest.mock("@ai-sdk/anthropic", () => ({ + createAnthropic: (...args: any[]) => mockCreateAnthropic(...args), +})) + +// Mock ai-sdk transform utilities +vitest.mock("../../transform/ai-sdk", () => ({ + convertToAiSdkMessages: vitest.fn().mockReturnValue([{ role: "user", content: [{ type: "text", text: "Hello" }] }]), + convertToolsForAiSdk: vitest.fn().mockReturnValue(undefined), + processAiSdkStreamPart: vitest.fn().mockImplementation(function* (part: any) { + if (part.type === "text-delta") { + yield { type: "text", text: part.text } + } else if (part.type === "reasoning-delta") { + yield { type: "reasoning", text: part.text } + } else if (part.type === "tool-input-start") { + yield { type: "tool_call_start", id: part.id, name: part.toolName } + } else if (part.type === "tool-input-delta") { + yield { type: "tool_call_delta", id: part.id, delta: part.delta } + } else if (part.type === "tool-input-end") { + yield { type: "tool_call_end", id: part.id } + } + }), + mapToolChoice: vitest.fn().mockReturnValue(undefined), + handleAiSdkError: vitest.fn().mockImplementation((error: any) => error), +})) + +// Import mocked modules +import { convertToAiSdkMessages, convertToolsForAiSdk, mapToolChoice } from "../../transform/ai-sdk" import { Anthropic } from "@anthropic-ai/sdk" -const mockAnthropicConstructor = vitest.mocked(Anthropic) +// Helper: create a mock provider function +function createMockProviderFn() { + const providerFn = vitest.fn().mockReturnValue("mock-model") + return providerFn +} describe("AnthropicHandler", () => { let handler: AnthropicHandler let mockOptions: ApiHandlerOptions + let mockProviderFn: ReturnType beforeEach(() => { mockOptions = { apiKey: "test-api-key", apiModelId: "claude-3-5-sonnet-20241022", } + + mockProviderFn = createMockProviderFn() + mockCreateAnthropic.mockReturnValue(mockProviderFn) + handler = new AnthropicHandler(mockOptions) vitest.clearAllMocks() + + // Re-set mock defaults after clearAllMocks + mockCreateAnthropic.mockReturnValue(mockProviderFn) + vitest + .mocked(convertToAiSdkMessages) + .mockReturnValue([{ role: "user", content: [{ type: "text", text: "Hello" }] }]) + vitest.mocked(convertToolsForAiSdk).mockReturnValue(undefined) + vitest.mocked(mapToolChoice).mockReturnValue(undefined) }) describe("constructor", () => { @@ -93,13 +94,15 @@ describe("AnthropicHandler", () => { expect(handler.getModel().id).toBe(mockOptions.apiModelId) }) - it("should initialize with undefined API key", () => { - // The SDK will handle API key validation, so we just verify it initializes + it("should initialize with undefined API key and pass it through for env-var fallback", () => { + mockCreateAnthropic.mockClear() const handlerWithoutKey = new AnthropicHandler({ ...mockOptions, apiKey: undefined, }) expect(handlerWithoutKey).toBeInstanceOf(AnthropicHandler) + const callArgs = mockCreateAnthropic.mock.calls[0]![0]! + expect(callArgs.apiKey).toBeUndefined() }) it("should use custom base URL if provided", () => { @@ -112,44 +115,132 @@ describe("AnthropicHandler", () => { }) it("use apiKey for passing token if anthropicUseAuthToken is not set", () => { - const handlerWithCustomUrl = new AnthropicHandler({ + mockCreateAnthropic.mockClear() + const _ = new AnthropicHandler({ ...mockOptions, }) - expect(handlerWithCustomUrl).toBeInstanceOf(AnthropicHandler) - expect(mockAnthropicConstructor).toHaveBeenCalledTimes(1) - expect(mockAnthropicConstructor.mock.calls[0]![0]!.apiKey).toEqual("test-api-key") - expect(mockAnthropicConstructor.mock.calls[0]![0]!.authToken).toBeUndefined() + expect(mockCreateAnthropic).toHaveBeenCalledTimes(1) + const callArgs = mockCreateAnthropic.mock.calls[0]![0]! + expect(callArgs.apiKey).toEqual("test-api-key") + expect(callArgs.authToken).toBeUndefined() }) it("use apiKey for passing token if anthropicUseAuthToken is set but custom base URL is not given", () => { - const handlerWithCustomUrl = new AnthropicHandler({ + mockCreateAnthropic.mockClear() + const _ = new AnthropicHandler({ ...mockOptions, anthropicUseAuthToken: true, }) - expect(handlerWithCustomUrl).toBeInstanceOf(AnthropicHandler) - expect(mockAnthropicConstructor).toHaveBeenCalledTimes(1) - expect(mockAnthropicConstructor.mock.calls[0]![0]!.apiKey).toEqual("test-api-key") - expect(mockAnthropicConstructor.mock.calls[0]![0]!.authToken).toBeUndefined() + expect(mockCreateAnthropic).toHaveBeenCalledTimes(1) + const callArgs = mockCreateAnthropic.mock.calls[0]![0]! + expect(callArgs.apiKey).toEqual("test-api-key") + expect(callArgs.authToken).toBeUndefined() }) it("use authToken for passing token if both of anthropicBaseUrl and anthropicUseAuthToken are set", () => { + mockCreateAnthropic.mockClear() const customBaseUrl = "https://custom.anthropic.com" - const handlerWithCustomUrl = new AnthropicHandler({ + const _ = new AnthropicHandler({ ...mockOptions, anthropicBaseUrl: customBaseUrl, anthropicUseAuthToken: true, }) - expect(handlerWithCustomUrl).toBeInstanceOf(AnthropicHandler) - expect(mockAnthropicConstructor).toHaveBeenCalledTimes(1) - expect(mockAnthropicConstructor.mock.calls[0]![0]!.authToken).toEqual("test-api-key") - expect(mockAnthropicConstructor.mock.calls[0]![0]!.apiKey).toBeUndefined() + expect(mockCreateAnthropic).toHaveBeenCalledTimes(1) + const callArgs = mockCreateAnthropic.mock.calls[0]![0]! + expect(callArgs.authToken).toEqual("test-api-key") + expect(callArgs.apiKey).toBeUndefined() + }) + + it("should include 1M context beta header when enabled", () => { + mockCreateAnthropic.mockClear() + const _ = new AnthropicHandler({ + ...mockOptions, + apiModelId: "claude-sonnet-4-5", + anthropicBeta1MContext: true, + }) + expect(mockCreateAnthropic).toHaveBeenCalledTimes(1) + const callArgs = mockCreateAnthropic.mock.calls[0]![0]! + expect(callArgs.headers["anthropic-beta"]).toContain("context-1m-2025-08-07") + }) + + it("should include output-128k beta for thinking model", () => { + mockCreateAnthropic.mockClear() + const _ = new AnthropicHandler({ + ...mockOptions, + apiModelId: "claude-3-7-sonnet-20250219:thinking", + }) + expect(mockCreateAnthropic).toHaveBeenCalledTimes(1) + const callArgs = mockCreateAnthropic.mock.calls[0]![0]! + expect(callArgs.headers["anthropic-beta"]).toContain("output-128k-2025-02-19") }) }) describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." + function setupStreamTextMock(parts: any[], usage?: any, providerMetadata?: any) { + const asyncIterable = { + async *[Symbol.asyncIterator]() { + for (const part of parts) { + yield part + } + }, + } + mockStreamText.mockReturnValue({ + fullStream: asyncIterable, + usage: Promise.resolve(usage || { inputTokens: 100, outputTokens: 50 }), + providerMetadata: Promise.resolve( + providerMetadata || { + anthropic: { + cacheCreationInputTokens: 20, + cacheReadInputTokens: 10, + }, + }, + ), + }) + } + + it("should stream text content using AI SDK", async () => { + setupStreamTextMock([ + { type: "text-delta", text: "Hello" }, + { type: "text-delta", text: " world" }, + ]) + + const stream = handler.createMessage(systemPrompt, [ + { + role: "user", + content: [{ type: "text" as const, text: "First message" }], + }, + ]) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Verify text content + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(2) + expect(textChunks[0].text).toBe("Hello") + expect(textChunks[1].text).toBe(" world") + + // Verify usage information + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + }) + it("should handle prompt caching for supported models", async () => { + setupStreamTextMock( + [{ type: "text-delta", text: "Hello" }], + { inputTokens: 100, outputTokens: 50 }, + { + anthropic: { + cacheCreationInputTokens: 20, + cacheReadInputTokens: 10, + }, + }, + ) + const stream = handler.createMessage(systemPrompt, [ { role: "user", @@ -170,56 +261,271 @@ describe("AnthropicHandler", () => { chunks.push(chunk) } - // Verify usage information - const usageChunk = chunks.find((chunk) => chunk.type === "usage") + // Verify usage information includes cache metrics + const usageChunk = chunks.find( + (chunk) => chunk.type === "usage" && (chunk.cacheWriteTokens || chunk.cacheReadTokens), + ) expect(usageChunk).toBeDefined() - expect(usageChunk?.inputTokens).toBe(100) - expect(usageChunk?.outputTokens).toBe(50) expect(usageChunk?.cacheWriteTokens).toBe(20) expect(usageChunk?.cacheReadTokens).toBe(10) - // Verify text content - const textChunks = chunks.filter((chunk) => chunk.type === "text") - expect(textChunks).toHaveLength(2) - expect(textChunks[0].text).toBe("Hello") - expect(textChunks[1].text).toBe(" world") + // Verify streamText was called + expect(mockStreamText).toHaveBeenCalled() + }) - // Verify API - expect(mockCreate).toHaveBeenCalled() + it("should pass tools via AI SDK when tools are provided", async () => { + const mockTools = [ + { + type: "function" as const, + function: { + name: "get_weather", + description: "Get the current weather", + parameters: { + type: "object", + properties: { + location: { type: "string" }, + }, + required: ["location"], + }, + }, + }, + ] + + setupStreamTextMock([{ type: "text-delta", text: "Weather check" }]) + + const stream = handler.createMessage( + systemPrompt, + [{ role: "user", content: [{ type: "text" as const, text: "What's the weather?" }] }], + { taskId: "test-task", tools: mockTools }, + ) + + for await (const _chunk of stream) { + // Consume stream + } + + // Verify tools were converted + expect(convertToolsForAiSdk).toHaveBeenCalled() + expect(mockStreamText).toHaveBeenCalled() + }) + + it("should handle tool_choice mapping", async () => { + setupStreamTextMock([{ type: "text-delta", text: "test" }]) + + const stream = handler.createMessage( + systemPrompt, + [{ role: "user", content: [{ type: "text" as const, text: "test" }] }], + { taskId: "test-task", tool_choice: "auto" }, + ) + + for await (const _chunk of stream) { + // Consume stream + } + + expect(mapToolChoice).toHaveBeenCalledWith("auto") + }) + + it("should disable parallel tool use when parallelToolCalls is false", async () => { + setupStreamTextMock([{ type: "text-delta", text: "test" }]) + + const stream = handler.createMessage( + systemPrompt, + [{ role: "user", content: [{ type: "text" as const, text: "test" }] }], + { taskId: "test-task", parallelToolCalls: false }, + ) + + for await (const _chunk of stream) { + // Consume stream + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: expect.objectContaining({ + anthropic: expect.objectContaining({ + disableParallelToolUse: true, + }), + }), + }), + ) + }) + + it("should not set disableParallelToolUse when parallelToolCalls is true or undefined", async () => { + setupStreamTextMock([{ type: "text-delta", text: "test" }]) + + const stream = handler.createMessage( + systemPrompt, + [{ role: "user", content: [{ type: "text" as const, text: "test" }] }], + { taskId: "test-task", parallelToolCalls: true }, + ) + + for await (const _chunk of stream) { + // Consume stream + } + + // providerOptions should not include disableParallelToolUse + const callArgs = mockStreamText.mock.calls[0]![0] + const anthropicOptions = callArgs?.providerOptions?.anthropic + expect(anthropicOptions?.disableParallelToolUse).toBeUndefined() + }) + + it("should handle tool call streaming via AI SDK", async () => { + setupStreamTextMock([ + { type: "tool-input-start", id: "toolu_123", toolName: "get_weather" }, + { type: "tool-input-delta", id: "toolu_123", delta: '{"location":' }, + { type: "tool-input-delta", id: "toolu_123", delta: '"London"}' }, + { type: "tool-input-end", id: "toolu_123" }, + ]) + + const stream = handler.createMessage( + systemPrompt, + [{ role: "user", content: [{ type: "text" as const, text: "What's the weather?" }] }], + { taskId: "test-task" }, + ) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const startChunk = chunks.find((c) => c.type === "tool_call_start") + expect(startChunk).toBeDefined() + expect(startChunk?.id).toBe("toolu_123") + expect(startChunk?.name).toBe("get_weather") + + const deltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + expect(deltaChunks).toHaveLength(2) + + const endChunk = chunks.find((c) => c.type === "tool_call_end") + expect(endChunk).toBeDefined() + }) + + it("should capture thinking signature from stream events", async () => { + const testSignature = "test-thinking-signature" + setupStreamTextMock([ + { + type: "reasoning-delta", + text: "thinking...", + providerMetadata: { anthropic: { signature: testSignature } }, + }, + { type: "text-delta", text: "Answer" }, + ]) + + const stream = handler.createMessage(systemPrompt, [ + { role: "user", content: [{ type: "text" as const, text: "test" }] }, + ]) + + for await (const _chunk of stream) { + // Consume stream + } + + expect(handler.getThoughtSignature()).toBe(testSignature) + }) + + it("should capture redacted thinking blocks from stream events", async () => { + setupStreamTextMock([ + { + type: "reasoning-delta", + text: "", + providerMetadata: { anthropic: { redactedData: "redacted-data-base64" } }, + }, + { type: "text-delta", text: "Answer" }, + ]) + + const stream = handler.createMessage(systemPrompt, [ + { role: "user", content: [{ type: "text" as const, text: "test" }] }, + ]) + + for await (const _chunk of stream) { + // Consume stream + } + + const redactedBlocks = handler.getRedactedThinkingBlocks() + expect(redactedBlocks).toBeDefined() + expect(redactedBlocks).toHaveLength(1) + expect(redactedBlocks![0]).toEqual({ + type: "redacted_thinking", + data: "redacted-data-base64", + }) + }) + + it("should reset thinking state between requests", async () => { + // First request with signature + setupStreamTextMock([ + { + type: "reasoning-delta", + text: "thinking...", + providerMetadata: { anthropic: { signature: "sig-1" } }, + }, + ]) + + const stream1 = handler.createMessage(systemPrompt, [ + { role: "user", content: [{ type: "text" as const, text: "test 1" }] }, + ]) + for await (const _chunk of stream1) { + // Consume + } + expect(handler.getThoughtSignature()).toBe("sig-1") + + // Second request without signature + setupStreamTextMock([{ type: "text-delta", text: "plain answer" }]) + + const stream2 = handler.createMessage(systemPrompt, [ + { role: "user", content: [{ type: "text" as const, text: "test 2" }] }, + ]) + for await (const _chunk of stream2) { + // Consume + } + expect(handler.getThoughtSignature()).toBeUndefined() + }) + + it("should pass system prompt via system param with systemProviderOptions for cache control", async () => { + setupStreamTextMock([{ type: "text-delta", text: "test" }]) + + const stream = handler.createMessage(systemPrompt, [ + { role: "user", content: [{ type: "text" as const, text: "test" }] }, + ]) + + for await (const _chunk of stream) { + // Consume + } + + // Verify streamText was called with system + systemProviderOptions (not as a message) + const callArgs = mockStreamText.mock.calls[0]![0] + expect(callArgs.system).toBe(systemPrompt) + expect(callArgs.systemProviderOptions).toEqual({ + anthropic: { cacheControl: { type: "ephemeral" } }, + }) + // System prompt should NOT be in the messages array + const systemMessages = callArgs.messages.filter((m: any) => m.role === "system") + expect(systemMessages).toHaveLength(0) }) }) describe("completePrompt", () => { it("should complete prompt successfully", async () => { + mockGenerateText.mockResolvedValueOnce({ + text: "Test response", + }) + const result = await handler.completePrompt("Test prompt") expect(result).toBe("Test response") - expect(mockCreate).toHaveBeenCalledWith({ - model: mockOptions.apiModelId, - messages: [{ role: "user", content: "Test prompt" }], - max_tokens: 8192, - temperature: 0, - thinking: undefined, - stream: false, - }) + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + temperature: 0, + }), + ) }) it("should handle API errors", async () => { - mockCreate.mockRejectedValueOnce(new Error("Anthropic completion error: API Error")) - await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Anthropic completion error: API Error") - }) - - it("should handle non-text content", async () => { - mockCreate.mockImplementationOnce(async () => ({ - content: [{ type: "image" }], - })) - const result = await handler.completePrompt("Test prompt") - expect(result).toBe("") + const error = new Error("Anthropic completion error: API Error") + mockGenerateText.mockRejectedValueOnce(error) + await expect(handler.completePrompt("Test prompt")).rejects.toThrow() }) it("should handle empty response", async () => { - mockCreate.mockImplementationOnce(async () => ({ - content: [{ type: "text", text: "" }], - })) + mockGenerateText.mockResolvedValueOnce({ + text: "", + }) const result = await handler.completePrompt("Test prompt") expect(result).toBe("") }) @@ -299,447 +605,19 @@ describe("AnthropicHandler", () => { }) }) - describe("reasoning block filtering", () => { - const systemPrompt = "You are a helpful assistant." - - it("should filter out internal reasoning blocks before sending to API", async () => { - handler = new AnthropicHandler({ - apiKey: "test-api-key", - apiModelId: "claude-3-5-sonnet-20241022", - }) - - // Messages with internal reasoning blocks (from stored conversation history) - const messagesWithReasoning: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello", - }, - { - role: "assistant", - content: [ - { - type: "reasoning" as any, - text: "This is internal reasoning that should be filtered", - }, - { - type: "text", - text: "This is the response", - }, - ], - }, - { - role: "user", - content: "Continue", - }, - ] - - const stream = handler.createMessage(systemPrompt, messagesWithReasoning) - const chunks: any[] = [] - - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Verify the API was called with filtered messages (no reasoning blocks) - const calledMessages = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0].messages - expect(calledMessages).toHaveLength(3) - - // Check assistant message - should have reasoning block filtered out - const assistantMessage = calledMessages.find((m: any) => m.role === "assistant") - expect(assistantMessage).toBeDefined() - expect(assistantMessage.content).toEqual([{ type: "text", text: "This is the response" }]) - - // Verify reasoning blocks were NOT sent to the API - expect(assistantMessage.content).not.toContainEqual(expect.objectContaining({ type: "reasoning" })) - }) - - it("should filter empty messages after removing all reasoning blocks", async () => { - handler = new AnthropicHandler({ - apiKey: "test-api-key", - apiModelId: "claude-3-5-sonnet-20241022", - }) - - // Message with only reasoning content (should be completely filtered) - const messagesWithOnlyReasoning: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello", - }, - { - role: "assistant", - content: [ - { - type: "reasoning" as any, - text: "Only reasoning, no actual text", - }, - ], - }, - { - role: "user", - content: "Continue", - }, - ] - - const stream = handler.createMessage(systemPrompt, messagesWithOnlyReasoning) - const chunks: any[] = [] - - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Verify empty message was filtered out - const calledMessages = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0].messages - expect(calledMessages.length).toBe(2) // Only the two user messages - expect(calledMessages.every((m: any) => m.role === "user")).toBe(true) + describe("isAiSdkProvider", () => { + it("should return true", () => { + expect(handler.isAiSdkProvider()).toBe(true) }) }) - describe("native tool calling", () => { - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [{ type: "text" as const, text: "What's the weather in London?" }], - }, - ] - - const mockTools = [ - { - type: "function" as const, - function: { - name: "get_weather", - description: "Get the current weather", - parameters: { - type: "object", - properties: { - location: { type: "string" }, - }, - required: ["location"], - }, - }, - }, - ] - - it("should include tools in request when tools are provided", async () => { - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: mockTools, - }) - - // Consume the stream to trigger the API call - for await (const _chunk of stream) { - // Just consume - } - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - name: "get_weather", - description: "Get the current weather", - input_schema: expect.objectContaining({ - type: "object", - properties: expect.objectContaining({ - location: { type: "string" }, - }), - }), - }), - ]), - }), - expect.anything(), - ) + describe("thinking signature", () => { + it("should return undefined when no signature captured", () => { + expect(handler.getThoughtSignature()).toBeUndefined() }) - it("should include tools when tools are provided", async () => { - const xmlHandler = new AnthropicHandler({ - ...mockOptions, - }) - - const stream = xmlHandler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: mockTools, - }) - - // Consume the stream to trigger the API call - for await (const _chunk of stream) { - // Just consume - } - - // Tool calling is request-driven: if tools are provided, we should include them. - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - name: "get_weather", - }), - ]), - }), - expect.anything(), - ) - }) - - it("should always include tools in request (tools are always present after PR #10841)", async () => { - // Handler uses native protocol by default - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - }) - - // Consume the stream to trigger the API call - for await (const _chunk of stream) { - // Just consume - } - - // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS) - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.any(Array), - tool_choice: expect.any(Object), - }), - expect.anything(), - ) - }) - - it("should convert tool_choice 'auto' to Anthropic format", async () => { - // Handler uses native protocol by default - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: mockTools, - tool_choice: "auto", - }) - - // Consume the stream to trigger the API call - for await (const _chunk of stream) { - // Just consume - } - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tool_choice: { type: "auto", disable_parallel_tool_use: false }, - }), - expect.anything(), - ) - }) - - it("should convert tool_choice 'required' to Anthropic 'any' format", async () => { - // Handler uses native protocol by default - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: mockTools, - tool_choice: "required", - }) - - // Consume the stream to trigger the API call - for await (const _chunk of stream) { - // Just consume - } - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tool_choice: { type: "any", disable_parallel_tool_use: false }, - }), - expect.anything(), - ) - }) - - it("should set tool_choice to undefined when tool_choice is 'none' (tools are still passed)", async () => { - // Handler uses native protocol by default - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: mockTools, - tool_choice: "none", - }) - - // Consume the stream to trigger the API call - for await (const _chunk of stream) { - // Just consume - } - - // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS) - // When tool_choice is 'none', the converter returns undefined for tool_choice - // but tools are still passed since they're always present - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.any(Array), - tool_choice: undefined, - }), - expect.anything(), - ) - }) - - it("should convert specific tool_choice to Anthropic 'tool' format", async () => { - // Handler uses native protocol by default - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: mockTools, - tool_choice: { type: "function" as const, function: { name: "get_weather" } }, - }) - - // Consume the stream to trigger the API call - for await (const _chunk of stream) { - // Just consume - } - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tool_choice: { type: "tool", name: "get_weather", disable_parallel_tool_use: false }, - }), - expect.anything(), - ) - }) - - it("should enable parallel tool calls when parallelToolCalls is true", async () => { - // Handler uses native protocol by default - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: mockTools, - tool_choice: "auto", - parallelToolCalls: true, - }) - - // Consume the stream to trigger the API call - for await (const _chunk of stream) { - // Just consume - } - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tool_choice: { type: "auto", disable_parallel_tool_use: false }, - }), - expect.anything(), - ) - }) - - it("should handle tool_use blocks in stream and emit tool_call_partial", async () => { - mockCreate.mockImplementationOnce(async () => ({ - async *[Symbol.asyncIterator]() { - yield { - type: "message_start", - message: { - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }, - } - yield { - type: "content_block_start", - index: 0, - content_block: { - type: "tool_use", - id: "toolu_123", - name: "get_weather", - }, - } - }, - })) - - // Handler uses native protocol by default - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: mockTools, - }) - - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Find the tool_call_partial chunk - const toolCallChunk = chunks.find((chunk) => chunk.type === "tool_call_partial") - expect(toolCallChunk).toBeDefined() - expect(toolCallChunk).toEqual({ - type: "tool_call_partial", - index: 0, - id: "toolu_123", - name: "get_weather", - arguments: undefined, - }) - }) - - it("should handle input_json_delta in stream and emit tool_call_partial arguments", async () => { - mockCreate.mockImplementationOnce(async () => ({ - async *[Symbol.asyncIterator]() { - yield { - type: "message_start", - message: { - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }, - } - yield { - type: "content_block_start", - index: 0, - content_block: { - type: "tool_use", - id: "toolu_123", - name: "get_weather", - }, - } - yield { - type: "content_block_delta", - index: 0, - delta: { - type: "input_json_delta", - partial_json: '{"location":', - }, - } - yield { - type: "content_block_delta", - index: 0, - delta: { - type: "input_json_delta", - partial_json: '"London"}', - }, - } - yield { - type: "content_block_stop", - index: 0, - } - }, - })) - - // Handler uses native protocol by default - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: mockTools, - }) - - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Find the tool_call_partial chunks - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") - expect(toolCallChunks).toHaveLength(3) - - // First chunk has id and name - expect(toolCallChunks[0]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "toolu_123", - name: "get_weather", - arguments: undefined, - }) - - // Subsequent chunks have arguments - expect(toolCallChunks[1]).toEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: '{"location":', - }) - - expect(toolCallChunks[2]).toEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: '"London"}', - }) + it("should return undefined for redacted blocks when none captured", () => { + expect(handler.getRedactedThinkingBlocks()).toBeUndefined() }) }) }) diff --git a/src/api/providers/__tests__/azure.spec.ts b/src/api/providers/__tests__/azure.spec.ts new file mode 100644 index 0000000000..487f9b2885 --- /dev/null +++ b/src/api/providers/__tests__/azure.spec.ts @@ -0,0 +1,431 @@ +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText, mockCreateAzure } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), + mockCreateAzure: vi.fn(() => { + // Return a provider function that supports Responses API model creation + const mockProvider = vi.fn(() => ({ + modelId: "gpt-4o", + provider: "azure", + })) + ;(mockProvider as any).responses = vi.fn(() => ({ + modelId: "gpt-4o", + provider: "azure.responses", + })) + return mockProvider + }), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) + +vi.mock("@ai-sdk/azure", () => ({ + createAzure: mockCreateAzure, +})) + +import type { Anthropic } from "@anthropic-ai/sdk" + +import type { ApiHandlerOptions } from "../../../shared/api" + +import { AzureHandler } from "../azure" + +describe("AzureHandler", () => { + let handler: AzureHandler + let mockOptions: ApiHandlerOptions + + beforeEach(() => { + vi.clearAllMocks() + mockOptions = { + azureApiKey: "test-api-key", + azureResourceName: "test-resource", + azureDeploymentName: "gpt-4o", + azureApiVersion: "2024-08-01-preview", + } + handler = new AzureHandler(mockOptions) + }) + + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(AzureHandler) + expect(handler.getModel().id).toBe(mockOptions.azureDeploymentName) + }) + + it("should use apiModelId if azureDeploymentName is not provided", () => { + const handlerWithModelId = new AzureHandler({ + ...mockOptions, + azureDeploymentName: undefined, + apiModelId: "gpt-35-turbo", + }) + expect(handlerWithModelId.getModel().id).toBe("gpt-35-turbo") + }) + + it("should use empty string if neither azureDeploymentName nor apiModelId is provided", () => { + const handlerWithoutModel = new AzureHandler({ + ...mockOptions, + azureDeploymentName: undefined, + apiModelId: undefined, + }) + expect(handlerWithoutModel.getModel().id).toBe("") + }) + + it("should use default API version if not provided", () => { + const handlerWithoutVersion = new AzureHandler({ + ...mockOptions, + azureApiVersion: undefined, + }) + expect(handlerWithoutVersion).toBeInstanceOf(AzureHandler) + expect(mockCreateAzure).toHaveBeenLastCalledWith( + expect.objectContaining({ apiVersion: "2025-04-01-preview" }), + ) + }) + + it("should normalize query-style API version input", () => { + new AzureHandler({ + ...mockOptions, + azureApiVersion: " ?api-version=2024-10-21&foo=bar ", + }) + + expect(mockCreateAzure).toHaveBeenLastCalledWith( + expect.objectContaining({ + apiVersion: "2024-10-21", + }), + ) + }) + + it("should use default API version when configured value is blank", () => { + new AzureHandler({ + ...mockOptions, + azureApiVersion: " ", + }) + + expect(mockCreateAzure).toHaveBeenLastCalledWith( + expect.objectContaining({ apiVersion: "2025-04-01-preview" }), + ) + }) + }) + + describe("getModel", () => { + it("should return model info with deployment name as ID", () => { + const model = handler.getModel() + expect(model.id).toBe(mockOptions.azureDeploymentName) + expect(model.info).toBeDefined() + }) + + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") + }) + }) + + describe("isAiSdkProvider", () => { + it("should return true", () => { + expect(handler.isAiSdkProvider()).toBe(true) + }) + }) + + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text" as const, + text: "Hello!", + }, + ], + }, + ] + + it("should use the Responses API language model", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 1, outputTokens: 1 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // exhaust stream + } + + expect(mockStreamText).toHaveBeenCalled() + const requestOptions = mockStreamText.mock.calls[0][0] + expect((requestOptions.model as any).provider).toBe("azure.responses") + }) + + it("should handle streaming responses", async () => { + // Mock the fullStream async generator + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + // Mock usage and providerMetadata promises + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({ + azure: { + promptCacheHitTokens: 2, + promptCacheMissTokens: 8, + }, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response") + }) + + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({ + azure: { + promptCacheHitTokens: 2, + promptCacheMissTokens: 8, + }, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(5) + }) + + it("should include cache metrics in usage information from providerMetadata", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + // Azure provides cache metrics via providerMetadata + const mockProviderMetadata = Promise.resolve({ + azure: { + promptCacheHitTokens: 2, + promptCacheMissTokens: 8, + }, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].cacheWriteTokens).toBeUndefined() + expect(usageChunks[0].cacheReadTokens).toBe(2) // promptCacheHitTokens + }) + + it("should handle tool calls via tool-input-start/delta/end events", async () => { + async function* mockFullStream() { + yield { type: "tool-input-start", id: "tool-1", toolName: "test_tool" } + yield { type: "tool-input-delta", id: "tool-1", delta: '{"arg":' } + yield { type: "tool-input-delta", id: "tool-1", delta: '"value"}' } + yield { type: "tool-input-end", id: "tool-1" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolStartChunks = chunks.filter((chunk) => chunk.type === "tool_call_start") + expect(toolStartChunks).toHaveLength(1) + expect(toolStartChunks[0].id).toBe("tool-1") + expect(toolStartChunks[0].name).toBe("test_tool") + + const toolDeltaChunks = chunks.filter((chunk) => chunk.type === "tool_call_delta") + expect(toolDeltaChunks).toHaveLength(2) + + const toolEndChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + expect(toolEndChunks).toHaveLength(1) + }) + + it("should handle errors from AI SDK", async () => { + const mockError = new Error("API Error") + ;(mockError as any).name = "AI_APICallError" + ;(mockError as any).status = 500 + + async function* mockFullStream(): AsyncGenerator { + yield { type: "text-delta", text: "" } + throw mockError + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({}), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, messages) + await expect(async () => { + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + }).rejects.toThrow("Azure AI Foundry") + }) + }) + + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", + }) + + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("Test completion") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) + }) + + it("should use configured temperature", async () => { + const handlerWithTemp = new AzureHandler({ + ...mockOptions, + modelTemperature: 0.7, + }) + + mockGenerateText.mockResolvedValue({ + text: "Test completion", + }) + + await handlerWithTemp.completePrompt("Test prompt") + + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + }), + ) + }) + }) + + describe("tools", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Use a tool" }], + }, + ] + + it("should pass tools to streamText", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Using tool" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + }) + + const tools = [ + { + type: "function" as const, + function: { + name: "test_tool", + description: "A test tool", + parameters: { + type: "object", + properties: { + arg: { type: "string" }, + }, + required: ["arg"], + }, + }, + }, + ] + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools, + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.any(Object), + }), + ) + }) + }) +}) diff --git a/src/api/providers/__tests__/cerebras.spec.ts b/src/api/providers/__tests__/baseten.spec.ts similarity index 50% rename from src/api/providers/__tests__/cerebras.spec.ts rename to src/api/providers/__tests__/baseten.spec.ts index caf8861b46..e44b201f29 100644 --- a/src/api/providers/__tests__/cerebras.spec.ts +++ b/src/api/providers/__tests__/baseten.spec.ts @@ -1,3 +1,5 @@ +// npx vitest run src/api/providers/__tests__/baseten.spec.ts + // Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ mockStreamText: vi.fn(), @@ -13,83 +15,81 @@ vi.mock("ai", async (importOriginal) => { } }) -vi.mock("@ai-sdk/cerebras", () => ({ - createCerebras: vi.fn(() => { - // Return a function that returns a mock language model +vi.mock("@ai-sdk/baseten", () => ({ + createBaseten: vi.fn(() => { return vi.fn(() => ({ - modelId: "llama-3.3-70b", - provider: "cerebras", + modelId: "zai-org/GLM-4.6", + provider: "baseten", })) }), })) import type { Anthropic } from "@anthropic-ai/sdk" -import { cerebrasDefaultModelId, cerebrasModels, type CerebrasModelId } from "@roo-code/types" +import { basetenDefaultModelId, basetenModels, type BasetenModelId } from "@roo-code/types" import type { ApiHandlerOptions } from "../../../shared/api" -import { CerebrasHandler } from "../cerebras" +import { BasetenHandler } from "../baseten" -describe("CerebrasHandler", () => { - let handler: CerebrasHandler +describe("BasetenHandler", () => { + let handler: BasetenHandler let mockOptions: ApiHandlerOptions beforeEach(() => { mockOptions = { - cerebrasApiKey: "test-api-key", - apiModelId: "llama-3.3-70b" as CerebrasModelId, + basetenApiKey: "test-baseten-api-key", + apiModelId: "zai-org/GLM-4.6", } - handler = new CerebrasHandler(mockOptions) + handler = new BasetenHandler(mockOptions) vi.clearAllMocks() }) describe("constructor", () => { it("should initialize with provided options", () => { - expect(handler).toBeInstanceOf(CerebrasHandler) + expect(handler).toBeInstanceOf(BasetenHandler) expect(handler.getModel().id).toBe(mockOptions.apiModelId) }) it("should use default model ID if not provided", () => { - const handlerWithoutModel = new CerebrasHandler({ + const handlerWithoutModel = new BasetenHandler({ ...mockOptions, apiModelId: undefined, }) - expect(handlerWithoutModel.getModel().id).toBe(cerebrasDefaultModelId) + expect(handlerWithoutModel.getModel().id).toBe(basetenDefaultModelId) }) }) describe("getModel", () => { - it("should return model info for valid model ID", () => { - const model = handler.getModel() - expect(model.id).toBe(mockOptions.apiModelId) - expect(model.info).toBeDefined() - expect(model.info.maxTokens).toBe(16384) - expect(model.info.contextWindow).toBe(64000) - expect(model.info.supportsImages).toBe(false) - expect(model.info.supportsPromptCache).toBe(false) + it("should return default model when no model is specified", () => { + const handlerWithoutModel = new BasetenHandler({ + basetenApiKey: "test-baseten-api-key", + }) + const model = handlerWithoutModel.getModel() + expect(model.id).toBe(basetenDefaultModelId) + expect(model.info).toEqual(basetenModels[basetenDefaultModelId]) + }) + + it("should return specified model when valid model is provided", () => { + const testModelId: BasetenModelId = "deepseek-ai/DeepSeek-R1" + const handlerWithModel = new BasetenHandler({ + apiModelId: testModelId, + basetenApiKey: "test-baseten-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual(basetenModels[testModelId]) }) it("should return provided model ID with default model info if model does not exist", () => { - const handlerWithInvalidModel = new CerebrasHandler({ + const handlerWithInvalidModel = new BasetenHandler({ ...mockOptions, apiModelId: "invalid-model", }) const model = handlerWithInvalidModel.getModel() - expect(model.id).toBe("invalid-model") // Returns provided ID - expect(model.info).toBeDefined() - // Should have the same base properties as default model - expect(model.info.contextWindow).toBe(cerebrasModels[cerebrasDefaultModelId].contextWindow) - }) - - it("should return default model if no model ID is provided", () => { - const handlerWithoutModel = new CerebrasHandler({ - ...mockOptions, - apiModelId: undefined, - }) - const model = handlerWithoutModel.getModel() - expect(model.id).toBe(cerebrasDefaultModelId) + expect(model.id).toBe("invalid-model") expect(model.info).toBeDefined() + expect(model.info).toBe(basetenModels[basetenDefaultModelId]) }) it("should include model parameters from getModelParams", () => { @@ -114,12 +114,10 @@ describe("CerebrasHandler", () => { ] it("should handle streaming responses", async () => { - // Mock the fullStream async generator async function* mockFullStream() { - yield { type: "text-delta", text: "Test response" } + yield { type: "text-delta", text: "Test response from Baseten" } } - // Mock usage promise const mockUsage = Promise.resolve({ inputTokens: 10, outputTokens: 5, @@ -139,7 +137,7 @@ describe("CerebrasHandler", () => { expect(chunks.length).toBeGreaterThan(0) const textChunks = chunks.filter((chunk) => chunk.type === "text") expect(textChunks).toHaveLength(1) - expect(textChunks[0].text).toBe("Test response") + expect(textChunks[0].text).toBe("Test response from Baseten") }) it("should include usage information", async () => { @@ -149,7 +147,7 @@ describe("CerebrasHandler", () => { const mockUsage = Promise.resolve({ inputTokens: 10, - outputTokens: 5, + outputTokens: 20, }) mockStreamText.mockReturnValue({ @@ -166,28 +164,73 @@ describe("CerebrasHandler", () => { const usageChunks = chunks.filter((chunk) => chunk.type === "usage") expect(usageChunks.length).toBeGreaterThan(0) expect(usageChunks[0].inputTokens).toBe(10) - expect(usageChunks[0].outputTokens).toBe(5) + expect(usageChunks[0].outputTokens).toBe(20) }) - it("should handle reasoning content in streaming responses", async () => { - // Mock the fullStream async generator with reasoning content + it("should pass correct temperature (0.5 default) to streamText", async () => { async function* mockFullStream() { - yield { type: "reasoning", text: "Let me think about this..." } - yield { type: "reasoning", text: " I'll analyze step by step." } - yield { type: "text-delta", text: "Test response" } + yield { type: "text-delta", text: "Test" } } - const mockUsage = Promise.resolve({ - inputTokens: 10, - outputTokens: 5, - details: { - reasoningTokens: 15, - }, - }) - mockStreamText.mockReturnValue({ fullStream: mockFullStream(), - usage: mockUsage, + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + const handlerWithDefaultTemp = new BasetenHandler({ + basetenApiKey: "test-key", + apiModelId: "zai-org/GLM-4.6", + }) + + const stream = handlerWithDefaultTemp.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.5, + }), + ) + }) + + it("should use user-specified temperature over default", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + const handlerWithCustomTemp = new BasetenHandler({ + basetenApiKey: "test-key", + apiModelId: "zai-org/GLM-4.6", + modelTemperature: 0.9, + }) + + const stream = handlerWithCustomTemp.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.9, + }), + ) + }) + + it("should handle stream with multiple chunks", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Hello" } + yield { type: "text-delta", text: " world" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 10 }), }) const stream = handler.createMessage(systemPrompt, messages) @@ -196,133 +239,43 @@ describe("CerebrasHandler", () => { chunks.push(chunk) } - // Should have reasoning chunks - const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") - expect(reasoningChunks.length).toBe(2) - expect(reasoningChunks[0].text).toBe("Let me think about this...") - expect(reasoningChunks[1].text).toBe(" I'll analyze step by step.") + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks[0]).toEqual({ type: "text", text: "Hello" }) + expect(textChunks[1]).toEqual({ type: "text", text: " world" }) - // Should also have text chunks - const textChunks = chunks.filter((chunk) => chunk.type === "text") - expect(textChunks.length).toBe(1) - expect(textChunks[0].text).toBe("Test response") + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks[0]).toMatchObject({ type: "usage", inputTokens: 5, outputTokens: 10 }) }) }) describe("completePrompt", () => { it("should complete a prompt using generateText", async () => { mockGenerateText.mockResolvedValue({ - text: "Test completion", + text: "Test completion from Baseten", }) const result = await handler.completePrompt("Test prompt") - expect(result).toBe("Test completion") + expect(result).toBe("Test completion from Baseten") expect(mockGenerateText).toHaveBeenCalledWith( expect.objectContaining({ prompt: "Test prompt", }), ) }) - }) - describe("processUsageMetrics", () => { - it("should correctly process usage metrics", () => { - // We need to access the protected method, so we'll create a test subclass - class TestCerebrasHandler extends CerebrasHandler { - public testProcessUsageMetrics(usage: any) { - return this.processUsageMetrics(usage) - } - } - - const testHandler = new TestCerebrasHandler(mockOptions) - - const usage = { - inputTokens: 100, - outputTokens: 50, - details: { - cachedInputTokens: 20, - reasoningTokens: 30, - }, - } - - const result = testHandler.testProcessUsageMetrics(usage) - - expect(result.type).toBe("usage") - expect(result.inputTokens).toBe(100) - expect(result.outputTokens).toBe(50) - expect(result.cacheReadTokens).toBe(20) - expect(result.reasoningTokens).toBe(30) - }) - - it("should handle missing cache metrics gracefully", () => { - class TestCerebrasHandler extends CerebrasHandler { - public testProcessUsageMetrics(usage: any) { - return this.processUsageMetrics(usage) - } - } - - const testHandler = new TestCerebrasHandler(mockOptions) - - const usage = { - inputTokens: 100, - outputTokens: 50, - } - - const result = testHandler.testProcessUsageMetrics(usage) - - expect(result.type).toBe("usage") - expect(result.inputTokens).toBe(100) - expect(result.outputTokens).toBe(50) - expect(result.cacheReadTokens).toBeUndefined() - expect(result.reasoningTokens).toBeUndefined() - }) - }) - - describe("getMaxOutputTokens", () => { - it("should return maxTokens from model info", () => { - class TestCerebrasHandler extends CerebrasHandler { - public testGetMaxOutputTokens() { - return this.getMaxOutputTokens() - } - } - - const testHandler = new TestCerebrasHandler(mockOptions) - const result = testHandler.testGetMaxOutputTokens() - - // llama-3.3-70b maxTokens is 16384 - expect(result).toBe(16384) - }) - - it("should use modelMaxTokens when provided", () => { - class TestCerebrasHandler extends CerebrasHandler { - public testGetMaxOutputTokens() { - return this.getMaxOutputTokens() - } - } - - const customMaxTokens = 5000 - const testHandler = new TestCerebrasHandler({ - ...mockOptions, - modelMaxTokens: customMaxTokens, + it("should use default temperature in completePrompt", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", }) - const result = testHandler.testGetMaxOutputTokens() - expect(result).toBe(customMaxTokens) - }) + await handler.completePrompt("Test prompt") - it("should fall back to modelInfo.maxTokens when modelMaxTokens is not provided", () => { - class TestCerebrasHandler extends CerebrasHandler { - public testGetMaxOutputTokens() { - return this.getMaxOutputTokens() - } - } - - const testHandler = new TestCerebrasHandler(mockOptions) - const result = testHandler.testGetMaxOutputTokens() - - // llama-3.3-70b has maxTokens of 16384 - expect(result).toBe(16384) + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.5, + }), + ) }) }) @@ -402,9 +355,6 @@ describe("CerebrasHandler", () => { }) it("should ignore tool-call events to prevent duplicate tools in UI", async () => { - // tool-call events are intentionally ignored because tool-input-start/delta/end - // already provide complete tool call information. Emitting tool-call would cause - // duplicate tools in the UI for AI SDK providers (e.g., DeepSeek, Moonshot, Cerebras). async function* mockFullStream() { yield { type: "tool-call", @@ -424,32 +374,73 @@ describe("CerebrasHandler", () => { usage: mockUsage, }) - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: [ - { - type: "function", - function: { - name: "read_file", - description: "Read a file", - parameters: { - type: "object", - properties: { path: { type: "string" } }, - required: ["path"], - }, - }, - }, - ], - }) - + const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { chunks.push(chunk) } - // tool-call events are ignored, so no tool_call chunks should be emitted - const toolCallChunks = chunks.filter((c) => c.type === "tool_call") + const toolCallChunks = chunks.filter( + (c) => c.type === "tool_call_start" || c.type === "tool_call_delta" || c.type === "tool_call_end", + ) expect(toolCallChunks.length).toBe(0) }) }) + + describe("error handling", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle AI SDK errors with handleAiSdkError", async () => { + // eslint-disable-next-line require-yield + async function* mockFullStream(): AsyncGenerator { + throw new Error("API Error") + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + const stream = handler.createMessage(systemPrompt, messages) + + await expect(async () => { + for await (const _ of stream) { + // consume stream + } + }).rejects.toThrow("Baseten: API Error") + }) + + it("should preserve status codes in error handling", async () => { + const apiError = new Error("Rate limit exceeded") + ;(apiError as any).status = 429 + + // eslint-disable-next-line require-yield + async function* mockFullStream(): AsyncGenerator { + throw apiError + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + const stream = handler.createMessage(systemPrompt, messages) + + try { + for await (const _ of stream) { + // consume stream + } + expect.fail("Should have thrown an error") + } catch (error: any) { + expect(error.message).toContain("Baseten") + expect(error.status).toBe(429) + } + }) + }) }) diff --git a/src/api/providers/__tests__/bedrock-custom-arn.spec.ts b/src/api/providers/__tests__/bedrock-custom-arn.spec.ts index dfad54c1fd..75cc27c89d 100644 --- a/src/api/providers/__tests__/bedrock-custom-arn.spec.ts +++ b/src/api/providers/__tests__/bedrock-custom-arn.spec.ts @@ -22,38 +22,6 @@ vitest.mock("../../../utils/logging", () => ({ }, })) -// Mock AWS SDK -vitest.mock("@aws-sdk/client-bedrock-runtime", () => { - const mockModule = { - lastCommandInput: null as Record | null, - mockSend: vitest.fn().mockImplementation(async function () { - return { - output: new TextEncoder().encode(JSON.stringify({ content: "Test response" })), - } - }), - mockConverseCommand: vitest.fn(function (input) { - mockModule.lastCommandInput = input - return { input } - }), - MockBedrockRuntimeClient: class { - public config: any - public send: any - - constructor(config: { region?: string }) { - this.config = config - this.send = mockModule.mockSend - } - }, - } - - return { - BedrockRuntimeClient: mockModule.MockBedrockRuntimeClient, - ConverseCommand: mockModule.mockConverseCommand, - ConverseStreamCommand: vitest.fn(), - __mock: mockModule, // Expose mock internals for testing - } -}) - describe("Bedrock ARN Handling", () => { // Helper function to create a handler with specific options const createHandler = (options: Partial = {}) => { @@ -224,8 +192,8 @@ describe("Bedrock ARN Handling", () => { "arn:aws:bedrock:eu-west-1:123456789012:inference-profile/anthropic.claude-3-sonnet-20240229-v1:0", }) - // Verify the client was created with the ARN region, not the provided region - expect((handler as any).client.config.region).toBe("eu-west-1") + // Verify the handler's options were updated with the ARN region + expect((handler as any).options.awsRegion).toBe("eu-west-1") }) it("should log region mismatch warning when ARN region differs from provided region", () => { diff --git a/src/api/providers/__tests__/bedrock-error-handling.spec.ts b/src/api/providers/__tests__/bedrock-error-handling.spec.ts index 2041dde457..d217984c8d 100644 --- a/src/api/providers/__tests__/bedrock-error-handling.spec.ts +++ b/src/api/providers/__tests__/bedrock-error-handling.spec.ts @@ -8,9 +8,6 @@ vi.mock("@roo-code/telemetry", () => ({ }, })) -// Mock BedrockRuntimeClient and commands -const mockSend = vi.fn() - // Mock AWS SDK credential providers vi.mock("@aws-sdk/credential-providers", () => { return { @@ -21,16 +18,27 @@ vi.mock("@aws-sdk/credential-providers", () => { } }) -vi.mock("@aws-sdk/client-bedrock-runtime", () => ({ - BedrockRuntimeClient: vi.fn().mockImplementation(() => ({ - send: mockSend, - })), - ConverseStreamCommand: vi.fn(), - ConverseCommand: vi.fn(), +// Use vi.hoisted to define mock functions for AI SDK +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) + +vi.mock("@ai-sdk/amazon-bedrock", () => ({ + createAmazonBedrock: vi.fn(() => vi.fn(() => ({ modelId: "test", provider: "bedrock" }))), })) import { AwsBedrockHandler } from "../bedrock" -import { Anthropic } from "@anthropic-ai/sdk" +import type { Anthropic } from "@anthropic-ai/sdk" describe("AwsBedrockHandler Error Handling", () => { let handler: AwsBedrockHandler @@ -46,6 +54,10 @@ describe("AwsBedrockHandler Error Handling", () => { }) }) + /** + * Helper: create an Error with optional extra properties that + * the production code inspects (status, name, $metadata, __type). + */ const createMockError = (options: { message?: string name?: string @@ -56,505 +68,481 @@ describe("AwsBedrockHandler Error Handling", () => { requestId?: string extendedRequestId?: string cfId?: string - [key: string]: any // Allow additional properties + [key: string]: unknown } }): Error => { const error = new Error(options.message || "Test error") as any if (options.name) error.name = options.name - if (options.status) error.status = options.status + if (options.status !== undefined) error.status = options.status if (options.__type) error.__type = options.__type if (options.$metadata) error.$metadata = options.$metadata return error } - describe("Throttling Error Detection", () => { - it("should detect throttling from HTTP 429 status code", async () => { + // ----------------------------------------------------------------------- + // Throttling Detection — completePrompt path + // + // Production flow: generateText throws → catch → isThrottlingError() is + // NOT called in completePrompt (only in createMessage), so it falls + // through to handleAiSdkError which wraps with "Bedrock: ". + // + // For createMessage: streamText throws → catch → isThrottlingError() + // returns true → re-throws original error. + // ----------------------------------------------------------------------- + + describe("Throttling Error Detection (createMessage)", () => { + it("should re-throw throttling errors with status 429 for retry", async () => { const throttleError = createMockError({ message: "Request failed", status: 429, }) - mockSend.mockRejectedValueOnce(throttleError) + mockStreamText.mockImplementation(() => { + throw throttleError + }) - try { - const result = await handler.completePrompt("test") - expect(result).toContain("throttled or rate limited") - } catch (error) { - expect(error.message).toContain("throttled or rate limited") - } + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) + + await expect(async () => { + for await (const _chunk of generator) { + // should throw + } + }).rejects.toThrow("Request failed") }) - it("should detect throttling from AWS SDK $metadata.httpStatusCode", async () => { + it("should re-throw throttling errors detected via $metadata.httpStatusCode", async () => { const throttleError = createMockError({ message: "Request failed", $metadata: { httpStatusCode: 429 }, }) - mockSend.mockRejectedValueOnce(throttleError) - - try { - const result = await handler.completePrompt("test") - expect(result).toContain("throttled or rate limited") - } catch (error) { - expect(error.message).toContain("throttled or rate limited") - } - }) - - it("should detect throttling from ThrottlingException name", async () => { - const throttleError = createMockError({ - message: "Request failed", - name: "ThrottlingException", + mockStreamText.mockImplementation(() => { + throw throttleError }) - mockSend.mockRejectedValueOnce(throttleError) - - try { - const result = await handler.completePrompt("test") - expect(result).toContain("throttled or rate limited") - } catch (error) { - expect(error.message).toContain("throttled or rate limited") - } - }) - - it("should detect throttling from __type field", async () => { - const throttleError = createMockError({ - message: "Request failed", - __type: "ThrottlingException", - }) - - mockSend.mockRejectedValueOnce(throttleError) - - try { - const result = await handler.completePrompt("test") - expect(result).toContain("throttled or rate limited") - } catch (error) { - expect(error.message).toContain("throttled or rate limited") - } - }) - - it("should detect throttling from 'Bedrock is unable to process your request' message", async () => { - const throttleError = createMockError({ - message: "Bedrock is unable to process your request", - }) - - mockSend.mockRejectedValueOnce(throttleError) - - try { - const result = await handler.completePrompt("test") - expect(result).toContain("throttled or rate limited") - } catch (error) { - expect(error.message).toMatch(/throttled or rate limited/) - } - }) - - it("should detect throttling from various message patterns", async () => { - const throttlingMessages = [ - "Request throttled", - "Rate limit exceeded", - "Too many requests", - "Service unavailable due to high demand", - "Server is overloaded", - "System is busy", - "Please wait and try again", - ] - - for (const message of throttlingMessages) { - const throttleError = createMockError({ message }) - mockSend.mockRejectedValueOnce(throttleError) - - try { - await handler.completePrompt("test") - // Should not reach here as completePrompt should throw - throw new Error("Expected error to be thrown") - } catch (error) { - expect(error.message).toContain("throttled or rate limited") - } - } - }) - - it("should display appropriate error information for throttling errors", async () => { - const throttlingError = createMockError({ - message: "Bedrock is unable to process your request", - name: "ThrottlingException", - status: 429, - $metadata: { - httpStatusCode: 429, - requestId: "12345-abcde-67890", - extendedRequestId: "extended-12345", - cfId: "cf-12345", - }, - }) - - mockSend.mockRejectedValueOnce(throttlingError) - - try { - await handler.completePrompt("test") - throw new Error("Expected error to be thrown") - } catch (error) { - // Should contain the main error message - expect(error.message).toContain("throttled or rate limited") - } - }) - }) - - describe("Service Quota Exceeded Detection", () => { - it("should detect service quota exceeded errors", async () => { - const quotaError = createMockError({ - message: "Service quota exceeded for model requests", - }) - - mockSend.mockRejectedValueOnce(quotaError) - - try { - const result = await handler.completePrompt("test") - expect(result).toContain("Service quota exceeded") - } catch (error) { - expect(error.message).toContain("Service quota exceeded") - } - }) - }) - - describe("Model Not Ready Detection", () => { - it("should detect model not ready errors", async () => { - const modelError = createMockError({ - message: "Model is not ready, please try again later", - }) - - mockSend.mockRejectedValueOnce(modelError) - - try { - const result = await handler.completePrompt("test") - expect(result).toContain("Model is not ready") - } catch (error) { - expect(error.message).toContain("Model is not ready") - } - }) - }) - - describe("Internal Server Error Detection", () => { - it("should detect internal server errors", async () => { - const serverError = createMockError({ - message: "Internal server error occurred", - }) - - mockSend.mockRejectedValueOnce(serverError) - - try { - const result = await handler.completePrompt("test") - expect(result).toContain("internal server error") - } catch (error) { - expect(error.message).toContain("internal server error") - } - }) - }) - - describe("Token Limit Detection", () => { - it("should detect enhanced token limit errors", async () => { - const tokenErrors = [ - "Too many tokens in request", - "Token limit exceeded", - "Maximum context length reached", - "Context length exceeds limit", - ] - - for (const message of tokenErrors) { - const tokenError = createMockError({ message }) - mockSend.mockRejectedValueOnce(tokenError) - - try { - await handler.completePrompt("test") - // Should not reach here as completePrompt should throw - throw new Error("Expected error to be thrown") - } catch (error) { - // Either "Too many tokens" for token-specific errors or "throttled" for limit-related errors - expect(error.message).toMatch(/Too many tokens|throttled or rate limited/) - } - } - }) - }) - - describe("Streaming Context Error Handling", () => { - it("should handle throttling errors in streaming context", async () => { - const throttleError = createMockError({ - message: "Bedrock is unable to process your request", - status: 429, - }) - - const mockStream = { - [Symbol.asyncIterator]() { - return { - async next() { - throw throttleError - }, - } - }, - } - - mockSend.mockResolvedValueOnce({ stream: mockStream }) - const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) - // For throttling errors, it should throw immediately without yielding chunks - // This allows the retry mechanism to catch and handle it await expect(async () => { - for await (const chunk of generator) { - // Should not yield any chunks for throttling errors + for await (const _chunk of generator) { + // should throw + } + }).rejects.toThrow("Request failed") + }) + + it("should re-throw ThrottlingException by name", async () => { + const throttleError = createMockError({ + message: "Request failed", + name: "ThrottlingException", + }) + + mockStreamText.mockImplementation(() => { + throw throttleError + }) + + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) + + await expect(async () => { + for await (const _chunk of generator) { + // should throw + } + }).rejects.toThrow("Request failed") + }) + + it("should re-throw 'Bedrock is unable to process your request' as throttling", async () => { + const throttleError = createMockError({ + message: "Bedrock is unable to process your request", + }) + + mockStreamText.mockImplementation(() => { + throw throttleError + }) + + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) + + await expect(async () => { + for await (const _chunk of generator) { + // should throw } }).rejects.toThrow("Bedrock is unable to process your request") }) - it("should yield error chunks for non-throttling errors in streaming context", async () => { - const genericError = createMockError({ - message: "Some other error", - status: 500, - }) + it("should detect throttling from various message patterns", async () => { + const throttlingMessages = ["Request throttled", "Rate limit exceeded", "Too many requests"] - const mockStream = { - [Symbol.asyncIterator]() { - return { - async next() { - throw genericError - }, + for (const message of throttlingMessages) { + vi.clearAllMocks() + const throttleError = createMockError({ message }) + + mockStreamText.mockImplementation(() => { + throw throttleError + }) + + const localHandler = new AwsBedrockHandler({ + apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", + awsAccessKey: "test-access-key", + awsSecretKey: "test-secret-key", + awsRegion: "us-east-1", + }) + + const generator = localHandler.createMessage("system", [{ role: "user", content: "test" }]) + + // Throttling errors are re-thrown with original message for retry + await expect(async () => { + for await (const _chunk of generator) { + // should throw } - }, + }).rejects.toThrow(message) } - - mockSend.mockResolvedValueOnce({ stream: mockStream }) - - const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) - - const chunks: any[] = [] - try { - for await (const chunk of generator) { - chunks.push(chunk) - } - } catch (error) { - // Expected to throw after yielding chunks - } - - // Should have yielded error chunks before throwing for non-throttling errors - expect( - chunks.some((chunk) => chunk.type === "text" && chunk.text && chunk.text.includes("Some other error")), - ).toBe(true) }) - }) - describe("Error Priority and Specificity", () => { - it("should prioritize HTTP status codes over message patterns", async () => { - // Error with both 429 status and generic message should be detected as throttling + it("should prioritize HTTP status 429 over message content for throttling", async () => { const mixedError = createMockError({ message: "Some generic error message", status: 429, }) - mockSend.mockRejectedValueOnce(mixedError) + mockStreamText.mockImplementation(() => { + throw mixedError + }) - try { - const result = await handler.completePrompt("test") - expect(result).toContain("throttled or rate limited") - } catch (error) { - expect(error.message).toContain("throttled or rate limited") - } + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) + + // Because status=429, it's throttling → re-throws original error + await expect(async () => { + for await (const _chunk of generator) { + // should throw + } + }).rejects.toThrow("Some generic error message") }) - it("should prioritize AWS error types over message patterns", async () => { - // Error with ThrottlingException name but different message should still be throttling + it("should prioritize ThrottlingException name over message for throttling", async () => { const specificError = createMockError({ message: "Some other error occurred", name: "ThrottlingException", }) - mockSend.mockRejectedValueOnce(specificError) + mockStreamText.mockImplementation(() => { + throw specificError + }) - try { - const result = await handler.completePrompt("test") - expect(result).toContain("throttled or rate limited") - } catch (error) { - expect(error.message).toContain("throttled or rate limited") - } + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) + + // ThrottlingException → re-throws original for retry + await expect(async () => { + for await (const _chunk of generator) { + // should throw + } + }).rejects.toThrow("Some other error occurred") }) }) - describe("Unknown Error Fallback", () => { - it("should still show unknown error for truly unrecognized errors", async () => { - const unknownError = createMockError({ + // ----------------------------------------------------------------------- + // Non-throttling errors (createMessage) are wrapped by handleAiSdkError + // ----------------------------------------------------------------------- + + describe("Non-throttling errors (createMessage)", () => { + it("should wrap non-throttling errors with provider name via handleAiSdkError", async () => { + const genericError = createMockError({ message: "Something completely unexpected happened", }) - mockSend.mockRejectedValueOnce(unknownError) - - try { - const result = await handler.completePrompt("test") - expect(result).toContain("Unknown Error") - } catch (error) { - expect(error.message).toContain("Unknown Error") - } - }) - }) - - describe("Enhanced Error Throw for Retry System", () => { - it("should throw enhanced error messages for completePrompt to display in retry system", async () => { - const throttlingError = createMockError({ - message: "Too many tokens, rate limited", - status: 429, - $metadata: { - httpStatusCode: 429, - requestId: "test-request-id-12345", - }, + mockStreamText.mockImplementation(() => { + throw genericError }) - mockSend.mockRejectedValueOnce(throttlingError) + + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) + + await expect(async () => { + for await (const _chunk of generator) { + // should throw + } + }).rejects.toThrow("Bedrock: Something completely unexpected happened") + }) + + it("should preserve status code from non-throttling API errors", async () => { + const apiError = createMockError({ + message: "Internal server error occurred", + status: 500, + }) + + mockStreamText.mockImplementation(() => { + throw apiError + }) + + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) try { - await handler.completePrompt("test") + for await (const _chunk of generator) { + // should throw + } throw new Error("Expected error to be thrown") - } catch (error) { - // Should contain the verbose message template - expect(error.message).toContain("Request was throttled or rate limited") - // Should preserve original error properties - expect((error as any).status).toBe(429) - expect((error as any).$metadata.requestId).toBe("test-request-id-12345") + } catch (error: any) { + expect(error.message).toContain("Bedrock:") + expect(error.message).toContain("Internal server error occurred") } }) - it("should throw enhanced error messages for createMessage streaming to display in retry system", async () => { + it("should handle validation errors (token limits) as non-throttling", async () => { const tokenError = createMockError({ message: "Too many tokens in request", name: "ValidationException", - $metadata: { - httpStatusCode: 400, - requestId: "token-error-id-67890", - extendedRequestId: "extended-12345", - }, }) - const mockStream = { - [Symbol.asyncIterator]() { - return { - async next() { - throw tokenError - }, - } - }, - } + mockStreamText.mockImplementation(() => { + throw tokenError + }) - mockSend.mockResolvedValueOnce({ stream: mockStream }) + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) - try { - const stream = handler.createMessage("system", [{ role: "user", content: "test" }]) - for await (const chunk of stream) { - // Should not reach here as it should throw an error + await expect(async () => { + for await (const _chunk of generator) { + // should throw } - throw new Error("Expected error to be thrown") - } catch (error) { - // Should contain error codes (note: this will be caught by the non-throttling error path) - expect(error.message).toContain("Too many tokens") - // Should preserve original error properties - expect(error.name).toBe("ValidationException") - expect((error as any).$metadata.requestId).toBe("token-error-id-67890") - } + }).rejects.toThrow("Bedrock: Too many tokens in request") }) }) - describe("Edge Case Test Coverage", () => { - it("should handle concurrent throttling errors correctly", async () => { - const throttlingError = createMockError({ + // ----------------------------------------------------------------------- + // Streaming context: errors mid-stream + // ----------------------------------------------------------------------- + + describe("Streaming Context Error Handling", () => { + it("should re-throw throttling errors that occur mid-stream", async () => { + const throttleError = createMockError({ message: "Bedrock is unable to process your request", status: 429, }) - // Setup multiple concurrent requests that will all fail with throttling - mockSend.mockRejectedValue(throttlingError) + // Mock streamText to return an object whose fullStream throws mid-iteration + async function* failingStream() { + yield { type: "text-delta" as const, textDelta: "partial" } + throw throttleError + } + + mockStreamText.mockReturnValue({ + fullStream: failingStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) + + await expect(async () => { + for await (const _chunk of generator) { + // may yield partial text before throwing + } + }).rejects.toThrow("Bedrock is unable to process your request") + }) + + it("should wrap non-throttling errors that occur mid-stream via handleAiSdkError", async () => { + const genericError = createMockError({ + message: "Some other error", + status: 500, + }) + + async function* failingStream() { + yield { type: "text-delta" as const, textDelta: "partial" } + throw genericError + } + + mockStreamText.mockReturnValue({ + fullStream: failingStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) + + await expect(async () => { + for await (const _chunk of generator) { + // should throw + } + }).rejects.toThrow("Bedrock: Some other error") + }) + }) + + // ----------------------------------------------------------------------- + // completePrompt errors — all go through handleAiSdkError (no throttle check) + // ----------------------------------------------------------------------- + + describe("completePrompt error handling", () => { + it("should wrap errors with provider name for completePrompt", async () => { + mockGenerateText.mockRejectedValueOnce(new Error("Bedrock API failure")) + + await expect(handler.completePrompt("test")).rejects.toThrow("Bedrock: Bedrock API failure") + }) + + it("should wrap throttling-pattern errors with provider name for completePrompt", async () => { + const throttleError = createMockError({ + message: "Bedrock is unable to process your request", + status: 429, + }) + + mockGenerateText.mockRejectedValueOnce(throttleError) + + // completePrompt does NOT have the throttle-rethrow path; it always uses handleAiSdkError + await expect(handler.completePrompt("test")).rejects.toThrow( + "Bedrock: Bedrock is unable to process your request", + ) + }) + + it("should handle concurrent generateText failures", async () => { + const error = new Error("API failure") + mockGenerateText.mockRejectedValue(error) - // Execute multiple concurrent requests const promises = Array.from({ length: 5 }, () => handler.completePrompt("test")) - - // All should throw with throttling error const results = await Promise.allSettled(promises) results.forEach((result) => { expect(result.status).toBe("rejected") if (result.status === "rejected") { - expect(result.reason.message).toContain("throttled or rate limited") + expect(result.reason.message).toContain("Bedrock:") } }) }) - it("should handle mixed error scenarios with both throttling and other indicators", async () => { - // Error with both 429 status (throttling) and validation error message - const mixedError = createMockError({ - message: "ValidationException: Your input is invalid, but also rate limited", - name: "ValidationException", - status: 429, - $metadata: { - httpStatusCode: 429, - requestId: "mixed-error-id", - }, + it("should preserve status code from API call errors in completePrompt", async () => { + const apiError = createMockError({ + message: "Service unavailable", + status: 503, }) - mockSend.mockRejectedValueOnce(mixedError) + mockGenerateText.mockRejectedValueOnce(apiError) try { await handler.completePrompt("test") - } catch (error) { - // Should be treated as throttling due to 429 status taking priority - expect(error.message).toContain("throttled or rate limited") - // Should still preserve metadata - expect((error as any).$metadata?.requestId).toBe("mixed-error-id") - } - }) - - it("should handle rapid successive retries in streaming context", async () => { - const throttlingError = createMockError({ - message: "ThrottlingException", - name: "ThrottlingException", - }) - - // Mock stream that throws immediately - const mockStream = { - // eslint-disable-next-line require-yield - [Symbol.asyncIterator]: async function* () { - throw throttlingError - }, - } - - mockSend.mockResolvedValueOnce({ stream: mockStream }) - - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "test" }] - - try { - // Should throw immediately without yielding any chunks - const stream = handler.createMessage("", messages) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - // Should not reach here - expect(chunks).toHaveLength(0) - } catch (error) { - // Error should be thrown immediately for retry mechanism - // The error might be a TypeError if the stream iterator fails - expect(error).toBeDefined() - // The important thing is that it throws immediately without yielding chunks - } - }) - - it("should validate error properties exist before accessing them", async () => { - // Error with unusual structure - const unusualError = { - message: "Error with unusual structure", - // Missing typical properties like name, status, etc. - } - - mockSend.mockRejectedValueOnce(unusualError) - - try { - await handler.completePrompt("test") - } catch (error) { - // Should handle gracefully without accessing undefined properties - expect(error.message).toContain("Unknown Error") - // Should not have undefined values in the error message - expect(error.message).not.toContain("undefined") + throw new Error("Expected error to be thrown") + } catch (error: any) { + expect(error.message).toContain("Bedrock:") + expect(error.message).toContain("Service unavailable") } }) }) + + // ----------------------------------------------------------------------- + // Telemetry + // ----------------------------------------------------------------------- + + describe("Error telemetry", () => { + it("should capture telemetry for createMessage errors", async () => { + mockStreamText.mockImplementation(() => { + throw new Error("Stream failure") + }) + + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) + + await expect(async () => { + for await (const _chunk of generator) { + // should throw + } + }).rejects.toThrow() + + expect(mockCaptureException).toHaveBeenCalled() + }) + + it("should capture telemetry for completePrompt errors", async () => { + mockGenerateText.mockRejectedValueOnce(new Error("Generate failure")) + + await expect(handler.completePrompt("test")).rejects.toThrow() + + expect(mockCaptureException).toHaveBeenCalled() + }) + + it("should capture telemetry for throttling errors too", async () => { + const throttleError = createMockError({ + message: "Rate limit exceeded", + status: 429, + }) + + mockStreamText.mockImplementation(() => { + throw throttleError + }) + + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) + + await expect(async () => { + for await (const _chunk of generator) { + // should throw + } + }).rejects.toThrow() + + // Telemetry is captured even for throttling errors + expect(mockCaptureException).toHaveBeenCalled() + }) + }) + + // ----------------------------------------------------------------------- + // Edge cases + // ----------------------------------------------------------------------- + + describe("Edge Case Test Coverage", () => { + it("should handle non-Error objects thrown by generateText", async () => { + mockGenerateText.mockRejectedValueOnce("string error") + + await expect(handler.completePrompt("test")).rejects.toThrow("Bedrock: string error") + }) + + it("should handle non-Error objects thrown by streamText", async () => { + mockStreamText.mockImplementation(() => { + throw "string error" + }) + + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) + + // Non-Error values are not detected as throttling → handleAiSdkError path + await expect(async () => { + for await (const _chunk of generator) { + // should throw + } + }).rejects.toThrow("Bedrock: string error") + }) + + it("should handle errors with unusual structure gracefully", async () => { + const unusualError = { message: "Error with unusual structure" } + mockGenerateText.mockRejectedValueOnce(unusualError) + + try { + await handler.completePrompt("test") + throw new Error("Expected error to be thrown") + } catch (error: any) { + // handleAiSdkError wraps with "Bedrock: ..." + expect(error.message).toContain("Bedrock:") + expect(error.message).not.toContain("undefined") + } + }) + + it("should handle concurrent throttling errors in streaming context", async () => { + const throttlingError = createMockError({ + message: "Bedrock is unable to process your request", + status: 429, + }) + + mockStreamText.mockImplementation(() => { + throw throttlingError + }) + + // Execute multiple concurrent streaming requests + const promises = Array.from({ length: 3 }, async () => { + const localHandler = new AwsBedrockHandler({ + apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", + awsAccessKey: "test-access-key", + awsSecretKey: "test-secret-key", + awsRegion: "us-east-1", + }) + const gen = localHandler.createMessage("system", [{ role: "user", content: "test" }]) + for await (const _chunk of gen) { + // should throw + } + }) + + const results = await Promise.allSettled(promises) + results.forEach((result) => { + expect(result.status).toBe("rejected") + if (result.status === "rejected") { + // Throttling errors are re-thrown with original message + expect(result.reason.message).toBe("Bedrock is unable to process your request") + } + }) + }) + }) }) diff --git a/src/api/providers/__tests__/bedrock-inference-profiles.spec.ts b/src/api/providers/__tests__/bedrock-inference-profiles.spec.ts index dee3af3b91..131e462f09 100644 --- a/src/api/providers/__tests__/bedrock-inference-profiles.spec.ts +++ b/src/api/providers/__tests__/bedrock-inference-profiles.spec.ts @@ -4,18 +4,6 @@ import { AWS_INFERENCE_PROFILE_MAPPING } from "@roo-code/types" import { AwsBedrockHandler } from "../bedrock" import { ApiHandlerOptions } from "../../../shared/api" -// Mock AWS SDK -vitest.mock("@aws-sdk/client-bedrock-runtime", () => { - return { - BedrockRuntimeClient: vitest.fn().mockImplementation(() => ({ - send: vitest.fn(), - config: { region: "us-east-1" }, - })), - ConverseCommand: vitest.fn(), - ConverseStreamCommand: vitest.fn(), - } -}) - describe("Amazon Bedrock Inference Profiles", () => { // Helper function to create a handler with specific options const createHandler = (options: Partial = {}) => { diff --git a/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts b/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts index fe16ea89eb..63322d988e 100644 --- a/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts +++ b/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts @@ -1,350 +1,198 @@ // npx vitest run src/api/providers/__tests__/bedrock-invokedModelId.spec.ts -import { ApiHandlerOptions } from "../../../shared/api" +// Mock TelemetryService before other imports +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: vi.fn(), + }, + }, +})) -import { AwsBedrockHandler, StreamEvent } from "../bedrock" - -// Mock AWS SDK credential providers and Bedrock client -vitest.mock("@aws-sdk/credential-providers", () => ({ - fromIni: vitest.fn().mockReturnValue({ +// Mock AWS SDK credential providers +vi.mock("@aws-sdk/credential-providers", () => ({ + fromIni: vi.fn().mockReturnValue({ accessKeyId: "profile-access-key", secretAccessKey: "profile-secret-key", }), })) -// Mock Smithy client -vitest.mock("@smithy/smithy-client", () => ({ - throwDefaultError: vitest.fn(), +// Use vi.hoisted to define mock functions for AI SDK +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), })) -// Create a mock send function that we can reference -const mockSend = vitest.fn().mockImplementation(async () => { +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - $metadata: { - httpStatusCode: 200, - requestId: "mock-request-id", - }, - stream: { - [Symbol.asyncIterator]: async function* () { - yield { - metadata: { - usage: { - inputTokens: 100, - outputTokens: 200, - }, - }, - } - }, - }, + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) -// Mock AWS SDK modules -vitest.mock("@aws-sdk/client-bedrock-runtime", () => { - return { - BedrockRuntimeClient: vitest.fn().mockImplementation(() => ({ - send: mockSend, - config: { region: "us-east-1" }, - middlewareStack: { - clone: () => ({ resolve: () => {} }), - use: () => {}, - }, - })), - ConverseStreamCommand: vitest.fn((params) => ({ - ...params, - input: params, - middlewareStack: { - clone: () => ({ resolve: () => {} }), - use: () => {}, - }, - })), - ConverseCommand: vitest.fn((params) => ({ - ...params, - input: params, - middlewareStack: { - clone: () => ({ resolve: () => {} }), - use: () => {}, - }, - })), - } -}) +vi.mock("@ai-sdk/amazon-bedrock", () => ({ + createAmazonBedrock: vi.fn(() => vi.fn(() => ({ modelId: "test", provider: "bedrock" }))), +})) + +import { AwsBedrockHandler } from "../bedrock" +import { bedrockModels } from "@roo-code/types" describe("AwsBedrockHandler with invokedModelId", () => { beforeEach(() => { - vitest.clearAllMocks() + vi.clearAllMocks() }) - // Helper function to create a mock async iterable stream - function createMockStream(events: StreamEvent[]) { - return { - [Symbol.asyncIterator]: async function* () { - for (const event of events) { - yield event - } - // Always yield a metadata event at the end - yield { - metadata: { - usage: { - inputTokens: 100, - outputTokens: 200, + /** + * Helper: set up mockStreamText to return a stream whose resolved + * `providerMetadata` contains the given `invokedModelId` in the + * `bedrock.trace.promptRouter` path. + */ + function setupMockStreamWithInvokedModelId(invokedModelId?: string) { + async function* mockFullStream() { + yield { type: "text-delta", text: "Hello" } + yield { type: "text-delta", text: ", world!" } + } + + const providerMetadata = invokedModelId + ? { + bedrock: { + trace: { + promptRouter: { + invokedModelId, + }, }, }, } - }, - } + : {} + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 200 }), + providerMetadata: Promise.resolve(providerMetadata), + }) } - it("should update costModelConfig when invokedModelId is present in the stream", async () => { - // Create a handler with a custom ARN - const mockOptions: ApiHandlerOptions = { + it("should update costModelConfig when invokedModelId is present in providerMetadata", async () => { + // Create a handler with a custom ARN (prompt router) + const handler = new AwsBedrockHandler({ awsAccessKey: "test-access-key", awsSecretKey: "test-secret-key", awsRegion: "us-east-1", awsCustomArn: "arn:aws:bedrock:us-west-2:123456789:default-prompt-router/anthropic.claude:1", - } - - const handler = new AwsBedrockHandler(mockOptions) - - // Verify that getModel returns the updated model info - const initialModel = handler.getModel() - //the default prompt router model has an input price of 3. After the stream is handled it should be updated to 8 - expect(initialModel.info.inputPrice).toBe(3) - - // Create a spy on the getModel - const getModelByIdSpy = vitest.spyOn(handler, "getModelById") - - // Mock the stream to include an event with invokedModelId and usage metadata - mockSend.mockImplementationOnce(async () => { - return { - stream: createMockStream([ - // First event with invokedModelId and usage metadata - { - trace: { - promptRouter: { - invokedModelId: - "arn:aws:bedrock:us-west-2:699475926481:inference-profile/us.anthropic.claude-3-opus-20240229-v1:0", - usage: { - inputTokens: 150, - outputTokens: 250, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }, - }, - }, - }, - { - contentBlockStart: { - start: { - text: "Hello", - }, - contentBlockIndex: 0, - }, - }, - { - contentBlockDelta: { - delta: { - text: ", world!", - }, - contentBlockIndex: 0, - }, - }, - ]), - } }) - // Create a message generator - const messageGenerator = handler.createMessage("system prompt", [{ role: "user", content: "user message" }]) + // The default prompt router model should use sonnet pricing (inputPrice: 3) + const initialModel = handler.getModel() + expect(initialModel.info.inputPrice).toBe(3) - // Collect all yielded events to verify usage events + // Spy on getModelById to verify the invoked model is looked up + const getModelByIdSpy = vi.spyOn(handler, "getModelById") + + // Set up stream to include an invokedModelId pointing to Claude 3 Opus + setupMockStreamWithInvokedModelId( + "arn:aws:bedrock:us-west-2:699475926481:inference-profile/us.anthropic.claude-3-opus-20240229-v1:0", + ) + + // Consume the generator const events = [] - for await (const event of messageGenerator) { + for await (const event of handler.createMessage("system prompt", [{ role: "user", content: "user message" }])) { events.push(event) } - // Verify that getModelById was called with the id, not the full arn + // Verify that getModelById was called with the parsed model id and type expect(getModelByIdSpy).toHaveBeenCalledWith("anthropic.claude-3-opus-20240229-v1:0", "inference-profile") - // Verify that getModel returns the updated model info + // After processing, getModel should return the invoked model's pricing (Opus: inputPrice 15) const costModel = handler.getModel() - //expect(costModel.id).toBe("anthropic.claude-3-5-sonnet-20240620-v1:0") expect(costModel.info.inputPrice).toBe(15) - // Verify that a usage event was emitted after updating the costModelConfig - const usageEvents = events.filter((event) => event.type === "usage") + // Verify that a usage event was emitted + const usageEvents = events.filter((e: any) => e.type === "usage") expect(usageEvents.length).toBeGreaterThanOrEqual(1) - // The last usage event should have the token counts from the metadata - const lastUsageEvent = usageEvents[usageEvents.length - 1] - // Expect the usage event to include all token information + // The usage event should contain the token counts + const lastUsageEvent = usageEvents[usageEvents.length - 1] as any expect(lastUsageEvent).toMatchObject({ type: "usage", inputTokens: 100, outputTokens: 200, - // Cache tokens may be present with default values - cacheReadTokens: expect.any(Number), - cacheWriteTokens: expect.any(Number), }) }) it("should not update costModelConfig when invokedModelId is not present", async () => { - // Create a handler with default settings - const mockOptions: ApiHandlerOptions = { + const handler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", awsSecretKey: "test-secret-key", awsRegion: "us-east-1", - } + }) - const handler = new AwsBedrockHandler(mockOptions) - - // Store the initial model configuration const initialModelConfig = handler.getModel() expect(initialModelConfig.id).toBe("anthropic.claude-3-5-sonnet-20241022-v2:0") - // Mock the stream without an invokedModelId event - mockSend.mockImplementationOnce(async () => { - return { - stream: createMockStream([ - // Some content events but no invokedModelId - { - contentBlockStart: { - start: { - text: "Hello", - }, - contentBlockIndex: 0, - }, - }, - { - contentBlockDelta: { - delta: { - text: ", world!", - }, - contentBlockIndex: 0, - }, - }, - ]), - } - }) - - // Create a message generator - const messageGenerator = handler.createMessage("system prompt", [{ role: "user", content: "user message" }]) + // Set up stream WITHOUT an invokedModelId + setupMockStreamWithInvokedModelId(undefined) // Consume the generator - for await (const _ of messageGenerator) { - // Just consume the messages + for await (const _ of handler.createMessage("system prompt", [{ role: "user", content: "user message" }])) { + // Just consume } - // Verify that getModel returns the original model info (unchanged) + // Model should remain unchanged const costModel = handler.getModel() expect(costModel.id).toBe("anthropic.claude-3-5-sonnet-20241022-v2:0") - expect(costModel).toEqual(initialModelConfig) + expect(costModel.info.inputPrice).toBe(initialModelConfig.info.inputPrice) }) it("should handle invalid invokedModelId format gracefully", async () => { - // Create a handler with default settings - const mockOptions: ApiHandlerOptions = { + const handler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", awsSecretKey: "test-secret-key", awsRegion: "us-east-1", - } - - const handler = new AwsBedrockHandler(mockOptions) - - // Mock the stream with an invalid invokedModelId - mockSend.mockImplementationOnce(async () => { - return { - stream: createMockStream([ - // Event with invalid invokedModelId format - { - trace: { - promptRouter: { - invokedModelId: "invalid-format-not-an-arn", - }, - }, - }, - // Some content events - { - contentBlockStart: { - start: { - text: "Hello", - }, - contentBlockIndex: 0, - }, - }, - ]), - } }) - // Create a message generator - const messageGenerator = handler.createMessage("system prompt", [{ role: "user", content: "user message" }]) + // Set up stream with an invalid (non-ARN) invokedModelId + setupMockStreamWithInvokedModelId("invalid-format-not-an-arn") - // Consume the generator - for await (const _ of messageGenerator) { - // Just consume the messages + // Consume the generator — should not throw + for await (const _ of handler.createMessage("system prompt", [{ role: "user", content: "user message" }])) { + // Just consume } - // Verify that getModel returns the original model info + // Model should remain unchanged (the parseArn call should fail gracefully) const costModel = handler.getModel() expect(costModel.id).toBe("anthropic.claude-3-5-sonnet-20241022-v2:0") }) - it("should handle errors during invokedModelId processing", async () => { - // Create a handler with default settings - const mockOptions: ApiHandlerOptions = { - apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", + it("should use the invoked model's pricing for totalCost calculation", async () => { + const handler = new AwsBedrockHandler({ awsAccessKey: "test-access-key", awsSecretKey: "test-secret-key", awsRegion: "us-east-1", - } - - const handler = new AwsBedrockHandler(mockOptions) - - // Mock the stream with a valid invokedModelId - mockSend.mockImplementationOnce(async () => { - return { - stream: createMockStream([ - // Event with valid invokedModelId - { - trace: { - promptRouter: { - invokedModelId: - "arn:aws:bedrock:us-east-1:123456789:foundation-model/anthropic.claude-3-sonnet-20240229-v1:0", - }, - }, - }, - ]), - } + awsCustomArn: "arn:aws:bedrock:us-west-2:123456789:default-prompt-router/anthropic.claude:1", }) - // Mock getModel to throw an error when called with the model name - vitest.spyOn(handler, "getModel").mockImplementation((modelName?: string) => { - if (modelName === "anthropic.claude-3-sonnet-20240229-v1:0") { - throw new Error("Test error during model lookup") - } + // Set up stream to include Opus as the invoked model + setupMockStreamWithInvokedModelId( + "arn:aws:bedrock:us-west-2:699475926481:foundation-model/anthropic.claude-3-opus-20240229-v1:0", + ) - // Default return value for initial call - return { - id: "anthropic.claude-3-5-sonnet-20241022-v2:0", - info: { - maxTokens: 4096, - contextWindow: 128_000, - supportsPromptCache: false, - supportsImages: true, - }, - } - }) - - // Create a message generator - const messageGenerator = handler.createMessage("system prompt", [{ role: "user", content: "user message" }]) - - // Consume the generator - for await (const _ of messageGenerator) { - // Just consume the messages + const events = [] + for await (const event of handler.createMessage("system prompt", [{ role: "user", content: "user message" }])) { + events.push(event) } - // Verify that getModel returns the original model info - const costModel = handler.getModel() - expect(costModel.id).toBe("anthropic.claude-3-5-sonnet-20241022-v2:0") + const usageEvent = events.find((e: any) => e.type === "usage") as any + expect(usageEvent).toBeDefined() + + // Calculate expected cost based on Opus pricing ($15 / 1M input, $75 / 1M output) + const opusInfo = bedrockModels["anthropic.claude-3-opus-20240229-v1:0"] + const expectedCost = + (100 * (opusInfo.inputPrice ?? 0)) / 1_000_000 + (200 * (opusInfo.outputPrice ?? 0)) / 1_000_000 + + expect(usageEvent.totalCost).toBeCloseTo(expectedCost, 10) }) }) diff --git a/src/api/providers/__tests__/bedrock-native-tools.spec.ts b/src/api/providers/__tests__/bedrock-native-tools.spec.ts index e95b2c34b6..74439f00d5 100644 --- a/src/api/providers/__tests__/bedrock-native-tools.spec.ts +++ b/src/api/providers/__tests__/bedrock-native-tools.spec.ts @@ -1,3 +1,12 @@ +// Mock TelemetryService before other imports +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: vi.fn(), + }, + }, +})) + // Mock AWS SDK credential providers vi.mock("@aws-sdk/credential-providers", () => { const mockFromIni = vi.fn().mockReturnValue({ @@ -7,28 +16,27 @@ vi.mock("@aws-sdk/credential-providers", () => { return { fromIni: mockFromIni } }) -// Mock BedrockRuntimeClient and ConverseStreamCommand -const mockSend = vi.fn() +// Use vi.hoisted to define mock functions for AI SDK +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) -vi.mock("@aws-sdk/client-bedrock-runtime", () => { +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - BedrockRuntimeClient: vi.fn().mockImplementation(() => ({ - send: mockSend, - config: { region: "us-east-1" }, - })), - ConverseStreamCommand: vi.fn((params) => ({ - ...params, - input: params, - })), - ConverseCommand: vi.fn(), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) -import { AwsBedrockHandler } from "../bedrock" -import { ConverseStreamCommand } from "@aws-sdk/client-bedrock-runtime" -import type { ApiHandlerCreateMessageMetadata } from "../../index" +vi.mock("@ai-sdk/amazon-bedrock", () => ({ + createAmazonBedrock: vi.fn(() => vi.fn(() => ({ modelId: "test", provider: "bedrock" }))), +})) -const mockConverseStreamCommand = vi.mocked(ConverseStreamCommand) +import { AwsBedrockHandler } from "../bedrock" +import type { ApiHandlerCreateMessageMetadata } from "../../index" // Test tool definitions in OpenAI format const testTools = [ @@ -63,525 +71,365 @@ const testTools = [ }, ] -describe("AwsBedrockHandler Native Tool Calling", () => { +/** + * Helper: set up mockStreamText to return a simple text-delta stream. + */ +function setupMockStreamText() { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response text" } + } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + providerMetadata: Promise.resolve({}), + }) +} + +/** + * Helper: set up mockStreamText to return a stream with tool-call events. + */ +function setupMockStreamTextWithToolCall() { + async function* mockFullStream() { + yield { + type: "tool-input-start", + id: "tool-123", + toolName: "read_file", + } + yield { + type: "tool-input-delta", + id: "tool-123", + delta: '{"path": "/test.txt"}', + } + yield { + type: "tool-input-end", + id: "tool-123", + } + } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + providerMetadata: Promise.resolve({}), + }) +} + +describe("AwsBedrockHandler Native Tool Calling (AI SDK)", () => { let handler: AwsBedrockHandler beforeEach(() => { vi.clearAllMocks() - // Create handler with a model that supports native tools handler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", awsSecretKey: "test-secret-key", awsRegion: "us-east-1", }) - - // Mock the stream response - mockSend.mockResolvedValue({ - stream: [], - }) }) - describe("convertToolsForBedrock", () => { - it("should convert OpenAI tools to Bedrock format", () => { - // Access private method - const convertToolsForBedrock = (handler as any).convertToolsForBedrock.bind(handler) - - const bedrockTools = convertToolsForBedrock(testTools) - - expect(bedrockTools).toHaveLength(2) - - // Check structure and key properties (normalizeToolSchema adds additionalProperties: false) - const tool = bedrockTools[0] - expect(tool.toolSpec.name).toBe("read_file") - expect(tool.toolSpec.description).toBe("Read a file from the filesystem") - expect(tool.toolSpec.inputSchema.json.type).toBe("object") - expect(tool.toolSpec.inputSchema.json.properties.path.type).toBe("string") - expect(tool.toolSpec.inputSchema.json.properties.path.description).toBe("The path to the file") - expect(tool.toolSpec.inputSchema.json.required).toEqual(["path"]) - // normalizeToolSchema adds additionalProperties: false by default - expect(tool.toolSpec.inputSchema.json.additionalProperties).toBe(false) - }) - - it("should transform type arrays to anyOf for JSON Schema 2020-12 compliance", () => { - const convertToolsForBedrock = (handler as any).convertToolsForBedrock.bind(handler) - - // Tools with type: ["string", "null"] syntax (valid in draft-07 but not 2020-12) - const toolsWithNullableTypes = [ - { - type: "function" as const, - function: { - name: "execute_command", - description: "Execute a command", - parameters: { - type: "object", - properties: { - command: { type: "string", description: "The command to execute" }, - cwd: { - type: ["string", "null"], - description: "Working directory (optional)", - }, - }, - required: ["command", "cwd"], - }, - }, - }, - { - type: "function" as const, - function: { - name: "read_file", - description: "Read files", - parameters: { - type: "object", - properties: { - path: { type: "string" }, - indentation: { - type: ["object", "null"], - properties: { - anchor_line: { - type: ["integer", "null"], - description: "Optional anchor line", - }, - }, - }, - }, - required: ["path"], - }, - }, - }, - ] - - const bedrockTools = convertToolsForBedrock(toolsWithNullableTypes) - - expect(bedrockTools).toHaveLength(2) - - // First tool: cwd should be transformed from type: ["string", "null"] to anyOf - const executeCommandSchema = bedrockTools[0].toolSpec.inputSchema.json as any - expect(executeCommandSchema.properties.cwd.anyOf).toEqual([{ type: "string" }, { type: "null" }]) - expect(executeCommandSchema.properties.cwd.type).toBeUndefined() - expect(executeCommandSchema.properties.cwd.description).toBe("Working directory (optional)") - - // Second tool: nested nullable object should be transformed from type: ["object", "null"] to anyOf - const readFileSchema = bedrockTools[1].toolSpec.inputSchema.json as any - const indentation = readFileSchema.properties.indentation - expect(indentation.anyOf).toBeDefined() - expect(indentation.type).toBeUndefined() - // Object-level schema properties are preserved at the root, not inside the anyOf object variant - expect(indentation.additionalProperties).toBe(false) - expect(indentation.properties.anchor_line.anyOf).toEqual([{ type: "integer" }, { type: "null" }]) - }) - - it("should filter non-function tools", () => { - const convertToolsForBedrock = (handler as any).convertToolsForBedrock.bind(handler) - - const mixedTools = [ - ...testTools, - { type: "other" as any, something: {} }, // Should be filtered out - ] - - const bedrockTools = convertToolsForBedrock(mixedTools) - - expect(bedrockTools).toHaveLength(2) - }) - }) - - describe("convertToolChoiceForBedrock", () => { - it("should convert 'auto' to Bedrock auto format", () => { - const convertToolChoiceForBedrock = (handler as any).convertToolChoiceForBedrock.bind(handler) - - const result = convertToolChoiceForBedrock("auto") - - expect(result).toEqual({ auto: {} }) - }) - - it("should convert 'required' to Bedrock any format", () => { - const convertToolChoiceForBedrock = (handler as any).convertToolChoiceForBedrock.bind(handler) - - const result = convertToolChoiceForBedrock("required") - - expect(result).toEqual({ any: {} }) - }) - - it("should return undefined for 'none'", () => { - const convertToolChoiceForBedrock = (handler as any).convertToolChoiceForBedrock.bind(handler) - - const result = convertToolChoiceForBedrock("none") - - expect(result).toBeUndefined() - }) - - it("should convert specific tool choice to Bedrock tool format", () => { - const convertToolChoiceForBedrock = (handler as any).convertToolChoiceForBedrock.bind(handler) - - const result = convertToolChoiceForBedrock({ - type: "function", - function: { name: "read_file" }, - }) - - expect(result).toEqual({ - tool: { - name: "read_file", - }, - }) - }) - - it("should default to auto for undefined toolChoice", () => { - const convertToolChoiceForBedrock = (handler as any).convertToolChoiceForBedrock.bind(handler) - - const result = convertToolChoiceForBedrock(undefined) - - expect(result).toEqual({ auto: {} }) - }) - }) - - describe("createMessage with native tools", () => { - it("should include toolConfig when tools are provided", async () => { - const handlerWithNativeTools = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", - }) + describe("tools passed to streamText", () => { + it("should pass converted tools to streamText when tools are provided", async () => { + setupMockStreamText() const metadata: ApiHandlerCreateMessageMetadata = { taskId: "test-task", tools: testTools, } - const generator = handlerWithNativeTools.createMessage( + const generator = handler.createMessage( "You are a helpful assistant.", [{ role: "user", content: "Read the file at /test.txt" }], metadata, ) - await generator.next() + // Drain the generator + for await (const _chunk of generator) { + /* consume */ + } - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - expect(commandArg.toolConfig).toBeDefined() - expect(commandArg.toolConfig.tools).toHaveLength(2) - expect(commandArg.toolConfig.tools[0].toolSpec.name).toBe("read_file") - expect(commandArg.toolConfig.toolChoice).toEqual({ auto: {} }) + // tools should be defined and contain AI SDK tool objects keyed by name + expect(callArgs.tools).toBeDefined() + expect(callArgs.tools.read_file).toBeDefined() + expect(callArgs.tools.write_file).toBeDefined() }) - it("should always include toolConfig (tools are always present after PR #10841)", async () => { - const handlerWithNativeTools = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", - }) + it("should pass undefined tools when no tools are provided in metadata", async () => { + setupMockStreamText() const metadata: ApiHandlerCreateMessageMetadata = { taskId: "test-task", - // Even without explicit tools, tools are always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS) + // No tools } - const generator = handlerWithNativeTools.createMessage( - "You are a helpful assistant.", - [{ role: "user", content: "Read the file at /test.txt" }], - metadata, - ) - - await generator.next() - - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any - - // Tools are now always present - expect(commandArg.toolConfig).toBeDefined() - expect(commandArg.toolConfig.tools).toBeDefined() - expect(commandArg.toolConfig.toolChoice).toEqual({ auto: {} }) - }) - - it("should include toolConfig with undefined toolChoice when tool_choice is none", async () => { - const handlerWithNativeTools = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", - }) - - const metadata: ApiHandlerCreateMessageMetadata = { - taskId: "test-task", - tools: testTools, - tool_choice: "none", // Explicitly disable tool use - } - - const generator = handlerWithNativeTools.createMessage( - "You are a helpful assistant.", - [{ role: "user", content: "Read the file at /test.txt" }], - metadata, - ) - - await generator.next() - - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any - - // toolConfig is still provided but toolChoice is undefined for "none" - expect(commandArg.toolConfig).toBeDefined() - expect(commandArg.toolConfig.toolChoice).toBeUndefined() - }) - - it("should include fine-grained tool streaming beta for Claude models with native tools", async () => { - const handlerWithNativeTools = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", - }) - - const metadata: ApiHandlerCreateMessageMetadata = { - taskId: "test-task", - tools: testTools, - } - - const generator = handlerWithNativeTools.createMessage( - "You are a helpful assistant.", - [{ role: "user", content: "Read the file at /test.txt" }], - metadata, - ) - - await generator.next() - - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any - - // Should include the fine-grained tool streaming beta - expect(commandArg.additionalModelRequestFields).toBeDefined() - expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain( - "fine-grained-tool-streaming-2025-05-14", - ) - }) - - it("should always include fine-grained tool streaming beta for Claude models", async () => { - const handlerWithNativeTools = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", - }) - - const metadata: ApiHandlerCreateMessageMetadata = { - taskId: "test-task", - // No tools provided - } - - const generator = handlerWithNativeTools.createMessage( + const generator = handler.createMessage( "You are a helpful assistant.", [{ role: "user", content: "Hello" }], metadata, ) - await generator.next() + for await (const _chunk of generator) { + /* consume */ + } - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - // Should always include anthropic_beta with fine-grained-tool-streaming for Claude models - expect(commandArg.additionalModelRequestFields).toBeDefined() - expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain( - "fine-grained-tool-streaming-2025-05-14", + // When no tools are provided, tools should be undefined + expect(callArgs.tools).toBeUndefined() + }) + + it("should filter non-function tools before passing to streamText", async () => { + setupMockStreamText() + + const mixedTools: any[] = [ + ...testTools, + { type: "other", something: {} }, // Should be filtered out + ] + + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: mixedTools as any, + } + + const generator = handler.createMessage( + "You are a helpful assistant.", + [{ role: "user", content: "Read a file" }], + metadata, ) + + for await (const _chunk of generator) { + /* consume */ + } + + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + + // Only function tools should be present (keyed by name) + expect(callArgs.tools).toBeDefined() + expect(Object.keys(callArgs.tools)).toHaveLength(2) + expect(callArgs.tools.read_file).toBeDefined() + expect(callArgs.tools.write_file).toBeDefined() + }) + }) + + describe("toolChoice passed to streamText", () => { + it("should default toolChoice to undefined when tool_choice is not specified", async () => { + setupMockStreamText() + + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: testTools, + // No tool_choice + } + + const generator = handler.createMessage( + "You are a helpful assistant.", + [{ role: "user", content: "Read the file" }], + metadata, + ) + + for await (const _chunk of generator) { + /* consume */ + } + + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + + // mapToolChoice(undefined) returns undefined + expect(callArgs.toolChoice).toBeUndefined() + }) + + it("should pass 'auto' toolChoice when tool_choice is 'auto'", async () => { + setupMockStreamText() + + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: testTools, + tool_choice: "auto", + } + + const generator = handler.createMessage( + "You are a helpful assistant.", + [{ role: "user", content: "Read the file" }], + metadata, + ) + + for await (const _chunk of generator) { + /* consume */ + } + + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + + expect(callArgs.toolChoice).toBe("auto") + }) + + it("should pass 'none' toolChoice when tool_choice is 'none'", async () => { + setupMockStreamText() + + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: testTools, + tool_choice: "none", + } + + const generator = handler.createMessage( + "You are a helpful assistant.", + [{ role: "user", content: "Read the file" }], + metadata, + ) + + for await (const _chunk of generator) { + /* consume */ + } + + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + + expect(callArgs.toolChoice).toBe("none") + }) + + it("should pass 'required' toolChoice when tool_choice is 'required'", async () => { + setupMockStreamText() + + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: testTools, + tool_choice: "required", + } + + const generator = handler.createMessage( + "You are a helpful assistant.", + [{ role: "user", content: "Read the file" }], + metadata, + ) + + for await (const _chunk of generator) { + /* consume */ + } + + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + + expect(callArgs.toolChoice).toBe("required") + }) + + it("should pass specific tool choice when tool_choice names a function", async () => { + setupMockStreamText() + + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: testTools, + tool_choice: { + type: "function", + function: { name: "read_file" }, + }, + } + + const generator = handler.createMessage( + "You are a helpful assistant.", + [{ role: "user", content: "Read the file" }], + metadata, + ) + + for await (const _chunk of generator) { + /* consume */ + } + + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + + expect(callArgs.toolChoice).toEqual({ + type: "tool", + toolName: "read_file", + }) }) }) describe("tool call streaming events", () => { - it("should yield tool_call_partial for toolUse block start", async () => { - const handlerWithNativeTools = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", - }) + it("should yield tool_call_start, tool_call_delta, and tool_call_end for tool input stream", async () => { + setupMockStreamTextWithToolCall() - // Mock stream with tool use events - mockSend.mockResolvedValue({ - stream: (async function* () { - yield { - contentBlockStart: { - contentBlockIndex: 0, - start: { - toolUse: { - toolUseId: "tool-123", - name: "read_file", - }, - }, - }, - } - yield { - contentBlockDelta: { - contentBlockIndex: 0, - delta: { - toolUse: { - input: '{"path": "/test.txt"}', - }, - }, - }, - } - yield { - metadata: { - usage: { - inputTokens: 100, - outputTokens: 50, - }, - }, - } - })(), - }) + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: testTools, + } - const generator = handlerWithNativeTools.createMessage("You are a helpful assistant.", [ - { role: "user", content: "Read the file" }, - ]) + const generator = handler.createMessage( + "You are a helpful assistant.", + [{ role: "user", content: "Read the file" }], + metadata, + ) const results: any[] = [] for await (const chunk of generator) { results.push(chunk) } - // Should have tool_call_partial chunks - const toolCallChunks = results.filter((r) => r.type === "tool_call_partial") - expect(toolCallChunks).toHaveLength(2) - - // First chunk should have id and name - expect(toolCallChunks[0]).toEqual({ - type: "tool_call_partial", - index: 0, + // Should have tool_call_start chunk + const startChunks = results.filter((r) => r.type === "tool_call_start") + expect(startChunks).toHaveLength(1) + expect(startChunks[0]).toEqual({ + type: "tool_call_start", id: "tool-123", name: "read_file", - arguments: undefined, }) - // Second chunk should have arguments - expect(toolCallChunks[1]).toEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: '{"path": "/test.txt"}', + // Should have tool_call_delta chunk + const deltaChunks = results.filter((r) => r.type === "tool_call_delta") + expect(deltaChunks).toHaveLength(1) + expect(deltaChunks[0]).toEqual({ + type: "tool_call_delta", + id: "tool-123", + delta: '{"path": "/test.txt"}', + }) + + // Should have tool_call_end chunk + const endChunks = results.filter((r) => r.type === "tool_call_end") + expect(endChunks).toHaveLength(1) + expect(endChunks[0]).toEqual({ + type: "tool_call_end", + id: "tool-123", }) }) - it("should yield tool_call_partial for contentBlock toolUse structure", async () => { - const handlerWithNativeTools = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", + it("should handle mixed text and tool use content in stream", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Let me read that file for you." } + yield { type: "text-delta", text: " Here's what I found:" } + yield { + type: "tool-input-start", + id: "tool-789", + toolName: "read_file", + } + yield { + type: "tool-input-delta", + id: "tool-789", + delta: '{"path": "/example.txt"}', + } + yield { + type: "tool-input-end", + id: "tool-789", + } + } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 150, outputTokens: 75 }), + providerMetadata: Promise.resolve({}), }) - // Mock stream with alternative tool use structure - mockSend.mockResolvedValue({ - stream: (async function* () { - yield { - contentBlockStart: { - contentBlockIndex: 0, - contentBlock: { - toolUse: { - toolUseId: "tool-456", - name: "write_file", - }, - }, - }, - } - yield { - metadata: { - usage: { - inputTokens: 100, - outputTokens: 50, - }, - }, - } - })(), - }) - - const generator = handlerWithNativeTools.createMessage("You are a helpful assistant.", [ - { role: "user", content: "Write a file" }, - ]) - - const results: any[] = [] - for await (const chunk of generator) { - results.push(chunk) + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: testTools, } - // Should have tool_call_partial chunk - const toolCallChunks = results.filter((r) => r.type === "tool_call_partial") - expect(toolCallChunks).toHaveLength(1) - - expect(toolCallChunks[0]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "tool-456", - name: "write_file", - arguments: undefined, - }) - }) - - it("should handle mixed text and tool use content", async () => { - const handlerWithNativeTools = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", - }) - - // Mock stream with mixed content - mockSend.mockResolvedValue({ - stream: (async function* () { - yield { - contentBlockStart: { - contentBlockIndex: 0, - start: { - text: "Let me read that file for you.", - }, - }, - } - yield { - contentBlockDelta: { - contentBlockIndex: 0, - delta: { - text: " Here's what I found:", - }, - }, - } - yield { - contentBlockStart: { - contentBlockIndex: 1, - start: { - toolUse: { - toolUseId: "tool-789", - name: "read_file", - }, - }, - }, - } - yield { - contentBlockDelta: { - contentBlockIndex: 1, - delta: { - toolUse: { - input: '{"path": "/example.txt"}', - }, - }, - }, - } - yield { - metadata: { - usage: { - inputTokens: 150, - outputTokens: 75, - }, - }, - } - })(), - }) - - const generator = handlerWithNativeTools.createMessage("You are a helpful assistant.", [ - { role: "user", content: "Read the example file" }, - ]) + const generator = handler.createMessage( + "You are a helpful assistant.", + [{ role: "user", content: "Read the example file" }], + metadata, + ) const results: any[] = [] for await (const chunk of generator) { @@ -594,11 +442,158 @@ describe("AwsBedrockHandler Native Tool Calling", () => { expect(textChunks[0].text).toBe("Let me read that file for you.") expect(textChunks[1].text).toBe(" Here's what I found:") - // Should have tool call chunks - const toolCallChunks = results.filter((r) => r.type === "tool_call_partial") - expect(toolCallChunks).toHaveLength(2) - expect(toolCallChunks[0].name).toBe("read_file") - expect(toolCallChunks[1].arguments).toBe('{"path": "/example.txt"}') + // Should have tool call start + const startChunks = results.filter((r) => r.type === "tool_call_start") + expect(startChunks).toHaveLength(1) + expect(startChunks[0].name).toBe("read_file") + + // Should have tool call delta + const deltaChunks = results.filter((r) => r.type === "tool_call_delta") + expect(deltaChunks).toHaveLength(1) + expect(deltaChunks[0].delta).toBe('{"path": "/example.txt"}') + + // Should have tool call end + const endChunks = results.filter((r) => r.type === "tool_call_end") + expect(endChunks).toHaveLength(1) + }) + + it("should handle multiple tool calls in a single stream", async () => { + async function* mockFullStream() { + yield { + type: "tool-input-start", + id: "tool-1", + toolName: "read_file", + } + yield { + type: "tool-input-delta", + id: "tool-1", + delta: '{"path": "/file1.txt"}', + } + yield { + type: "tool-input-end", + id: "tool-1", + } + yield { + type: "tool-input-start", + id: "tool-2", + toolName: "write_file", + } + yield { + type: "tool-input-delta", + id: "tool-2", + delta: '{"path": "/file2.txt", "content": "hello"}', + } + yield { + type: "tool-input-end", + id: "tool-2", + } + } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 200, outputTokens: 100 }), + providerMetadata: Promise.resolve({}), + }) + + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: testTools, + } + + const generator = handler.createMessage( + "You are a helpful assistant.", + [{ role: "user", content: "Read and write files" }], + metadata, + ) + + const results: any[] = [] + for await (const chunk of generator) { + results.push(chunk) + } + + // Should have two tool_call_start chunks + const startChunks = results.filter((r) => r.type === "tool_call_start") + expect(startChunks).toHaveLength(2) + expect(startChunks[0].name).toBe("read_file") + expect(startChunks[1].name).toBe("write_file") + + // Should have two tool_call_delta chunks + const deltaChunks = results.filter((r) => r.type === "tool_call_delta") + expect(deltaChunks).toHaveLength(2) + + // Should have two tool_call_end chunks + const endChunks = results.filter((r) => r.type === "tool_call_end") + expect(endChunks).toHaveLength(2) + }) + }) + + describe("tools schema normalization", () => { + it("should apply schema normalization (additionalProperties: false, strict: true) via convertToolsForOpenAI", async () => { + setupMockStreamText() + + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: [ + { + type: "function" as const, + function: { + name: "test_tool", + description: "A test tool", + parameters: { + type: "object", + properties: { + arg1: { type: "string" }, + }, + // Note: no "required" field and no "additionalProperties" + }, + }, + }, + ], + } + + const generator = handler.createMessage( + "You are a helpful assistant.", + [{ role: "user", content: "test" }], + metadata, + ) + + for await (const _chunk of generator) { + /* consume */ + } + + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + + // The AI SDK tools should be keyed by tool name + expect(callArgs.tools).toBeDefined() + expect(callArgs.tools.test_tool).toBeDefined() + }) + }) + + describe("usage metrics with tools", () => { + it("should yield usage chunk after tool call stream completes", async () => { + setupMockStreamTextWithToolCall() + + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: testTools, + } + + const generator = handler.createMessage( + "You are a helpful assistant.", + [{ role: "user", content: "Read a file" }], + metadata, + ) + + const results: any[] = [] + for await (const chunk of generator) { + results.push(chunk) + } + + // Should have a usage chunk at the end + const usageChunks = results.filter((r) => r.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].inputTokens).toBe(100) + expect(usageChunks[0].outputTokens).toBe(50) }) }) }) diff --git a/src/api/providers/__tests__/bedrock-reasoning.spec.ts b/src/api/providers/__tests__/bedrock-reasoning.spec.ts index 9dd271744c..dfe35d4d8e 100644 --- a/src/api/providers/__tests__/bedrock-reasoning.spec.ts +++ b/src/api/providers/__tests__/bedrock-reasoning.spec.ts @@ -1,37 +1,48 @@ -// npx vitest api/providers/__tests__/bedrock-reasoning.test.ts +// npx vitest run api/providers/__tests__/bedrock-reasoning.spec.ts + +// Use vi.hoisted to define mock functions for AI SDK +const { mockStreamText, mockGenerateText, mockCreateAmazonBedrock } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), + mockCreateAmazonBedrock: vi.fn(() => vi.fn(() => ({ modelId: "test", provider: "bedrock" }))), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) + +vi.mock("@ai-sdk/amazon-bedrock", () => ({ + createAmazonBedrock: mockCreateAmazonBedrock, +})) + +// Mock AWS SDK credential providers +vi.mock("@aws-sdk/credential-providers", () => ({ + fromIni: vi.fn().mockReturnValue(async () => ({ + accessKeyId: "profile-access-key", + secretAccessKey: "profile-secret-key", + })), +})) + +vi.mock("../../../utils/logging", () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, +})) import { AwsBedrockHandler } from "../bedrock" -import { BedrockRuntimeClient, ConverseStreamCommand } from "@aws-sdk/client-bedrock-runtime" import { logger } from "../../../utils/logging" -// Mock the AWS SDK -vi.mock("@aws-sdk/client-bedrock-runtime") -vi.mock("../../../utils/logging") - -// Store the command payload for verification -let capturedPayload: any = null - describe("AwsBedrockHandler - Extended Thinking", () => { - let handler: AwsBedrockHandler - let mockSend: ReturnType - beforeEach(() => { - capturedPayload = null - mockSend = vi.fn() - - // Mock ConverseStreamCommand to capture the payload - ;(ConverseStreamCommand as unknown as ReturnType).mockImplementation((payload) => { - capturedPayload = payload - return { - input: payload, - } - }) - ;(BedrockRuntimeClient as unknown as ReturnType).mockImplementation(() => ({ - send: mockSend, - config: { region: "us-east-1" }, - })) - ;(logger.info as unknown as ReturnType).mockImplementation(() => {}) - ;(logger.error as unknown as ReturnType).mockImplementation(() => {}) + vi.clearAllMocks() }) afterEach(() => { @@ -39,8 +50,8 @@ describe("AwsBedrockHandler - Extended Thinking", () => { }) describe("Extended Thinking Support", () => { - it("should include thinking parameter for Claude Sonnet 4 when reasoning is enabled", async () => { - handler = new AwsBedrockHandler({ + it("should include reasoningConfig in providerOptions when reasoning is enabled", async () => { + const handler = new AwsBedrockHandler({ apiProvider: "bedrock", apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0", awsRegion: "us-east-1", @@ -49,35 +60,17 @@ describe("AwsBedrockHandler - Extended Thinking", () => { modelMaxThinkingTokens: 4096, }) - // Mock the stream response - mockSend.mockResolvedValue({ - stream: (async function* () { - yield { - messageStart: { role: "assistant" }, - } - yield { - contentBlockStart: { - content_block: { type: "thinking", thinking: "Let me think..." }, - contentBlockIndex: 0, - }, - } - yield { - contentBlockDelta: { - delta: { type: "thinking_delta", thinking: " about this problem." }, - }, - } - yield { - contentBlockStart: { - start: { text: "Here's the answer:" }, - contentBlockIndex: 1, - }, - } - yield { - metadata: { - usage: { inputTokens: 100, outputTokens: 50 }, - }, - } - })(), + // Mock stream with reasoning content + async function* mockFullStream() { + yield { type: "reasoning", text: "Let me think..." } + yield { type: "reasoning", text: " about this problem." } + yield { type: "text-delta", text: "Here's the answer:" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + providerMetadata: Promise.resolve({}), }) const messages = [{ role: "user" as const, content: "Test message" }] @@ -88,13 +81,14 @@ describe("AwsBedrockHandler - Extended Thinking", () => { chunks.push(chunk) } - // Verify the command was called with the correct payload - expect(mockSend).toHaveBeenCalledTimes(1) - expect(capturedPayload).toBeDefined() - expect(capturedPayload.additionalModelRequestFields).toBeDefined() - expect(capturedPayload.additionalModelRequestFields.thinking).toEqual({ + // Verify streamText was called with providerOptions containing reasoningConfig + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions).toBeDefined() + expect(callArgs.providerOptions.bedrock).toBeDefined() + expect(callArgs.providerOptions.bedrock.reasoningConfig).toEqual({ type: "enabled", - budget_tokens: 4096, // Uses the full modelMaxThinkingTokens value + budgetTokens: 4096, }) // Verify reasoning chunks were yielded @@ -102,110 +96,24 @@ describe("AwsBedrockHandler - Extended Thinking", () => { expect(reasoningChunks).toHaveLength(2) expect(reasoningChunks[0].text).toBe("Let me think...") expect(reasoningChunks[1].text).toBe(" about this problem.") - - // Verify that topP is NOT present when thinking is enabled - expect(capturedPayload.inferenceConfig).not.toHaveProperty("topP") }) - it("should pass thinking parameters from metadata", async () => { - handler = new AwsBedrockHandler({ - apiProvider: "bedrock", - apiModelId: "anthropic.claude-3-7-sonnet-20250219-v1:0", - awsRegion: "us-east-1", - }) - - mockSend.mockResolvedValue({ - stream: (async function* () { - yield { messageStart: { role: "assistant" } } - yield { metadata: { usage: { inputTokens: 100, outputTokens: 50 } } } - })(), - }) - - const messages = [{ role: "user" as const, content: "Test message" }] - const metadata = { - taskId: "test-task", - thinking: { - enabled: true, - maxTokens: 16384, - maxThinkingTokens: 8192, - }, - } - - const stream = handler.createMessage("System prompt", messages, metadata) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Verify the thinking parameter was passed correctly - expect(mockSend).toHaveBeenCalledTimes(1) - expect(capturedPayload).toBeDefined() - expect(capturedPayload.additionalModelRequestFields).toBeDefined() - expect(capturedPayload.additionalModelRequestFields.thinking).toEqual({ - type: "enabled", - budget_tokens: 8192, - }) - - // Verify that topP is NOT present when thinking is enabled via metadata - expect(capturedPayload.inferenceConfig).not.toHaveProperty("topP") - }) - - it("should log when extended thinking is enabled", async () => { - handler = new AwsBedrockHandler({ - apiProvider: "bedrock", - apiModelId: "anthropic.claude-opus-4-20250514-v1:0", - awsRegion: "us-east-1", - enableReasoningEffort: true, - modelMaxThinkingTokens: 5000, - }) - - mockSend.mockResolvedValue({ - stream: (async function* () { - yield { messageStart: { role: "assistant" } } - })(), - }) - - const messages = [{ role: "user" as const, content: "Test" }] - const stream = handler.createMessage("System prompt", messages) - - for await (const chunk of stream) { - // consume stream - } - - // Verify logging - expect(logger.info).toHaveBeenCalledWith( - expect.stringContaining("Extended thinking enabled"), - expect.objectContaining({ - ctx: "bedrock", - modelId: "anthropic.claude-opus-4-20250514-v1:0", - }), - ) - }) - - it("should not include topP when thinking is disabled (global removal)", async () => { - handler = new AwsBedrockHandler({ + it("should not include reasoningConfig when reasoning is disabled", async () => { + const handler = new AwsBedrockHandler({ apiProvider: "bedrock", apiModelId: "anthropic.claude-3-7-sonnet-20250219-v1:0", awsRegion: "us-east-1", // Note: no enableReasoningEffort = true, so thinking is disabled }) - mockSend.mockResolvedValue({ - stream: (async function* () { - yield { messageStart: { role: "assistant" } } - yield { - contentBlockStart: { - start: { text: "Hello" }, - contentBlockIndex: 0, - }, - } - yield { - contentBlockDelta: { - delta: { text: " world" }, - }, - } - yield { metadata: { usage: { inputTokens: 100, outputTokens: 50 } } } - })(), + async function* mockFullStream() { + yield { type: "text-delta", text: "Hello world" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + providerMetadata: Promise.resolve({}), }) const messages = [{ role: "user" as const, content: "Test message" }] @@ -216,43 +124,117 @@ describe("AwsBedrockHandler - Extended Thinking", () => { chunks.push(chunk) } - // Verify that topP is NOT present for any model (removed globally) - expect(mockSend).toHaveBeenCalledTimes(1) - expect(capturedPayload).toBeDefined() - expect(capturedPayload.inferenceConfig).not.toHaveProperty("topP") - - // Verify that additionalModelRequestFields contains fine-grained-tool-streaming for Claude models - expect(capturedPayload.additionalModelRequestFields).toBeDefined() - expect(capturedPayload.additionalModelRequestFields.anthropic_beta).toContain( - "fine-grained-tool-streaming-2025-05-14", - ) + // Verify streamText was called — providerOptions should not contain reasoningConfig + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + const bedrockOpts = callArgs.providerOptions?.bedrock + expect(bedrockOpts?.reasoningConfig).toBeUndefined() }) - it("should enable reasoning when enableReasoningEffort is true in settings", async () => { - handler = new AwsBedrockHandler({ + it("should capture thinking signature from stream providerMetadata", async () => { + const handler = new AwsBedrockHandler({ apiProvider: "bedrock", apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0", awsRegion: "us-east-1", - enableReasoningEffort: true, // This should trigger reasoning + enableReasoningEffort: true, modelMaxThinkingTokens: 4096, }) - mockSend.mockResolvedValue({ - stream: (async function* () { - yield { messageStart: { role: "assistant" } } - yield { - contentBlockStart: { - content_block: { type: "thinking", thinking: "Let me think..." }, - contentBlockIndex: 0, - }, - } - yield { - contentBlockDelta: { - delta: { type: "thinking_delta", thinking: " about this problem." }, - }, - } - yield { metadata: { usage: { inputTokens: 100, outputTokens: 50 } } } - })(), + const testSignature = "test-thinking-signature-abc123" + + // Mock stream with reasoning content that includes a signature in providerMetadata + async function* mockFullStream() { + yield { type: "reasoning", text: "Let me think..." } + // The SDK emits signature as a reasoning-delta with providerMetadata.bedrock.signature + yield { + type: "reasoning", + text: "", + providerMetadata: { bedrock: { signature: testSignature } }, + } + yield { type: "text-delta", text: "Answer" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + providerMetadata: Promise.resolve({}), + }) + + const messages = [{ role: "user" as const, content: "Test message" }] + const stream = handler.createMessage("System prompt", messages) + + for await (const _chunk of stream) { + // consume stream + } + + // Verify thinking signature was captured + expect(handler.getThoughtSignature()).toBe(testSignature) + }) + + it("should capture redacted thinking blocks from stream providerMetadata", async () => { + const handler = new AwsBedrockHandler({ + apiProvider: "bedrock", + apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0", + awsRegion: "us-east-1", + enableReasoningEffort: true, + modelMaxThinkingTokens: 4096, + }) + + const redactedData = "base64-encoded-redacted-data" + + // Mock stream with redacted reasoning content + async function* mockFullStream() { + yield { type: "reasoning", text: "Some thinking..." } + yield { + type: "reasoning", + text: "", + providerMetadata: { bedrock: { redactedData } }, + } + yield { type: "text-delta", text: "Answer" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + providerMetadata: Promise.resolve({}), + }) + + const messages = [{ role: "user" as const, content: "Test message" }] + const stream = handler.createMessage("System prompt", messages) + + for await (const _chunk of stream) { + // consume stream + } + + // Verify redacted thinking blocks were captured + const redactedBlocks = handler.getRedactedThinkingBlocks() + expect(redactedBlocks).toBeDefined() + expect(redactedBlocks).toHaveLength(1) + expect(redactedBlocks![0]).toEqual({ + type: "redacted_thinking", + data: redactedData, + }) + }) + + it("should enable reasoning when enableReasoningEffort is true in settings", async () => { + const handler = new AwsBedrockHandler({ + apiProvider: "bedrock", + apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0", + awsRegion: "us-east-1", + enableReasoningEffort: true, + modelMaxThinkingTokens: 4096, + }) + + async function* mockFullStream() { + yield { type: "reasoning", text: "Let me think..." } + yield { type: "reasoning", text: " about this problem." } + yield { type: "text-delta", text: "Test response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + providerMetadata: Promise.resolve({}), }) const messages = [{ role: "user" as const, content: "Test message" }] @@ -264,17 +246,13 @@ describe("AwsBedrockHandler - Extended Thinking", () => { } // Verify thinking was enabled via settings - expect(mockSend).toHaveBeenCalledTimes(1) - expect(capturedPayload).toBeDefined() - expect(capturedPayload.additionalModelRequestFields).toBeDefined() - expect(capturedPayload.additionalModelRequestFields.thinking).toEqual({ + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.bedrock?.reasoningConfig).toEqual({ type: "enabled", - budget_tokens: 4096, + budgetTokens: 4096, }) - // Verify that topP is NOT present when thinking is enabled via settings - expect(capturedPayload.inferenceConfig).not.toHaveProperty("topP") - // Verify reasoning chunks were yielded const reasoningChunks = chunks.filter((c) => c.type === "reasoning") expect(reasoningChunks).toHaveLength(2) @@ -282,8 +260,8 @@ describe("AwsBedrockHandler - Extended Thinking", () => { expect(reasoningChunks[1].text).toBe(" about this problem.") }) - it("should support API key authentication", async () => { - handler = new AwsBedrockHandler({ + it("should support API key authentication via createAmazonBedrock", () => { + new AwsBedrockHandler({ apiProvider: "bedrock", apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsRegion: "us-east-1", @@ -291,41 +269,13 @@ describe("AwsBedrockHandler - Extended Thinking", () => { awsApiKey: "test-api-key-token", }) - mockSend.mockResolvedValue({ - stream: (async function* () { - yield { messageStart: { role: "assistant" } } - yield { - contentBlockStart: { - start: { text: "Hello from API key auth" }, - contentBlockIndex: 0, - }, - } - yield { metadata: { usage: { inputTokens: 100, outputTokens: 50 } } } - })(), - }) - - const messages = [{ role: "user" as const, content: "Test message" }] - const stream = handler.createMessage("System prompt", messages) - - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Verify the client was created with API key token - expect(BedrockRuntimeClient).toHaveBeenCalledWith( + // Verify the provider was created with API key + expect(mockCreateAmazonBedrock).toHaveBeenCalledWith( expect.objectContaining({ region: "us-east-1", - token: { token: "test-api-key-token" }, - authSchemePreference: ["httpBearerAuth"], + apiKey: "test-api-key-token", }), ) - - // Verify the stream worked correctly - expect(mockSend).toHaveBeenCalledTimes(1) - const textChunks = chunks.filter((c) => c.type === "text") - expect(textChunks).toHaveLength(1) - expect(textChunks[0].text).toBe("Hello from API key auth") }) }) }) diff --git a/src/api/providers/__tests__/bedrock-vpc-endpoint.spec.ts b/src/api/providers/__tests__/bedrock-vpc-endpoint.spec.ts index 7823775bea..19bb68bb77 100644 --- a/src/api/providers/__tests__/bedrock-vpc-endpoint.spec.ts +++ b/src/api/providers/__tests__/bedrock-vpc-endpoint.spec.ts @@ -7,38 +7,40 @@ vi.mock("@aws-sdk/credential-providers", () => { return { fromIni: mockFromIni } }) -// Mock BedrockRuntimeClient and ConverseStreamCommand -vi.mock("@aws-sdk/client-bedrock-runtime", () => { - const mockSend = vi.fn().mockResolvedValue({ - stream: [], - }) - const mockBedrockRuntimeClient = vi.fn().mockImplementation(() => ({ - send: mockSend, - })) +// Use vi.hoisted to define mock functions for AI SDK +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - BedrockRuntimeClient: mockBedrockRuntimeClient, - ConverseStreamCommand: vi.fn(), - ConverseCommand: vi.fn(), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) -import { AwsBedrockHandler } from "../bedrock" -import { BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime" +// Mock createAmazonBedrock so we can inspect how it was called +const { mockCreateAmazonBedrock } = vi.hoisted(() => ({ + mockCreateAmazonBedrock: vi.fn(() => vi.fn(() => ({ modelId: "test", provider: "bedrock" }))), +})) -// Get access to the mocked functions -const mockBedrockRuntimeClient = vi.mocked(BedrockRuntimeClient) +vi.mock("@ai-sdk/amazon-bedrock", () => ({ + createAmazonBedrock: mockCreateAmazonBedrock, +})) + +import { AwsBedrockHandler } from "../bedrock" describe("Amazon Bedrock VPC Endpoint Functionality", () => { beforeEach(() => { - // Clear all mocks before each test vi.clearAllMocks() }) // Test Scenario 1: Input Validation Test describe("VPC Endpoint URL Validation", () => { - it("should configure client with endpoint URL when both URL and enabled flag are provided", () => { - // Create handler with endpoint URL and enabled flag + it("should configure provider with baseURL when both URL and enabled flag are provided", () => { new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", @@ -48,17 +50,15 @@ describe("Amazon Bedrock VPC Endpoint Functionality", () => { awsBedrockEndpointEnabled: true, }) - // Verify the client was created with the correct endpoint - expect(mockBedrockRuntimeClient).toHaveBeenCalledWith( + expect(mockCreateAmazonBedrock).toHaveBeenCalledWith( expect.objectContaining({ region: "us-east-1", - endpoint: "https://bedrock-vpc.example.com", + baseURL: "https://bedrock-vpc.example.com", }), ) }) - it("should not configure client with endpoint URL when URL is provided but enabled flag is false", () => { - // Create handler with endpoint URL but disabled flag + it("should not configure provider with baseURL when URL is provided but enabled flag is false", () => { new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", @@ -68,23 +68,23 @@ describe("Amazon Bedrock VPC Endpoint Functionality", () => { awsBedrockEndpointEnabled: false, }) - // Verify the client was created without the endpoint - expect(mockBedrockRuntimeClient).toHaveBeenCalledWith( + expect(mockCreateAmazonBedrock).toHaveBeenCalledWith( expect.objectContaining({ region: "us-east-1", }), ) - // Verify the endpoint property is not present - const clientConfig = mockBedrockRuntimeClient.mock.calls[0][0] - expect(clientConfig).not.toHaveProperty("endpoint") + const providerSettings = (mockCreateAmazonBedrock.mock.calls as unknown[][])[0][0] as Record< + string, + unknown + > + expect(providerSettings).not.toHaveProperty("baseURL") }) }) // Test Scenario 2: Edge Case Tests describe("Edge Cases", () => { it("should handle empty endpoint URL gracefully", () => { - // Create handler with empty endpoint URL but enabled flag new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", @@ -94,20 +94,21 @@ describe("Amazon Bedrock VPC Endpoint Functionality", () => { awsBedrockEndpointEnabled: true, }) - // Verify the client was created without the endpoint (since it's empty) - expect(mockBedrockRuntimeClient).toHaveBeenCalledWith( + expect(mockCreateAmazonBedrock).toHaveBeenCalledWith( expect.objectContaining({ region: "us-east-1", }), ) - // Verify the endpoint property is not present - const clientConfig = mockBedrockRuntimeClient.mock.calls[0][0] - expect(clientConfig).not.toHaveProperty("endpoint") + // Empty string is falsy, so baseURL should not be set + const providerSettings = (mockCreateAmazonBedrock.mock.calls as unknown[][])[0][0] as Record< + string, + unknown + > + expect(providerSettings).not.toHaveProperty("baseURL") }) it("should handle undefined endpoint URL gracefully", () => { - // Create handler with undefined endpoint URL but enabled flag new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", @@ -117,23 +118,23 @@ describe("Amazon Bedrock VPC Endpoint Functionality", () => { awsBedrockEndpointEnabled: true, }) - // Verify the client was created without the endpoint - expect(mockBedrockRuntimeClient).toHaveBeenCalledWith( + expect(mockCreateAmazonBedrock).toHaveBeenCalledWith( expect.objectContaining({ region: "us-east-1", }), ) - // Verify the endpoint property is not present - const clientConfig = mockBedrockRuntimeClient.mock.calls[0][0] - expect(clientConfig).not.toHaveProperty("endpoint") + const providerSettings = (mockCreateAmazonBedrock.mock.calls as unknown[][])[0][0] as Record< + string, + unknown + > + expect(providerSettings).not.toHaveProperty("baseURL") }) }) - // Test Scenario 4: Error Handling Tests + // Test Scenario 3: Error Handling Tests describe("Error Handling", () => { - it("should handle invalid endpoint URLs by passing them directly to AWS SDK", () => { - // Create handler with an invalid URL format + it("should handle invalid endpoint URLs by passing them directly to the provider", () => { new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", @@ -143,21 +144,24 @@ describe("Amazon Bedrock VPC Endpoint Functionality", () => { awsBedrockEndpointEnabled: true, }) - // Verify the client was created with the invalid endpoint - // (AWS SDK will handle the validation/errors) - expect(mockBedrockRuntimeClient).toHaveBeenCalledWith( + // The invalid URL is passed directly; the provider/SDK will handle validation + expect(mockCreateAmazonBedrock).toHaveBeenCalledWith( expect.objectContaining({ region: "us-east-1", - endpoint: "invalid-url-format", + baseURL: "invalid-url-format", }), ) }) }) - // Test Scenario 5: Persistence Tests + // Test Scenario 4: Persistence Tests describe("Persistence", () => { it("should maintain consistent behavior across multiple requests", async () => { - // Create handler with endpoint URL and enabled flag + mockGenerateText.mockResolvedValue({ + text: "test response", + usage: { promptTokens: 10, completionTokens: 5 }, + }) + const handler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", @@ -167,23 +171,23 @@ describe("Amazon Bedrock VPC Endpoint Functionality", () => { awsBedrockEndpointEnabled: true, }) - // Verify the client was configured with the endpoint - expect(mockBedrockRuntimeClient).toHaveBeenCalledWith( + // Verify the provider was configured with the endpoint + expect(mockCreateAmazonBedrock).toHaveBeenCalledWith( expect.objectContaining({ region: "us-east-1", - endpoint: "https://bedrock-vpc.example.com", + baseURL: "https://bedrock-vpc.example.com", }), ) // Make a request to ensure the endpoint configuration persists try { await handler.completePrompt("Test prompt") - } catch (error) { - // Ignore errors, we're just testing the client configuration persistence + } catch { + // Ignore errors — we're just testing the provider configuration persistence } - // Verify the client instance was created and used - expect(mockBedrockRuntimeClient).toHaveBeenCalled() + // The provider factory should have been called exactly once (during construction) + expect(mockCreateAmazonBedrock).toHaveBeenCalledTimes(1) }) }) }) diff --git a/src/api/providers/__tests__/bedrock.spec.ts b/src/api/providers/__tests__/bedrock.spec.ts index 115cb9fb40..2cb09fc56d 100644 --- a/src/api/providers/__tests__/bedrock.spec.ts +++ b/src/api/providers/__tests__/bedrock.spec.ts @@ -18,24 +18,26 @@ vi.mock("@aws-sdk/credential-providers", () => { return { fromIni: mockFromIni } }) -// Mock BedrockRuntimeClient and ConverseStreamCommand -vi.mock("@aws-sdk/client-bedrock-runtime", () => { - const mockSend = vi.fn().mockResolvedValue({ - stream: [], - }) - const mockConverseStreamCommand = vi.fn() +// Use vi.hoisted to define mock functions for AI SDK +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - BedrockRuntimeClient: vi.fn().mockImplementation(() => ({ - send: mockSend, - })), - ConverseStreamCommand: mockConverseStreamCommand, - ConverseCommand: vi.fn(), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) +vi.mock("@ai-sdk/amazon-bedrock", () => ({ + createAmazonBedrock: vi.fn(() => vi.fn(() => ({ modelId: "test", provider: "bedrock" }))), +})) + import { AwsBedrockHandler } from "../bedrock" -import { ConverseStreamCommand, BedrockRuntimeClient, ConverseCommand } from "@aws-sdk/client-bedrock-runtime" import { BEDROCK_1M_CONTEXT_MODEL_IDS, BEDROCK_SERVICE_TIER_MODEL_IDS, @@ -45,10 +47,6 @@ import { import type { Anthropic } from "@anthropic-ai/sdk" -// Get access to the mocked functions -const mockConverseStreamCommand = vi.mocked(ConverseStreamCommand) -const mockBedrockRuntimeClient = vi.mocked(BedrockRuntimeClient) - describe("AwsBedrockHandler", () => { let handler: AwsBedrockHandler @@ -478,12 +476,20 @@ describe("AwsBedrockHandler", () => { describe("image handling", () => { const mockImageData = Buffer.from("test-image-data").toString("base64") - beforeEach(() => { - // Reset the mocks before each test - mockConverseStreamCommand.mockReset() - }) + function setupMockStreamText() { + async function* mockFullStream() { + yield { type: "text-delta", text: "I see an image" } + } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + }) + } + + it("should properly pass image content through to streamText via AI SDK messages", async () => { + setupMockStreamText() - it("should properly convert image content to Bedrock format", async () => { const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", @@ -505,42 +511,39 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } - // Verify the command was created with the right payload - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] + // Verify streamText was called + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - // Verify the image was properly formatted - const imageBlock = commandArg.messages![0].content![0] - expect(imageBlock).toHaveProperty("image") - expect(imageBlock.image).toHaveProperty("format", "jpeg") - expect(imageBlock.image!.source).toHaveProperty("bytes") - expect(imageBlock.image!.source!.bytes).toBeInstanceOf(Uint8Array) - }) + // Verify messages were converted to AI SDK format with image parts + const aiSdkMessages = callArgs.messages + expect(aiSdkMessages).toBeDefined() + expect(aiSdkMessages.length).toBeGreaterThan(0) - it("should reject unsupported image formats", async () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "image", - source: { - type: "base64", - data: mockImageData, - media_type: "image/tiff" as "image/jpeg", // Type assertion to bypass TS - }, - }, - ], - }, - ] + // Find the user message containing image content + const userMsg = aiSdkMessages.find((m: { role: string }) => m.role === "user") + expect(userMsg).toBeDefined() + expect(Array.isArray(userMsg.content)).toBe(true) - const generator = handler.createMessage("", messages) - await expect(generator.next()).rejects.toThrow("Unsupported image format: tiff") + // The AI SDK convertToAiSdkMessages converts images to { type: "image", image: "data:...", mimeType: "..." } + const imagePart = userMsg.content.find((p: { type: string }) => p.type === "image") + expect(imagePart).toBeDefined() + expect(imagePart.image).toContain("data:image/jpeg;base64,") + expect(imagePart.mimeType).toBe("image/jpeg") + + const textPart = userMsg.content.find((p: { type: string }) => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart.text).toBe("What's in this image?") }) it("should handle multiple images in a single message", async () => { + setupMockStreamText() + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", @@ -574,20 +577,25 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } - // Verify the command was created with the right payload - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] + // Verify streamText was called + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - // Verify both images were properly formatted - const firstImage = commandArg.messages![0].content![0] - const secondImage = commandArg.messages![0].content![2] + // Verify messages contain both images + const userMsg = callArgs.messages.find((m: { role: string }) => m.role === "user") + expect(userMsg).toBeDefined() - expect(firstImage).toHaveProperty("image") - expect(firstImage.image).toHaveProperty("format", "jpeg") - expect(secondImage).toHaveProperty("image") - expect(secondImage.image).toHaveProperty("format", "png") + const imageParts = userMsg.content.filter((p: { type: string }) => p.type === "image") + expect(imageParts).toHaveLength(2) + expect(imageParts[0].image).toContain("data:image/jpeg;base64,") + expect(imageParts[0].mimeType).toBe("image/jpeg") + expect(imageParts[1].image).toContain("data:image/png;base64,") + expect(imageParts[1].mimeType).toBe("image/png") }) }) @@ -686,6 +694,17 @@ describe("AwsBedrockHandler", () => { }) describe("1M context beta feature", () => { + function setupMockStreamText() { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + }) + } + it("should enable 1M context window when awsBedrock1MContext is true for Claude Sonnet 4", () => { const handler = new AwsBedrockHandler({ apiModelId: BEDROCK_1M_CONTEXT_MODEL_IDS[0], @@ -731,7 +750,9 @@ describe("AwsBedrockHandler", () => { expect(model.info.contextWindow).toBe(200_000) }) - it("should include anthropic_beta parameter when 1M context is enabled", async () => { + it("should include anthropicBeta in providerOptions when 1M context is enabled", async () => { + setupMockStreamText() + const handler = new AwsBedrockHandler({ apiModelId: BEDROCK_1M_CONTEXT_MODEL_IDS[0], awsAccessKey: "test", @@ -748,23 +769,23 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } - // Verify the command was created with the right payload - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - // Should include anthropic_beta in additionalModelRequestFields with both 1M context and fine-grained-tool-streaming - expect(commandArg.additionalModelRequestFields).toBeDefined() - expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain("context-1m-2025-08-07") - expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain( - "fine-grained-tool-streaming-2025-05-14", - ) - // Should not include anthropic_version since thinking is not enabled - expect(commandArg.additionalModelRequestFields.anthropic_version).toBeUndefined() + // Should include anthropicBeta in providerOptions.bedrock with 1M context + const bedrockOpts = callArgs.providerOptions?.bedrock as Record | undefined + expect(bedrockOpts).toBeDefined() + expect(bedrockOpts!.anthropicBeta).toContain("context-1m-2025-08-07") }) - it("should not include 1M context beta when 1M context is disabled but still include fine-grained-tool-streaming", async () => { + it("should not include 1M context beta when 1M context is disabled", async () => { + setupMockStreamText() + const handler = new AwsBedrockHandler({ apiModelId: BEDROCK_1M_CONTEXT_MODEL_IDS[0], awsAccessKey: "test", @@ -781,22 +802,24 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } - // Verify the command was created with the right payload - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - // Should include anthropic_beta with fine-grained-tool-streaming for Claude models - expect(commandArg.additionalModelRequestFields).toBeDefined() - expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain( - "fine-grained-tool-streaming-2025-05-14", - ) - // Should NOT include 1M context beta - expect(commandArg.additionalModelRequestFields.anthropic_beta).not.toContain("context-1m-2025-08-07") + // Should NOT include anthropicBeta with 1M context + const bedrockOpts = callArgs.providerOptions?.bedrock as Record | undefined + if (bedrockOpts?.anthropicBeta) { + expect(bedrockOpts.anthropicBeta).not.toContain("context-1m-2025-08-07") + } }) - it("should not include 1M context beta for non-Claude Sonnet 4 models but still include fine-grained-tool-streaming", async () => { + it("should not include 1M context beta for non-Claude Sonnet 4 models", async () => { + setupMockStreamText() + const handler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test", @@ -813,19 +836,19 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } - // Verify the command was created with the right payload - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - // Should include anthropic_beta with fine-grained-tool-streaming for Claude models (even non-Sonnet 4) - expect(commandArg.additionalModelRequestFields).toBeDefined() - expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain( - "fine-grained-tool-streaming-2025-05-14", - ) - // Should NOT include 1M context beta for non-Sonnet 4 models - expect(commandArg.additionalModelRequestFields.anthropic_beta).not.toContain("context-1m-2025-08-07") + // Should NOT include anthropicBeta with 1M context for non-Sonnet 4 models + const bedrockOpts = callArgs.providerOptions?.bedrock as Record | undefined + if (bedrockOpts?.anthropicBeta) { + expect(bedrockOpts.anthropicBeta).not.toContain("context-1m-2025-08-07") + } }) it("should enable 1M context window with cross-region inference for Claude Sonnet 4", () => { @@ -846,7 +869,9 @@ describe("AwsBedrockHandler", () => { expect(model.id).toBe(`us.${BEDROCK_1M_CONTEXT_MODEL_IDS[0]}`) }) - it("should include anthropic_beta parameter with cross-region inference for Claude Sonnet 4", async () => { + it("should include anthropicBeta with cross-region inference for Claude Sonnet 4", async () => { + setupMockStreamText() + const handler = new AwsBedrockHandler({ apiModelId: BEDROCK_1M_CONTEXT_MODEL_IDS[0], awsAccessKey: "test", @@ -864,33 +889,34 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } - // Verify the command was created with the right payload - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[ - mockConverseStreamCommand.mock.calls.length - 1 - ][0] as any + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - // Should include anthropic_beta in additionalModelRequestFields with both 1M context and fine-grained-tool-streaming - expect(commandArg.additionalModelRequestFields).toBeDefined() - expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain("context-1m-2025-08-07") - expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain( - "fine-grained-tool-streaming-2025-05-14", - ) - // Should not include anthropic_version since thinking is not enabled - expect(commandArg.additionalModelRequestFields.anthropic_version).toBeUndefined() - // Model ID should have cross-region prefix - expect(commandArg.modelId).toBe(`us.${BEDROCK_1M_CONTEXT_MODEL_IDS[0]}`) + // Should include anthropicBeta in providerOptions.bedrock with 1M context + const bedrockOpts = callArgs.providerOptions?.bedrock as Record | undefined + expect(bedrockOpts).toBeDefined() + expect(bedrockOpts!.anthropicBeta).toContain("context-1m-2025-08-07") }) }) describe("service tier feature", () => { const supportedModelId = BEDROCK_SERVICE_TIER_MODEL_IDS[0] // amazon.nova-lite-v1:0 - beforeEach(() => { - mockConverseStreamCommand.mockReset() - }) + function setupMockStreamText() { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + }) + } describe("pricing multipliers in getModel()", () => { it("should apply FLEX tier pricing with 50% discount", () => { @@ -976,7 +1002,9 @@ describe("AwsBedrockHandler", () => { }) describe("service_tier parameter in API requests", () => { - it("should include service_tier as top-level parameter for supported models", async () => { + it("should include service_tier in providerOptions.bedrock.additionalModelRequestFields for supported models", async () => { + setupMockStreamText() + const handler = new AwsBedrockHandler({ apiModelId: supportedModelId, awsAccessKey: "test", @@ -993,23 +1021,27 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator - - // Verify the command was created with service_tier at top level - // Per AWS documentation, service_tier must be a top-level parameter, not inside additionalModelRequestFields - // https://docs.aws.amazon.com/bedrock/latest/userguide/service-tiers-inference.html - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any - - // service_tier should be at the top level of the payload - expect(commandArg.service_tier).toBe("PRIORITY") - // service_tier should NOT be in additionalModelRequestFields - if (commandArg.additionalModelRequestFields) { - expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined() + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) } + + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + + // service_tier should be passed through providerOptions.bedrock.additionalModelRequestFields + const bedrockOpts = callArgs.providerOptions?.bedrock as Record | undefined + expect(bedrockOpts).toBeDefined() + const additionalFields = bedrockOpts!.additionalModelRequestFields as + | Record + | undefined + expect(additionalFields).toBeDefined() + expect(additionalFields!.service_tier).toBe("PRIORITY") }) - it("should include service_tier FLEX as top-level parameter", async () => { + it("should include service_tier FLEX in providerOptions", async () => { + setupMockStreamText() + const handler = new AwsBedrockHandler({ apiModelId: supportedModelId, awsAccessKey: "test", @@ -1026,20 +1058,26 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator - - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any - - // service_tier should be at the top level of the payload - expect(commandArg.service_tier).toBe("FLEX") - // service_tier should NOT be in additionalModelRequestFields - if (commandArg.additionalModelRequestFields) { - expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined() + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) } + + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + + const bedrockOpts = callArgs.providerOptions?.bedrock as Record | undefined + expect(bedrockOpts).toBeDefined() + const additionalFields = bedrockOpts!.additionalModelRequestFields as + | Record + | undefined + expect(additionalFields).toBeDefined() + expect(additionalFields!.service_tier).toBe("FLEX") }) it("should NOT include service_tier for unsupported models", async () => { + setupMockStreamText() + const unsupportedModelId = "anthropic.claude-3-5-sonnet-20241022-v2:0" const handler = new AwsBedrockHandler({ apiModelId: unsupportedModelId, @@ -1057,19 +1095,25 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - // Service tier should NOT be included for unsupported models (at top level or in additionalModelRequestFields) - expect(commandArg.service_tier).toBeUndefined() - if (commandArg.additionalModelRequestFields) { - expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined() + // Service tier should NOT be included for unsupported models + const bedrockOpts = callArgs.providerOptions?.bedrock as Record | undefined + if (bedrockOpts?.additionalModelRequestFields) { + const additionalFields = bedrockOpts.additionalModelRequestFields as Record + expect(additionalFields.service_tier).toBeUndefined() } }) it("should NOT include service_tier when not specified", async () => { + setupMockStreamText() + const handler = new AwsBedrockHandler({ apiModelId: supportedModelId, awsAccessKey: "test", @@ -1086,15 +1130,19 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - // Service tier should NOT be included when not specified (at top level or in additionalModelRequestFields) - expect(commandArg.service_tier).toBeUndefined() - if (commandArg.additionalModelRequestFields) { - expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined() + // Service tier should NOT be included when not specified + const bedrockOpts = callArgs.providerOptions?.bedrock as Record | undefined + if (bedrockOpts?.additionalModelRequestFields) { + const additionalFields = bedrockOpts.additionalModelRequestFields as Record + expect(additionalFields.service_tier).toBeUndefined() } }) }) @@ -1127,16 +1175,16 @@ describe("AwsBedrockHandler", () => { }) describe("error telemetry", () => { - let mockSend: ReturnType - beforeEach(() => { mockCaptureException.mockClear() - // Get access to the mock send function from the mocked client - mockSend = vi.mocked(BedrockRuntimeClient).mock.results[0]?.value?.send }) it("should capture telemetry on createMessage error", async () => { - // Create a handler with a fresh mock + // Mock streamText to throw an error + mockStreamText.mockImplementation(() => { + throw new Error("Bedrock API error") + }) + const errorHandler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", @@ -1144,15 +1192,6 @@ describe("AwsBedrockHandler", () => { awsRegion: "us-east-1", }) - // Get the mock send from the new handler instance - const clientInstance = - vi.mocked(BedrockRuntimeClient).mock.results[vi.mocked(BedrockRuntimeClient).mock.results.length - 1] - ?.value - const mockSendFn = clientInstance?.send as ReturnType - - // Mock the send to throw an error - mockSendFn.mockRejectedValueOnce(new Error("Bedrock API error")) - const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", @@ -1186,7 +1225,9 @@ describe("AwsBedrockHandler", () => { }) it("should capture telemetry on completePrompt error", async () => { - // Create a handler with a fresh mock + // Mock generateText to throw an error + mockGenerateText.mockRejectedValueOnce(new Error("Bedrock completion error")) + const errorHandler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", @@ -1194,15 +1235,6 @@ describe("AwsBedrockHandler", () => { awsRegion: "us-east-1", }) - // Get the mock send from the new handler instance - const clientInstance = - vi.mocked(BedrockRuntimeClient).mock.results[vi.mocked(BedrockRuntimeClient).mock.results.length - 1] - ?.value - const mockSendFn = clientInstance?.send as ReturnType - - // Mock the send to throw an error for ConverseCommand - mockSendFn.mockRejectedValueOnce(new Error("Bedrock completion error")) - // Call completePrompt - it should throw await expect(errorHandler.completePrompt("Test prompt")).rejects.toThrow() @@ -1223,7 +1255,11 @@ describe("AwsBedrockHandler", () => { }) it("should still throw the error after capturing telemetry", async () => { - // Create a handler with a fresh mock + // Mock streamText to throw an error + mockStreamText.mockImplementation(() => { + throw new Error("Test error for throw verification") + }) + const errorHandler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", @@ -1231,15 +1267,6 @@ describe("AwsBedrockHandler", () => { awsRegion: "us-east-1", }) - // Get the mock send from the new handler instance - const clientInstance = - vi.mocked(BedrockRuntimeClient).mock.results[vi.mocked(BedrockRuntimeClient).mock.results.length - 1] - ?.value - const mockSendFn = clientInstance?.send as ReturnType - - // Mock the send to throw an error - mockSendFn.mockRejectedValueOnce(new Error("Test error for throw verification")) - const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", diff --git a/src/api/providers/__tests__/chutes.spec.ts b/src/api/providers/__tests__/chutes.spec.ts deleted file mode 100644 index c89ccb7990..0000000000 --- a/src/api/providers/__tests__/chutes.spec.ts +++ /dev/null @@ -1,336 +0,0 @@ -// npx vitest run api/providers/__tests__/chutes.spec.ts - -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" - -import { chutesDefaultModelId, chutesDefaultModelInfo, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types" - -import { ChutesHandler } from "../chutes" - -// Create mock functions -const mockCreate = vi.fn() -const mockFetchModel = vi.fn() - -// Mock OpenAI module -vi.mock("openai", () => ({ - default: vi.fn(() => ({ - chat: { - completions: { - create: mockCreate, - }, - }, - })), -})) - -describe("ChutesHandler", () => { - let handler: ChutesHandler - - beforeEach(() => { - vi.clearAllMocks() - // Set up default mock implementation - mockCreate.mockImplementation(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { content: "Test response" }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } - }, - })) - handler = new ChutesHandler({ chutesApiKey: "test-key" }) - // Mock fetchModel to return default model - mockFetchModel.mockResolvedValue({ - id: chutesDefaultModelId, - info: chutesDefaultModelInfo, - }) - handler.fetchModel = mockFetchModel - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it("should use the correct Chutes base URL", () => { - new ChutesHandler({ chutesApiKey: "test-chutes-api-key" }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://llm.chutes.ai/v1" })) - }) - - it("should use the provided API key", () => { - const chutesApiKey = "test-chutes-api-key" - new ChutesHandler({ chutesApiKey }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: chutesApiKey })) - }) - - it("should handle DeepSeek R1 reasoning format", async () => { - // Override the mock for this specific test - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { content: "Thinking..." }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: { content: "Hello" }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5 }, - } - }, - })) - - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - mockFetchModel.mockResolvedValueOnce({ - id: "deepseek-ai/DeepSeek-R1-0528", - info: { maxTokens: 1024, temperature: 0.7 }, - }) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - expect(chunks).toEqual([ - { type: "reasoning", text: "Thinking..." }, - { type: "text", text: "Hello" }, - { type: "usage", inputTokens: 10, outputTokens: 5 }, - ]) - }) - - it("should handle non-DeepSeek models", async () => { - // Use default mock implementation which returns text content - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - mockFetchModel.mockResolvedValueOnce({ - id: "some-other-model", - info: { maxTokens: 1024, temperature: 0.7 }, - }) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - expect(chunks).toEqual([ - { type: "text", text: "Test response" }, - { type: "usage", inputTokens: 10, outputTokens: 5 }, - ]) - }) - - it("should return default model when no model is specified", async () => { - const model = await handler.fetchModel() - expect(model.id).toBe(chutesDefaultModelId) - expect(model.info).toEqual(expect.objectContaining(chutesDefaultModelInfo)) - }) - - it("should return specified model when valid model is provided", async () => { - const testModelId = "deepseek-ai/DeepSeek-R1" - const handlerWithModel = new ChutesHandler({ - apiModelId: testModelId, - chutesApiKey: "test-chutes-api-key", - }) - // Mock fetchModel for this handler to return the test model from dynamic fetch - handlerWithModel.fetchModel = vi.fn().mockResolvedValue({ - id: testModelId, - info: { maxTokens: 32768, contextWindow: 163840, supportsImages: false, supportsPromptCache: false }, - }) - const model = await handlerWithModel.fetchModel() - expect(model.id).toBe(testModelId) - }) - - it("completePrompt method should return text from Chutes API", async () => { - const expectedResponse = "This is a test response from Chutes" - mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] }) - const result = await handler.completePrompt("test prompt") - expect(result).toBe(expectedResponse) - }) - - it("should handle errors in completePrompt", async () => { - const errorMessage = "Chutes API error" - mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow(`Chutes completion error: ${errorMessage}`) - }) - - it("createMessage should yield text content from stream", async () => { - const testContent = "This is test content from Chutes stream" - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: testContent } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) - }) - - it("createMessage should yield usage data from stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "usage", inputTokens: 10, outputTokens: 20 }) - }) - - it("createMessage should yield tool_call_partial from stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_123", - function: { name: "test_tool", arguments: '{"arg":"value"}' }, - }, - ], - }, - }, - ], - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ - type: "tool_call_partial", - index: 0, - id: "call_123", - name: "test_tool", - arguments: '{"arg":"value"}', - }) - }) - - it("createMessage should pass tools and tool_choice to API", async () => { - const tools = [ - { - type: "function" as const, - function: { - name: "test_tool", - description: "A test tool", - parameters: { type: "object", properties: {} }, - }, - }, - ] - const tool_choice = "auto" as const - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi.fn().mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", [], { tools, tool_choice, taskId: "test-task-id" }) - // Consume stream - for await (const _ of stream) { - // noop - } - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools, - tool_choice, - }), - ) - }) - - it("should apply DeepSeek default temperature for R1 models", () => { - const testModelId = "deepseek-ai/DeepSeek-R1" - const handlerWithModel = new ChutesHandler({ - apiModelId: testModelId, - chutesApiKey: "test-chutes-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.info.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE) - }) - - it("should use default temperature for non-DeepSeek models", () => { - const testModelId = "unsloth/Llama-3.3-70B-Instruct" - const handlerWithModel = new ChutesHandler({ - apiModelId: testModelId, - chutesApiKey: "test-chutes-api-key", - }) - // Note: getModel() returns fallback default without calling fetchModel - // Since we haven't called fetchModel, it returns the default chutesDefaultModelId - // which is DeepSeek-R1-0528, therefore temperature will be DEEP_SEEK_DEFAULT_TEMPERATURE - const model = handlerWithModel.getModel() - // The default model is DeepSeek-R1, so it returns DEEP_SEEK_DEFAULT_TEMPERATURE - expect(model.info.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE) - }) -}) diff --git a/src/api/providers/__tests__/deepinfra.spec.ts b/src/api/providers/__tests__/deepinfra.spec.ts deleted file mode 100644 index c4a9275762..0000000000 --- a/src/api/providers/__tests__/deepinfra.spec.ts +++ /dev/null @@ -1,386 +0,0 @@ -// npx vitest api/providers/__tests__/deepinfra.spec.ts - -import { deepInfraDefaultModelId, deepInfraDefaultModelInfo } from "@roo-code/types" - -const mockCreate = vitest.fn() -const mockWithResponse = vitest.fn() - -vitest.mock("openai", () => { - const mockConstructor = vitest.fn() - - return { - __esModule: true, - default: mockConstructor.mockImplementation(() => ({ - chat: { - completions: { - create: mockCreate.mockImplementation(() => ({ - withResponse: mockWithResponse, - })), - }, - }, - })), - } -}) - -vitest.mock("../fetchers/modelCache", () => ({ - getModels: vitest.fn().mockResolvedValue({ - [deepInfraDefaultModelId]: deepInfraDefaultModelInfo, - }), - getModelsFromCache: vitest.fn().mockReturnValue(undefined), -})) - -import OpenAI from "openai" -import { DeepInfraHandler } from "../deepinfra" - -describe("DeepInfraHandler", () => { - let handler: DeepInfraHandler - - beforeEach(() => { - vi.clearAllMocks() - mockCreate.mockClear() - mockWithResponse.mockClear() - - handler = new DeepInfraHandler({}) - }) - - it("should use the correct DeepInfra base URL", () => { - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: "https://api.deepinfra.com/v1/openai", - }), - ) - }) - - it("should use the provided API key", () => { - vi.clearAllMocks() - - const deepInfraApiKey = "test-api-key" - new DeepInfraHandler({ deepInfraApiKey }) - - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ - apiKey: deepInfraApiKey, - }), - ) - }) - - it("should return default model when no model is specified", () => { - const model = handler.getModel() - expect(model.id).toBe(deepInfraDefaultModelId) - expect(model.info).toEqual(deepInfraDefaultModelInfo) - }) - - it("createMessage should yield text content from stream", async () => { - const testContent = "This is test content" - - mockWithResponse.mockResolvedValueOnce({ - data: { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [{ delta: { content: testContent } }], - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - }, - }) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ - type: "text", - text: testContent, - }) - }) - - it("createMessage should yield reasoning content from stream", async () => { - const testReasoning = "Test reasoning content" - - mockWithResponse.mockResolvedValueOnce({ - data: { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [{ delta: { reasoning_content: testReasoning } }], - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - }, - }) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ - type: "reasoning", - text: testReasoning, - }) - }) - - it("createMessage should yield usage data from stream", async () => { - mockWithResponse.mockResolvedValueOnce({ - data: { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [{ delta: {} }], - usage: { - prompt_tokens: 10, - completion_tokens: 20, - prompt_tokens_details: { - cache_write_tokens: 15, - cached_tokens: 5, - }, - }, - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - }, - }) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ - type: "usage", - inputTokens: 10, - outputTokens: 20, - cacheWriteTokens: 15, - cacheReadTokens: 5, - totalCost: expect.any(Number), - }) - }) - - describe("Native Tool Calling", () => { - const testTools = [ - { - type: "function" as const, - function: { - name: "test_tool", - description: "A test tool", - parameters: { - type: "object", - properties: { - arg1: { type: "string", description: "First argument" }, - }, - required: ["arg1"], - }, - }, - }, - ] - - it("should include tools in request when model supports native tools and tools are provided", async () => { - mockWithResponse.mockResolvedValueOnce({ - data: { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - }, - }) - - const messageGenerator = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - }) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - type: "function", - function: expect.objectContaining({ - name: "test_tool", - }), - }), - ]), - }), - ) - // parallel_tool_calls should be true by default when not explicitly set - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs).toHaveProperty("parallel_tool_calls", true) - }) - - it("should include tool_choice when provided", async () => { - mockWithResponse.mockResolvedValueOnce({ - data: { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - }, - }) - - const messageGenerator = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - tool_choice: "auto", - }) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tool_choice: "auto", - }), - ) - }) - - it("should always include tools and tool_choice in request (tools are always present after PR #10841)", async () => { - mockWithResponse.mockResolvedValueOnce({ - data: { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - }, - }) - - const messageGenerator = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - }) - await messageGenerator.next() - - const callArgs = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0] - // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS) - expect(callArgs).toHaveProperty("tools") - expect(callArgs).toHaveProperty("tool_choice") - // parallel_tool_calls should be true by default when not explicitly set - expect(callArgs).toHaveProperty("parallel_tool_calls", true) - }) - - it("should yield tool_call_partial chunks during streaming", async () => { - mockWithResponse.mockResolvedValueOnce({ - data: { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_123", - function: { - name: "test_tool", - arguments: '{"arg1":', - }, - }, - ], - }, - }, - ], - }, - }) - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { - arguments: '"value"}', - }, - }, - ], - }, - }, - ], - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - }, - }) - - const stream = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - }) - - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - expect(chunks).toContainEqual({ - type: "tool_call_partial", - index: 0, - id: "call_123", - name: "test_tool", - arguments: '{"arg1":', - }) - - expect(chunks).toContainEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: '"value"}', - }) - }) - - it("should set parallel_tool_calls based on metadata", async () => { - mockWithResponse.mockResolvedValueOnce({ - data: { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - }, - }) - - const messageGenerator = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - parallelToolCalls: true, - }) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - parallel_tool_calls: true, - }), - ) - }) - }) - - describe("completePrompt", () => { - it("should return text from API", async () => { - const expectedResponse = "This is a test response" - mockCreate.mockResolvedValueOnce({ - choices: [{ message: { content: expectedResponse } }], - }) - - const result = await handler.completePrompt("test prompt") - expect(result).toBe(expectedResponse) - }) - }) -}) diff --git a/src/api/providers/__tests__/deepseek.spec.ts b/src/api/providers/__tests__/deepseek.spec.ts index ece03c068e..32bd3a029a 100644 --- a/src/api/providers/__tests__/deepseek.spec.ts +++ b/src/api/providers/__tests__/deepseek.spec.ts @@ -25,7 +25,7 @@ vi.mock("@ai-sdk/deepseek", () => ({ import type { Anthropic } from "@anthropic-ai/sdk" -import { deepSeekDefaultModelId, type ModelInfo } from "@roo-code/types" +import { deepSeekDefaultModelId, DEEP_SEEK_DEFAULT_TEMPERATURE, type ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../../shared/api" @@ -155,6 +155,20 @@ describe("DeepSeekHandler", () => { expect(model).toHaveProperty("temperature") expect(model).toHaveProperty("maxTokens") }) + + it("should use DEEP_SEEK_DEFAULT_TEMPERATURE as the default temperature", () => { + const model = handler.getModel() + expect(model.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE) + }) + + it("should respect user-provided temperature over DEEP_SEEK_DEFAULT_TEMPERATURE", () => { + const handlerWithTemp = new DeepSeekHandler({ + ...mockOptions, + modelTemperature: 0.9, + }) + const model = handlerWithTemp.getModel() + expect(model.temperature).toBe(0.9) + }) }) describe("createMessage", () => { diff --git a/src/api/providers/__tests__/featherless.spec.ts b/src/api/providers/__tests__/featherless.spec.ts deleted file mode 100644 index 936c10fcd0..0000000000 --- a/src/api/providers/__tests__/featherless.spec.ts +++ /dev/null @@ -1,259 +0,0 @@ -// npx vitest run api/providers/__tests__/featherless.spec.ts - -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" - -import { type FeatherlessModelId, featherlessDefaultModelId, featherlessModels } from "@roo-code/types" - -import { FeatherlessHandler } from "../featherless" - -// Create mock functions -const mockCreate = vi.fn() - -// Mock OpenAI module -vi.mock("openai", () => ({ - default: vi.fn(() => ({ - chat: { - completions: { - create: mockCreate, - }, - }, - })), -})) - -describe("FeatherlessHandler", () => { - let handler: FeatherlessHandler - - beforeEach(() => { - vi.clearAllMocks() - // Set up default mock implementation - mockCreate.mockImplementation(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { content: "Test response" }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } - }, - })) - handler = new FeatherlessHandler({ featherlessApiKey: "test-key" }) - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it("should use the correct Featherless base URL", () => { - new FeatherlessHandler({ featherlessApiKey: "test-featherless-api-key" }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://api.featherless.ai/v1" })) - }) - - it("should use the provided API key", () => { - const featherlessApiKey = "test-featherless-api-key" - new FeatherlessHandler({ featherlessApiKey }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: featherlessApiKey })) - }) - - it("should handle reasoning format from models that use tags", async () => { - // Override the mock for this specific test - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { content: "Thinking..." }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: { content: "Hello" }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5 }, - } - }, - })) - - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - vi.spyOn(handler, "getModel").mockReturnValue({ - id: "some-reasoning-model", - info: { maxTokens: 1024, temperature: 0.7 }, - } as any) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - expect(chunks[0]).toEqual({ type: "reasoning", text: "Thinking..." }) - expect(chunks[1]).toEqual({ type: "text", text: "Hello" }) - expect(chunks[2]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5 }) - }) - - it("should fall back to base provider for non-DeepSeek models", async () => { - // Use default mock implementation which returns text content - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - vi.spyOn(handler, "getModel").mockReturnValue({ - id: "some-other-model", - info: { maxTokens: 1024, temperature: 0.7 }, - } as any) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - expect(chunks[0]).toEqual({ type: "text", text: "Test response" }) - expect(chunks[1]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5 }) - }) - - it("should return default model when no model is specified", () => { - const model = handler.getModel() - expect(model.id).toBe(featherlessDefaultModelId) - expect(model.info).toEqual(expect.objectContaining(featherlessModels[featherlessDefaultModelId])) - }) - - it("should return specified model when valid model is provided", () => { - const testModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct" - const handlerWithModel = new FeatherlessHandler({ - apiModelId: testModelId, - featherlessApiKey: "test-featherless-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual(expect.objectContaining(featherlessModels[testModelId])) - }) - - it("completePrompt method should return text from Featherless API", async () => { - const expectedResponse = "This is a test response from Featherless" - mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] }) - const result = await handler.completePrompt("test prompt") - expect(result).toBe(expectedResponse) - }) - - it("should handle errors in completePrompt", async () => { - const errorMessage = "Featherless API error" - mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - `Featherless completion error: ${errorMessage}`, - ) - }) - - it("createMessage should yield text content from stream", async () => { - const testContent = "This is test content from Featherless stream" - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: testContent } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) - }) - - it("createMessage should yield usage data from stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 }) - }) - - it("createMessage should pass correct parameters to Featherless client", async () => { - const modelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct" - - // Clear previous mocks and set up new implementation - mockCreate.mockClear() - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - // Empty stream for this test - }, - })) - - const handlerWithModel = new FeatherlessHandler({ - apiModelId: modelId, - featherlessApiKey: "test-featherless-api-key", - }) - - const systemPrompt = "Test system prompt for Featherless" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for Featherless" }] - - const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalled() - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs.model).toBe(modelId) - }) - - it("should use default temperature for non-DeepSeek models", () => { - const testModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct" - const handlerWithModel = new FeatherlessHandler({ - apiModelId: testModelId, - featherlessApiKey: "test-featherless-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.info.temperature).toBe(0.5) - }) -}) diff --git a/src/api/providers/__tests__/gemini-handler.spec.ts b/src/api/providers/__tests__/gemini-handler.spec.ts index a9544a0b97..8fe167ce1c 100644 --- a/src/api/providers/__tests__/gemini-handler.spec.ts +++ b/src/api/providers/__tests__/gemini-handler.spec.ts @@ -1,71 +1,95 @@ +// npx vitest run src/api/providers/__tests__/gemini-handler.spec.ts + +// Mock the AI SDK functions +const mockStreamText = vi.fn() +const mockGenerateText = vi.fn() + +vi.mock("ai", async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + streamText: (...args: unknown[]) => mockStreamText(...args), + generateText: (...args: unknown[]) => mockGenerateText(...args), + } +}) + import { t } from "i18next" -import { FunctionCallingConfigMode } from "@google/genai" import { GeminiHandler } from "../gemini" import type { ApiHandlerOptions } from "../../../shared/api" describe("GeminiHandler backend support", () => { - it("createMessage uses function declarations (URL context and grounding are only for completePrompt)", async () => { - // URL context and grounding are mutually exclusive with function declarations - // in Gemini API, so createMessage only uses function declarations. - // URL context/grounding are only added in completePrompt. - const options = { - apiProvider: "gemini", - enableUrlContext: true, - enableGrounding: true, - } as ApiHandlerOptions - const handler = new GeminiHandler(options) - const stub = vi.fn().mockReturnValue((async function* () {})()) - // @ts-ignore access private client - handler["client"].models.generateContentStream = stub - await handler.createMessage("instr", [] as any).next() - const config = stub.mock.calls[0][0].config - // createMessage always uses function declarations only - // (tools are always present from ALWAYS_AVAILABLE_TOOLS) - expect(config.tools).toEqual([{ functionDeclarations: expect.any(Array) }]) + beforeEach(() => { + mockStreamText.mockClear() + mockGenerateText.mockClear() }) - it("completePrompt passes config overrides without tools when URL context and grounding disabled", async () => { + it("createMessage uses AI SDK tools format", async () => { const options = { apiProvider: "gemini", - enableUrlContext: false, - enableGrounding: false, } as ApiHandlerOptions const handler = new GeminiHandler(options) - const stub = vi.fn().mockResolvedValue({ text: "ok" }) - // @ts-ignore access private client - handler["client"].models.generateContent = stub + + const mockFullStream = (async function* () {})() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + }) + + await handler.createMessage("instr", [] as any).next() + + // Verify streamText was called + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + system: "instr", + }), + ) + }) + + it("completePrompt generates text without tools", async () => { + const options = { + apiProvider: "gemini", + } as ApiHandlerOptions + const handler = new GeminiHandler(options) + + mockGenerateText.mockResolvedValue({ + text: "ok", + providerMetadata: {}, + }) + const res = await handler.completePrompt("hi") expect(res).toBe("ok") - const promptConfig = stub.mock.calls[0][0].config - expect(promptConfig.tools).toBeUndefined() + + // Verify generateText was called without tools + const callArgs = mockGenerateText.mock.calls[0][0] + expect(callArgs.tools).toBeUndefined() }) describe("error scenarios", () => { it("should handle grounding metadata extraction failure gracefully", async () => { const options = { apiProvider: "gemini", - enableGrounding: true, } as ApiHandlerOptions const handler = new GeminiHandler(options) - const mockStream = async function* () { - yield { - candidates: [ - { - groundingMetadata: { - // Invalid structure - missing groundingChunks - }, - content: { parts: [{ text: "test response" }] }, - }, - ], - usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 }, - } - } + // AI SDK text-delta events have a 'text' property (processAiSdkStreamPart casts to this) + const mockFullStream = (async function* () { + yield { type: "text-delta", text: "test response" } + })() - const stub = vi.fn().mockReturnValue(mockStream()) - // @ts-ignore access private client - handler["client"].models.generateContentStream = stub + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({ + google: { + groundingMetadata: { + // Invalid structure - missing groundingChunks + }, + }, + }), + }) const messages = [] for await (const chunk of handler.createMessage("test", [] as any)) { @@ -74,37 +98,35 @@ describe("GeminiHandler backend support", () => { // Should still return the main content without sources expect(messages.some((m) => m.type === "text" && m.text === "test response")).toBe(true) - expect(messages.some((m) => m.type === "text" && m.text?.includes("Sources:"))).toBe(false) + expect(messages.some((m) => m.type === "grounding")).toBe(false) }) it("should handle malformed grounding metadata", async () => { const options = { apiProvider: "gemini", - enableGrounding: true, } as ApiHandlerOptions const handler = new GeminiHandler(options) - const mockStream = async function* () { - yield { - candidates: [ - { - groundingMetadata: { - groundingChunks: [ - { web: null }, // Missing URI - { web: { uri: "https://example.com", title: "Example Site" } }, // Valid - {}, // Missing web property entirely - ], - }, - content: { parts: [{ text: "test response" }] }, - }, - ], - usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 }, - } - } + // AI SDK text-delta events have a 'text' property (processAiSdkStreamPart casts to this) + const mockFullStream = (async function* () { + yield { type: "text-delta", text: "test response" } + })() - const stub = vi.fn().mockReturnValue(mockStream()) - // @ts-ignore access private client - handler["client"].models.generateContentStream = stub + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({ + google: { + groundingMetadata: { + groundingChunks: [ + { web: null }, // Missing URI + { web: { uri: "https://example.com", title: "Example Site" } }, // Valid + {}, // Missing web property entirely + ], + }, + }, + }), + }) const messages = [] for await (const chunk of handler.createMessage("test", [] as any)) { @@ -128,18 +150,23 @@ describe("GeminiHandler backend support", () => { } }) - it("should handle API errors when tools are enabled", async () => { + it("should handle API errors", async () => { const options = { apiProvider: "gemini", - enableUrlContext: true, - enableGrounding: true, } as ApiHandlerOptions const handler = new GeminiHandler(options) const mockError = new Error("API rate limit exceeded") - const stub = vi.fn().mockRejectedValue(mockError) - // @ts-ignore access private client - handler["client"].models.generateContentStream = stub + // eslint-disable-next-line require-yield + const mockFullStream = (async function* () { + throw mockError + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({}), + providerMetadata: Promise.resolve({}), + }) await expect(async () => { const generator = handler.createMessage("test", [] as any) @@ -148,7 +175,7 @@ describe("GeminiHandler backend support", () => { }) }) - describe("allowedFunctionNames support", () => { + describe("toolChoice support", () => { const testTools = [ { type: "function" as const, @@ -176,123 +203,120 @@ describe("GeminiHandler backend support", () => { }, ] - it("should pass allowedFunctionNames to toolConfig when provided", async () => { + it("should pass tools to streamText", async () => { const options = { apiProvider: "gemini", } as ApiHandlerOptions const handler = new GeminiHandler(options) - const stub = vi.fn().mockReturnValue((async function* () {})()) - // @ts-ignore access private client - handler["client"].models.generateContentStream = stub - await handler - .createMessage("test", [] as any, { - taskId: "test-task", - tools: testTools, - allowedFunctionNames: ["read_file", "write_to_file"], - }) - .next() + const mockFullStream = (async function* () {})() - const config = stub.mock.calls[0][0].config - expect(config.toolConfig).toEqual({ - functionCallingConfig: { - mode: FunctionCallingConfigMode.ANY, - allowedFunctionNames: ["read_file", "write_to_file"], - }, + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({}), + providerMetadata: Promise.resolve({}), }) - }) - - it("should include all tools but restrict callable functions via allowedFunctionNames", async () => { - const options = { - apiProvider: "gemini", - } as ApiHandlerOptions - const handler = new GeminiHandler(options) - const stub = vi.fn().mockReturnValue((async function* () {})()) - // @ts-ignore access private client - handler["client"].models.generateContentStream = stub await handler .createMessage("test", [] as any, { taskId: "test-task", tools: testTools, - allowedFunctionNames: ["read_file"], }) .next() - const config = stub.mock.calls[0][0].config - // All tools should be passed to the model - expect(config.tools[0].functionDeclarations).toHaveLength(3) - // But only read_file should be allowed to be called - expect(config.toolConfig.functionCallingConfig.allowedFunctionNames).toEqual(["read_file"]) + // Verify streamText was called with tools + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.any(Object), + }), + ) }) - it("should take precedence over tool_choice when allowedFunctionNames is provided", async () => { + it("should pass toolChoice when allowedFunctionNames is provided", async () => { const options = { apiProvider: "gemini", } as ApiHandlerOptions const handler = new GeminiHandler(options) - const stub = vi.fn().mockReturnValue((async function* () {})()) - // @ts-ignore access private client - handler["client"].models.generateContentStream = stub + + const mockFullStream = (async function* () {})() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({}), + providerMetadata: Promise.resolve({}), + }) + + await handler + .createMessage("test", [] as any, { + taskId: "test-task", + tools: testTools, + allowedFunctionNames: ["read_file", "write_to_file"], + }) + .next() + + // Verify toolChoice is 'required' when allowedFunctionNames is provided + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + toolChoice: "required", + }), + ) + }) + + it("should use tool_choice when allowedFunctionNames is not provided", async () => { + const options = { + apiProvider: "gemini", + } as ApiHandlerOptions + const handler = new GeminiHandler(options) + + const mockFullStream = (async function* () {})() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({}), + providerMetadata: Promise.resolve({}), + }) await handler .createMessage("test", [] as any, { taskId: "test-task", tools: testTools, tool_choice: "auto", - allowedFunctionNames: ["read_file"], }) .next() - const config = stub.mock.calls[0][0].config - // allowedFunctionNames should take precedence - mode should be ANY, not AUTO - expect(config.toolConfig.functionCallingConfig.mode).toBe(FunctionCallingConfigMode.ANY) - expect(config.toolConfig.functionCallingConfig.allowedFunctionNames).toEqual(["read_file"]) + // Verify toolChoice follows tool_choice + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + toolChoice: "auto", + }), + ) }) - it("should fall back to tool_choice when allowedFunctionNames is empty", async () => { + it("should not set toolChoice when allowedFunctionNames is empty and no tool_choice", async () => { const options = { apiProvider: "gemini", } as ApiHandlerOptions const handler = new GeminiHandler(options) - const stub = vi.fn().mockReturnValue((async function* () {})()) - // @ts-ignore access private client - handler["client"].models.generateContentStream = stub + + const mockFullStream = (async function* () {})() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({}), + providerMetadata: Promise.resolve({}), + }) await handler .createMessage("test", [] as any, { taskId: "test-task", tools: testTools, - tool_choice: "auto", allowedFunctionNames: [], }) .next() - const config = stub.mock.calls[0][0].config - // Empty allowedFunctionNames should fall back to tool_choice behavior - expect(config.toolConfig.functionCallingConfig.mode).toBe(FunctionCallingConfigMode.AUTO) - expect(config.toolConfig.functionCallingConfig.allowedFunctionNames).toBeUndefined() - }) - - it("should not set toolConfig when allowedFunctionNames is undefined and no tool_choice", async () => { - const options = { - apiProvider: "gemini", - } as ApiHandlerOptions - const handler = new GeminiHandler(options) - const stub = vi.fn().mockReturnValue((async function* () {})()) - // @ts-ignore access private client - handler["client"].models.generateContentStream = stub - - await handler - .createMessage("test", [] as any, { - taskId: "test-task", - tools: testTools, - }) - .next() - - const config = stub.mock.calls[0][0].config - // No toolConfig should be set when neither allowedFunctionNames nor tool_choice is provided - expect(config.toolConfig).toBeUndefined() + // With empty allowedFunctionNames, toolChoice should be undefined + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.toolChoice).toBeUndefined() }) }) }) diff --git a/src/api/providers/__tests__/gemini.spec.ts b/src/api/providers/__tests__/gemini.spec.ts index 8c2ee87a78..13875499ee 100644 --- a/src/api/providers/__tests__/gemini.spec.ts +++ b/src/api/providers/__tests__/gemini.spec.ts @@ -1,5 +1,7 @@ // npx vitest run src/api/providers/__tests__/gemini.spec.ts +import { NoOutputGeneratedError } from "ai" + const mockCaptureException = vitest.fn() vitest.mock("@roo-code/telemetry", () => ({ @@ -10,6 +12,30 @@ vitest.mock("@roo-code/telemetry", () => ({ }, })) +// Mock the AI SDK functions +const mockStreamText = vitest.fn() +const mockGenerateText = vitest.fn() + +vitest.mock("ai", async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + streamText: (...args: unknown[]) => mockStreamText(...args), + generateText: (...args: unknown[]) => mockGenerateText(...args), + } +}) + +// Mock createGoogleGenerativeAI to capture constructor options +const mockCreateGoogleGenerativeAI = vitest.fn().mockReturnValue(() => ({})) + +vitest.mock("@ai-sdk/google", async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + createGoogleGenerativeAI: (...args: unknown[]) => mockCreateGoogleGenerativeAI(...args), + } +}) + import { Anthropic } from "@anthropic-ai/sdk" import { type ModelInfo, geminiDefaultModelId, ApiProviderError } from "@roo-code/types" @@ -25,26 +51,16 @@ describe("GeminiHandler", () => { beforeEach(() => { // Reset mocks mockCaptureException.mockClear() - - // Create mock functions - const mockGenerateContentStream = vitest.fn() - const mockGenerateContent = vitest.fn() - const mockGetGenerativeModel = vitest.fn() + mockStreamText.mockClear() + mockGenerateText.mockClear() + mockCreateGoogleGenerativeAI.mockClear() + mockCreateGoogleGenerativeAI.mockReturnValue(() => ({})) handler = new GeminiHandler({ apiKey: "test-key", apiModelId: GEMINI_MODEL_NAME, geminiApiKey: "test-key", }) - - // Replace the client with our mock - handler["client"] = { - models: { - generateContentStream: mockGenerateContentStream, - generateContent: mockGenerateContent, - getGenerativeModel: mockGetGenerativeModel, - }, - } as any }) describe("constructor", () => { @@ -52,6 +68,37 @@ describe("GeminiHandler", () => { expect(handler["options"].geminiApiKey).toBe("test-key") expect(handler["options"].apiModelId).toBe(GEMINI_MODEL_NAME) }) + + it("should pass undefined baseURL when googleGeminiBaseUrl is empty string", () => { + mockCreateGoogleGenerativeAI.mockClear() + new GeminiHandler({ + apiModelId: GEMINI_MODEL_NAME, + geminiApiKey: "test-key", + googleGeminiBaseUrl: "", + }) + expect(mockCreateGoogleGenerativeAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: undefined })) + }) + + it("should pass undefined baseURL when googleGeminiBaseUrl is not provided", () => { + mockCreateGoogleGenerativeAI.mockClear() + new GeminiHandler({ + apiModelId: GEMINI_MODEL_NAME, + geminiApiKey: "test-key", + }) + expect(mockCreateGoogleGenerativeAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: undefined })) + }) + + it("should pass custom baseURL when googleGeminiBaseUrl is a valid URL", () => { + mockCreateGoogleGenerativeAI.mockClear() + new GeminiHandler({ + apiModelId: GEMINI_MODEL_NAME, + geminiApiKey: "test-key", + googleGeminiBaseUrl: "https://custom-gemini.example.com/v1beta", + }) + expect(mockCreateGoogleGenerativeAI).toHaveBeenCalledWith( + expect.objectContaining({ baseURL: "https://custom-gemini.example.com/v1beta" }), + ) + }) }) describe("createMessage", () => { @@ -69,13 +116,17 @@ describe("GeminiHandler", () => { const systemPrompt = "You are a helpful assistant" it("should handle text messages correctly", async () => { - // Setup the mock implementation to return an async generator - ;(handler["client"].models.generateContentStream as any).mockResolvedValue({ - [Symbol.asyncIterator]: async function* () { - yield { text: "Hello" } - yield { text: " world!" } - yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } } - }, + // Setup the mock implementation to return an async generator for fullStream + // AI SDK text-delta events have a 'text' property (processAiSdkStreamPart casts to this) + const mockFullStream = (async function* () { + yield { type: "text-delta", text: "Hello" } + yield { type: "text-delta", text: " world!" } + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), }) const stream = handler.createMessage(systemPrompt, mockMessages) @@ -91,21 +142,105 @@ describe("GeminiHandler", () => { expect(chunks[1]).toEqual({ type: "text", text: " world!" }) expect(chunks[2]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5 }) - // Verify the call to generateContentStream - expect(handler["client"].models.generateContentStream).toHaveBeenCalledWith( + // Verify the call to streamText + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - model: GEMINI_MODEL_NAME, - config: expect.objectContaining({ - temperature: 1, - systemInstruction: systemPrompt, - }), + system: systemPrompt, + temperature: 1, }), ) }) + it("should yield informative message when stream produces no text content", async () => { + // Stream with only reasoning (no text-delta) simulates thinking-only response + const mockFullStream = (async function* () { + yield { type: "reasoning-delta", id: "1", text: "thinking..." } + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should have: reasoning chunk, empty-stream informative message, usage + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0]).toEqual({ + type: "text", + text: "Model returned an empty response. This may be caused by an unsupported thinking configuration or content filtering.", + }) + }) + + it("should suppress NoOutputGeneratedError when no text content was yielded", async () => { + // Empty stream - nothing yielded at all + const mockFullStream = (async function* () { + // empty stream + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.reject(new NoOutputGeneratedError({ message: "No output generated." })), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + + // Should NOT throw - the error is suppressed + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should have the informative empty-stream message only (no usage since it errored) + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("empty response"), + }) + }) + + it("should re-throw NoOutputGeneratedError when text content was yielded", async () => { + // Stream yields text content but usage still throws NoOutputGeneratedError (unexpected) + const mockFullStream = (async function* () { + yield { type: "text-delta", text: "Hello" } + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.reject(new NoOutputGeneratedError({ message: "No output generated." })), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + + await expect(async () => { + for await (const _chunk of stream) { + // consume stream + } + }).rejects.toThrow() + }) + it("should handle API errors", async () => { const mockError = new Error("Gemini API error") - ;(handler["client"].models.generateContentStream as any).mockRejectedValue(mockError) + // eslint-disable-next-line require-yield + const mockFullStream = (async function* () { + throw mockError + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({}), + providerMetadata: Promise.resolve({}), + }) const stream = handler.createMessage(systemPrompt, mockMessages) @@ -119,28 +254,26 @@ describe("GeminiHandler", () => { describe("completePrompt", () => { it("should complete prompt successfully", async () => { - // Mock the response with text property - ;(handler["client"].models.generateContent as any).mockResolvedValue({ + mockGenerateText.mockResolvedValue({ text: "Test response", + providerMetadata: {}, }) const result = await handler.completePrompt("Test prompt") expect(result).toBe("Test response") - // Verify the call to generateContent - expect(handler["client"].models.generateContent).toHaveBeenCalledWith({ - model: GEMINI_MODEL_NAME, - contents: [{ role: "user", parts: [{ text: "Test prompt" }] }], - config: { - httpOptions: undefined, + // Verify the call to generateText + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", temperature: 1, - }, - }) + }), + ) }) it("should handle API errors", async () => { const mockError = new Error("Gemini API error") - ;(handler["client"].models.generateContent as any).mockRejectedValue(mockError) + mockGenerateText.mockRejectedValue(mockError) await expect(handler.completePrompt("Test prompt")).rejects.toThrow( t("common:errors.gemini.generate_complete_prompt", { error: "Gemini API error" }), @@ -148,9 +281,9 @@ describe("GeminiHandler", () => { }) it("should handle empty response", async () => { - // Mock the response with empty text - ;(handler["client"].models.generateContent as any).mockResolvedValue({ + mockGenerateText.mockResolvedValue({ text: "", + providerMetadata: {}, }) const result = await handler.completePrompt("Test prompt") @@ -255,7 +388,16 @@ describe("GeminiHandler", () => { it("should capture telemetry on createMessage error", async () => { const mockError = new Error("Gemini API error") - ;(handler["client"].models.generateContentStream as any).mockRejectedValue(mockError) + // eslint-disable-next-line require-yield + const mockFullStream = (async function* () { + throw mockError + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({}), + providerMetadata: Promise.resolve({}), + }) const stream = handler.createMessage(systemPrompt, mockMessages) @@ -283,7 +425,7 @@ describe("GeminiHandler", () => { it("should capture telemetry on completePrompt error", async () => { const mockError = new Error("Gemini completion error") - ;(handler["client"].models.generateContent as any).mockRejectedValue(mockError) + mockGenerateText.mockRejectedValue(mockError) await expect(handler.completePrompt("Test prompt")).rejects.toThrow() @@ -305,7 +447,16 @@ describe("GeminiHandler", () => { it("should still throw the error after capturing telemetry", async () => { const mockError = new Error("Gemini API error") - ;(handler["client"].models.generateContentStream as any).mockRejectedValue(mockError) + // eslint-disable-next-line require-yield + const mockFullStream = (async function* () { + throw mockError + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({}), + providerMetadata: Promise.resolve({}), + }) const stream = handler.createMessage(systemPrompt, mockMessages) diff --git a/src/api/providers/__tests__/groq.spec.ts b/src/api/providers/__tests__/groq.spec.ts deleted file mode 100644 index efb5712cb9..0000000000 --- a/src/api/providers/__tests__/groq.spec.ts +++ /dev/null @@ -1,578 +0,0 @@ -// npx vitest run src/api/providers/__tests__/groq.spec.ts - -// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls -const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ - mockStreamText: vi.fn(), - mockGenerateText: vi.fn(), -})) - -vi.mock("ai", async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - streamText: mockStreamText, - generateText: mockGenerateText, - } -}) - -vi.mock("@ai-sdk/groq", () => ({ - createGroq: vi.fn(() => { - // Return a function that returns a mock language model - return vi.fn(() => ({ - modelId: "moonshotai/kimi-k2-instruct-0905", - provider: "groq", - })) - }), -})) - -import type { Anthropic } from "@anthropic-ai/sdk" - -import { groqDefaultModelId, groqModels, type GroqModelId } from "@roo-code/types" - -import type { ApiHandlerOptions } from "../../../shared/api" - -import { GroqHandler } from "../groq" - -describe("GroqHandler", () => { - let handler: GroqHandler - let mockOptions: ApiHandlerOptions - - beforeEach(() => { - mockOptions = { - groqApiKey: "test-groq-api-key", - apiModelId: "moonshotai/kimi-k2-instruct-0905", - } - handler = new GroqHandler(mockOptions) - vi.clearAllMocks() - }) - - describe("constructor", () => { - it("should initialize with provided options", () => { - expect(handler).toBeInstanceOf(GroqHandler) - expect(handler.getModel().id).toBe(mockOptions.apiModelId) - }) - - it("should use default model ID if not provided", () => { - const handlerWithoutModel = new GroqHandler({ - ...mockOptions, - apiModelId: undefined, - }) - expect(handlerWithoutModel.getModel().id).toBe(groqDefaultModelId) - }) - }) - - describe("getModel", () => { - it("should return default model when no model is specified", () => { - const handlerWithoutModel = new GroqHandler({ - groqApiKey: "test-groq-api-key", - }) - const model = handlerWithoutModel.getModel() - expect(model.id).toBe(groqDefaultModelId) - expect(model.info).toEqual(groqModels[groqDefaultModelId]) - }) - - it("should return specified model when valid model is provided", () => { - const testModelId: GroqModelId = "llama-3.3-70b-versatile" - const handlerWithModel = new GroqHandler({ - apiModelId: testModelId, - groqApiKey: "test-groq-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual(groqModels[testModelId]) - }) - - it("should return model info for llama-3.1-8b-instant", () => { - const handlerWithLlama = new GroqHandler({ - ...mockOptions, - apiModelId: "llama-3.1-8b-instant", - }) - const model = handlerWithLlama.getModel() - expect(model.id).toBe("llama-3.1-8b-instant") - expect(model.info).toBeDefined() - expect(model.info.maxTokens).toBe(8192) - expect(model.info.contextWindow).toBe(131072) - expect(model.info.supportsImages).toBe(false) - expect(model.info.supportsPromptCache).toBe(false) - }) - - it("should return model info for kimi-k2 which supports prompt cache", () => { - const handlerWithKimi = new GroqHandler({ - ...mockOptions, - apiModelId: "moonshotai/kimi-k2-instruct-0905", - }) - const model = handlerWithKimi.getModel() - expect(model.id).toBe("moonshotai/kimi-k2-instruct-0905") - expect(model.info).toBeDefined() - expect(model.info.maxTokens).toBe(16384) - expect(model.info.contextWindow).toBe(262144) - expect(model.info.supportsPromptCache).toBe(true) - }) - - it("should return provided model ID with default model info if model does not exist", () => { - const handlerWithInvalidModel = new GroqHandler({ - ...mockOptions, - apiModelId: "invalid-model", - }) - const model = handlerWithInvalidModel.getModel() - expect(model.id).toBe("invalid-model") - expect(model.info).toBeDefined() - // Should use default model info - expect(model.info).toBe(groqModels[groqDefaultModelId]) - }) - - it("should include model parameters from getModelParams", () => { - const model = handler.getModel() - expect(model).toHaveProperty("temperature") - expect(model).toHaveProperty("maxTokens") - }) - }) - - describe("createMessage", () => { - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "text" as const, - text: "Hello!", - }, - ], - }, - ] - - it("should handle streaming responses", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test response from Groq" } - } - - const mockUsage = Promise.resolve({ - inputTokens: 10, - outputTokens: 5, - }) - - const mockProviderMetadata = Promise.resolve({}) - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: mockUsage, - providerMetadata: mockProviderMetadata, - }) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - expect(chunks.length).toBeGreaterThan(0) - const textChunks = chunks.filter((chunk) => chunk.type === "text") - expect(textChunks).toHaveLength(1) - expect(textChunks[0].text).toBe("Test response from Groq") - }) - - it("should include usage information", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test response" } - } - - const mockUsage = Promise.resolve({ - inputTokens: 10, - outputTokens: 20, - }) - - const mockProviderMetadata = Promise.resolve({}) - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: mockUsage, - providerMetadata: mockProviderMetadata, - }) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - const usageChunks = chunks.filter((chunk) => chunk.type === "usage") - expect(usageChunks.length).toBeGreaterThan(0) - expect(usageChunks[0].inputTokens).toBe(10) - expect(usageChunks[0].outputTokens).toBe(20) - }) - - it("should handle cached tokens in usage data from providerMetadata", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test response" } - } - - const mockUsage = Promise.resolve({ - inputTokens: 100, - outputTokens: 50, - }) - - // Groq provides cache metrics via providerMetadata for supported models - const mockProviderMetadata = Promise.resolve({ - groq: { - promptCacheHitTokens: 30, - promptCacheMissTokens: 70, - }, - }) - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: mockUsage, - providerMetadata: mockProviderMetadata, - }) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - const usageChunks = chunks.filter((chunk) => chunk.type === "usage") - expect(usageChunks.length).toBeGreaterThan(0) - expect(usageChunks[0].inputTokens).toBe(100) - expect(usageChunks[0].outputTokens).toBe(50) - expect(usageChunks[0].cacheReadTokens).toBe(30) - expect(usageChunks[0].cacheWriteTokens).toBe(70) - }) - - it("should handle usage with details.cachedInputTokens when providerMetadata is not available", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test response" } - } - - const mockUsage = Promise.resolve({ - inputTokens: 100, - outputTokens: 50, - details: { - cachedInputTokens: 25, - }, - }) - - const mockProviderMetadata = Promise.resolve({}) - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: mockUsage, - providerMetadata: mockProviderMetadata, - }) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - const usageChunks = chunks.filter((chunk) => chunk.type === "usage") - expect(usageChunks.length).toBeGreaterThan(0) - expect(usageChunks[0].cacheReadTokens).toBe(25) - expect(usageChunks[0].cacheWriteTokens).toBeUndefined() - }) - - it("should pass correct temperature (0.5 default) to streamText", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test" } - } - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), - providerMetadata: Promise.resolve({}), - }) - - const handlerWithDefaultTemp = new GroqHandler({ - groqApiKey: "test-key", - apiModelId: "llama-3.1-8b-instant", - }) - - const stream = handlerWithDefaultTemp.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume stream - } - - expect(mockStreamText).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 0.5, - }), - ) - }) - }) - - describe("completePrompt", () => { - it("should complete a prompt using generateText", async () => { - mockGenerateText.mockResolvedValue({ - text: "Test completion from Groq", - }) - - const result = await handler.completePrompt("Test prompt") - - expect(result).toBe("Test completion from Groq") - expect(mockGenerateText).toHaveBeenCalledWith( - expect.objectContaining({ - prompt: "Test prompt", - }), - ) - }) - - it("should use default temperature in completePrompt", async () => { - mockGenerateText.mockResolvedValue({ - text: "Test completion", - }) - - await handler.completePrompt("Test prompt") - - expect(mockGenerateText).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 0.5, - }), - ) - }) - }) - - describe("processUsageMetrics", () => { - it("should correctly process usage metrics including cache information from providerMetadata", () => { - class TestGroqHandler extends GroqHandler { - public testProcessUsageMetrics(usage: any, providerMetadata?: any) { - return this.processUsageMetrics(usage, providerMetadata) - } - } - - const testHandler = new TestGroqHandler(mockOptions) - - const usage = { - inputTokens: 100, - outputTokens: 50, - } - - const providerMetadata = { - groq: { - promptCacheHitTokens: 20, - promptCacheMissTokens: 80, - }, - } - - const result = testHandler.testProcessUsageMetrics(usage, providerMetadata) - - expect(result.type).toBe("usage") - expect(result.inputTokens).toBe(100) - expect(result.outputTokens).toBe(50) - expect(result.cacheWriteTokens).toBe(80) - expect(result.cacheReadTokens).toBe(20) - }) - - it("should handle missing cache metrics gracefully", () => { - class TestGroqHandler extends GroqHandler { - public testProcessUsageMetrics(usage: any, providerMetadata?: any) { - return this.processUsageMetrics(usage, providerMetadata) - } - } - - const testHandler = new TestGroqHandler(mockOptions) - - const usage = { - inputTokens: 100, - outputTokens: 50, - } - - const result = testHandler.testProcessUsageMetrics(usage) - - expect(result.type).toBe("usage") - expect(result.inputTokens).toBe(100) - expect(result.outputTokens).toBe(50) - expect(result.cacheWriteTokens).toBeUndefined() - expect(result.cacheReadTokens).toBeUndefined() - }) - - it("should include reasoning tokens when provided", () => { - class TestGroqHandler extends GroqHandler { - public testProcessUsageMetrics(usage: any, providerMetadata?: any) { - return this.processUsageMetrics(usage, providerMetadata) - } - } - - const testHandler = new TestGroqHandler(mockOptions) - - const usage = { - inputTokens: 100, - outputTokens: 50, - details: { - reasoningTokens: 30, - }, - } - - const result = testHandler.testProcessUsageMetrics(usage) - - expect(result.reasoningTokens).toBe(30) - }) - }) - - describe("tool handling", () => { - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [{ type: "text" as const, text: "Hello!" }], - }, - ] - - it("should handle tool calls in streaming", async () => { - async function* mockFullStream() { - yield { - type: "tool-input-start", - id: "tool-call-1", - toolName: "read_file", - } - yield { - type: "tool-input-delta", - id: "tool-call-1", - delta: '{"path":"test.ts"}', - } - yield { - type: "tool-input-end", - id: "tool-call-1", - } - } - - const mockUsage = Promise.resolve({ - inputTokens: 10, - outputTokens: 5, - }) - - const mockProviderMetadata = Promise.resolve({}) - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: mockUsage, - providerMetadata: mockProviderMetadata, - }) - - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: [ - { - type: "function", - function: { - name: "read_file", - description: "Read a file", - parameters: { - type: "object", - properties: { path: { type: "string" } }, - required: ["path"], - }, - }, - }, - ], - }) - - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start") - const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta") - const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end") - - expect(toolCallStartChunks.length).toBe(1) - expect(toolCallStartChunks[0].id).toBe("tool-call-1") - expect(toolCallStartChunks[0].name).toBe("read_file") - - expect(toolCallDeltaChunks.length).toBe(1) - expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}') - - expect(toolCallEndChunks.length).toBe(1) - expect(toolCallEndChunks[0].id).toBe("tool-call-1") - }) - - it("should ignore tool-call events to prevent duplicate tools in UI", async () => { - async function* mockFullStream() { - yield { - type: "tool-call", - toolCallId: "tool-call-1", - toolName: "read_file", - input: { path: "test.ts" }, - } - } - - const mockUsage = Promise.resolve({ - inputTokens: 10, - outputTokens: 5, - }) - - const mockProviderMetadata = Promise.resolve({}) - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: mockUsage, - providerMetadata: mockProviderMetadata, - }) - - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: [ - { - type: "function", - function: { - name: "read_file", - description: "Read a file", - parameters: { - type: "object", - properties: { path: { type: "string" } }, - required: ["path"], - }, - }, - }, - ], - }) - - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // tool-call events are ignored, so no tool_call chunks should be emitted - const toolCallChunks = chunks.filter((c) => c.type === "tool_call") - expect(toolCallChunks.length).toBe(0) - }) - }) - - describe("getMaxOutputTokens", () => { - it("should return maxTokens from model info", () => { - class TestGroqHandler extends GroqHandler { - public testGetMaxOutputTokens() { - return this.getMaxOutputTokens() - } - } - - const testHandler = new TestGroqHandler({ - ...mockOptions, - apiModelId: "llama-3.1-8b-instant", - }) - const result = testHandler.testGetMaxOutputTokens() - - // llama-3.1-8b-instant has maxTokens of 8192 - expect(result).toBe(8192) - }) - - it("should use modelMaxTokens when provided", () => { - class TestGroqHandler extends GroqHandler { - public testGetMaxOutputTokens() { - return this.getMaxOutputTokens() - } - } - - const customMaxTokens = 5000 - const testHandler = new TestGroqHandler({ - ...mockOptions, - modelMaxTokens: customMaxTokens, - }) - - const result = testHandler.testGetMaxOutputTokens() - expect(result).toBe(customMaxTokens) - }) - }) -}) diff --git a/src/api/providers/__tests__/io-intelligence.spec.ts b/src/api/providers/__tests__/io-intelligence.spec.ts deleted file mode 100644 index 99dfcefea4..0000000000 --- a/src/api/providers/__tests__/io-intelligence.spec.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" - -import { IOIntelligenceHandler } from "../io-intelligence" -import type { ApiHandlerOptions } from "../../../shared/api" - -const mockCreate = vi.fn() - -// Mock OpenAI -vi.mock("openai", () => ({ - default: class MockOpenAI { - baseURL: string - apiKey: string - chat = { - completions: { - create: vi.fn(), - }, - } - constructor(options: any) { - this.baseURL = options.baseURL - this.apiKey = options.apiKey - this.chat.completions.create = mockCreate - } - }, -})) - -// Mock the fetcher functions -vi.mock("../fetchers/io-intelligence", () => ({ - getIOIntelligenceModels: vi.fn(), - getCachedIOIntelligenceModels: vi.fn(() => ({ - "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": { - maxTokens: 8192, - contextWindow: 430000, - description: "Llama 4 Maverick 17B model", - supportsImages: true, - supportsPromptCache: false, - }, - "deepseek-ai/DeepSeek-R1-0528": { - maxTokens: 8192, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - description: "DeepSeek R1 reasoning model", - }, - "Intel/Qwen3-Coder-480B-A35B-Instruct-int4-mixed-ar": { - maxTokens: 4096, - contextWindow: 106000, - supportsImages: false, - supportsPromptCache: false, - description: "Qwen3 Coder 480B specialized for coding", - }, - "openai/gpt-oss-120b": { - maxTokens: 8192, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - description: "OpenAI GPT-OSS 120B model", - }, - })), -})) - -// Mock constants -vi.mock("../constants", () => ({ - DEFAULT_HEADERS: { "User-Agent": "roo-cline" }, -})) - -// Mock transform functions -vi.mock("../../transform/openai-format", () => ({ - convertToOpenAiMessages: vi.fn((messages) => messages), -})) - -describe("IOIntelligenceHandler", () => { - let handler: IOIntelligenceHandler - let mockOptions: ApiHandlerOptions - - beforeEach(() => { - vi.clearAllMocks() - mockOptions = { - ioIntelligenceApiKey: "test-api-key", - apiModelId: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", - modelTemperature: 0.7, - includeMaxTokens: false, - modelMaxTokens: undefined, - } as ApiHandlerOptions - - mockCreate.mockImplementation(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { content: "Test response" }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } - }, - })) - handler = new IOIntelligenceHandler(mockOptions) - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it("should create OpenAI client with correct configuration", () => { - const ioIntelligenceApiKey = "test-io-intelligence-api-key" - const handler = new IOIntelligenceHandler({ ioIntelligenceApiKey }) - // Verify that the handler was created successfully - expect(handler).toBeInstanceOf(IOIntelligenceHandler) - expect(handler["client"]).toBeDefined() - // Verify the client has the expected properties - expect(handler["client"].baseURL).toBe("https://api.intelligence.io.solutions/api/v1") - expect(handler["client"].apiKey).toBe(ioIntelligenceApiKey) - }) - - it("should initialize with correct configuration", () => { - expect(handler).toBeInstanceOf(IOIntelligenceHandler) - expect(handler["client"]).toBeDefined() - expect(handler["options"]).toEqual({ - ...mockOptions, - apiKey: mockOptions.ioIntelligenceApiKey, - }) - }) - - it("should throw error when API key is missing", () => { - const optionsWithoutKey = { ...mockOptions } - delete optionsWithoutKey.ioIntelligenceApiKey - - expect(() => new IOIntelligenceHandler(optionsWithoutKey)).toThrow("IO Intelligence API key is required") - }) - - it("should handle streaming response correctly", async () => { - const mockStream = [ - { - choices: [{ delta: { content: "Hello" } }], - usage: null, - }, - { - choices: [{ delta: { content: " world" } }], - usage: null, - }, - { - choices: [{ delta: {} }], - usage: { prompt_tokens: 10, completion_tokens: 5 }, - }, - ] - - mockCreate.mockResolvedValue({ - [Symbol.asyncIterator]: async function* () { - for (const chunk of mockStream) { - yield chunk - } - }, - }) - - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] - - const stream = handler.createMessage("System prompt", messages) - const results = [] - - for await (const chunk of stream) { - results.push(chunk) - } - - expect(results).toHaveLength(3) - expect(results[0]).toEqual({ type: "text", text: "Hello" }) - expect(results[1]).toEqual({ type: "text", text: " world" }) - expect(results[2]).toMatchObject({ - type: "usage", - inputTokens: 10, - outputTokens: 5, - }) - }) - - it("completePrompt method should return text from IO Intelligence API", async () => { - const expectedResponse = "This is a test response from IO Intelligence" - mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] }) - const result = await handler.completePrompt("test prompt") - expect(result).toBe(expectedResponse) - }) - - it("should handle errors in completePrompt", async () => { - const errorMessage = "IO Intelligence API error" - mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - `IO Intelligence completion error: ${errorMessage}`, - ) - }) - - it("createMessage should yield text content from stream", async () => { - const testContent = "This is test content from IO Intelligence stream" - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: testContent } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) - }) - - it("createMessage should yield usage data from stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 }) - }) - - it("should return model info from cache when available", () => { - const model = handler.getModel() - expect(model.id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8") - expect(model.info).toEqual({ - maxTokens: 8192, - contextWindow: 430000, - description: "Llama 4 Maverick 17B model", - supportsImages: true, - supportsPromptCache: false, - }) - }) - - it("should return fallback model info when not in cache", () => { - const handlerWithUnknownModel = new IOIntelligenceHandler({ - ...mockOptions, - apiModelId: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", - }) - const model = handlerWithUnknownModel.getModel() - expect(model.id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8") - expect(model.info).toEqual({ - maxTokens: 8192, - contextWindow: 430000, - description: "Llama 4 Maverick 17B model", - supportsImages: true, - supportsPromptCache: false, - }) - }) - - it("should use default model when no model is specified", () => { - const handlerWithoutModel = new IOIntelligenceHandler({ - ...mockOptions, - apiModelId: undefined, - }) - const model = handlerWithoutModel.getModel() - expect(model.id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8") - }) - - it("should handle empty response from completePrompt", async () => { - mockCreate.mockResolvedValueOnce({ - choices: [{ message: { content: null } }], - }) - - const result = await handler.completePrompt("Test prompt") - expect(result).toBe("") - }) - - it("should handle missing choices in completePrompt response", async () => { - mockCreate.mockResolvedValueOnce({ - choices: [], - }) - - const result = await handler.completePrompt("Test prompt") - expect(result).toBe("") - }) -}) diff --git a/src/api/providers/__tests__/lm-studio-timeout.spec.ts b/src/api/providers/__tests__/lm-studio-timeout.spec.ts index 659fcaaf67..30cf210783 100644 --- a/src/api/providers/__tests__/lm-studio-timeout.spec.ts +++ b/src/api/providers/__tests__/lm-studio-timeout.spec.ts @@ -1,62 +1,69 @@ // npx vitest run api/providers/__tests__/lm-studio-timeout.spec.ts +const { mockCreateOpenAICompatible } = vi.hoisted(() => ({ + mockCreateOpenAICompatible: vi.fn(() => { + return vi.fn(() => ({ + modelId: "llama2", + provider: "lmstudio", + })) + }), +})) + +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: mockCreateOpenAICompatible, +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: vi.fn(), + generateText: vi.fn(), + } +}) + import { LmStudioHandler } from "../lm-studio" import { ApiHandlerOptions } from "../../../shared/api" -// Mock the timeout config utility -vitest.mock("../utils/timeout-config", () => ({ - getApiRequestTimeout: vitest.fn(), -})) - -import { getApiRequestTimeout } from "../utils/timeout-config" - -// Mock OpenAI -const mockOpenAIConstructor = vitest.fn() -vitest.mock("openai", () => { - return { - __esModule: true, - default: vitest.fn().mockImplementation((config) => { - mockOpenAIConstructor(config) - return { - chat: { - completions: { - create: vitest.fn(), - }, - }, - } - }), - } -}) - -describe("LmStudioHandler timeout configuration", () => { +describe("LmStudioHandler configuration", () => { beforeEach(() => { - vitest.clearAllMocks() + vi.clearAllMocks() }) - it("should use default timeout of 600 seconds when no configuration is set", () => { - ;(getApiRequestTimeout as any).mockReturnValue(600000) - + it("should configure the provider with default base URL", () => { const options: ApiHandlerOptions = { apiModelId: "llama2", lmStudioModelId: "llama2", - lmStudioBaseUrl: "http://localhost:1234", } new LmStudioHandler(options) - expect(getApiRequestTimeout).toHaveBeenCalled() - expect(mockOpenAIConstructor).toHaveBeenCalledWith( + expect(mockCreateOpenAICompatible).toHaveBeenCalledWith( expect.objectContaining({ + name: "lmstudio", baseURL: "http://localhost:1234/v1", apiKey: "noop", - timeout: 600000, // 600 seconds in milliseconds }), ) }) - it("should use custom timeout when configuration is set", () => { - ;(getApiRequestTimeout as any).mockReturnValue(1200000) // 20 minutes + it("should configure the provider with custom base URL", () => { + const options: ApiHandlerOptions = { + apiModelId: "llama2", + lmStudioModelId: "llama2", + lmStudioBaseUrl: "http://localhost:5678", + } + new LmStudioHandler(options) + + expect(mockCreateOpenAICompatible).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "http://localhost:5678/v1", + }), + ) + }) + + it("should use 'noop' as the API key", () => { const options: ApiHandlerOptions = { apiModelId: "llama2", lmStudioModelId: "llama2", @@ -65,26 +72,9 @@ describe("LmStudioHandler timeout configuration", () => { new LmStudioHandler(options) - expect(mockOpenAIConstructor).toHaveBeenCalledWith( + expect(mockCreateOpenAICompatible).toHaveBeenCalledWith( expect.objectContaining({ - timeout: 1200000, // 1200 seconds in milliseconds - }), - ) - }) - - it("should handle zero timeout (no timeout)", () => { - ;(getApiRequestTimeout as any).mockReturnValue(0) - - const options: ApiHandlerOptions = { - apiModelId: "llama2", - lmStudioModelId: "llama2", - } - - new LmStudioHandler(options) - - expect(mockOpenAIConstructor).toHaveBeenCalledWith( - expect.objectContaining({ - timeout: 0, // No timeout + apiKey: "noop", }), ) }) diff --git a/src/api/providers/__tests__/lmstudio-native-tools.spec.ts b/src/api/providers/__tests__/lmstudio-native-tools.spec.ts index cca543a269..f9a0b10fb6 100644 --- a/src/api/providers/__tests__/lmstudio-native-tools.spec.ts +++ b/src/api/providers/__tests__/lmstudio-native-tools.spec.ts @@ -1,22 +1,28 @@ // npx vitest run api/providers/__tests__/lmstudio-native-tools.spec.ts -// Mock OpenAI client - must come before other imports -const mockCreate = vi.fn() -vi.mock("openai", () => { +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - __esModule: true, - default: vi.fn().mockImplementation(() => ({ - chat: { - completions: { - create: mockCreate, - }, - }, - })), + ...actual, + streamText: mockStreamText, } }) +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + return vi.fn(() => ({ + modelId: "local-model", + provider: "lmstudio", + })) + }), +})) + import { LmStudioHandler } from "../lm-studio" -import { NativeToolCallParser } from "../../../core/assistant-message/NativeToolCallParser" import type { ApiHandlerOptions } from "../../../shared/api" describe("LmStudioHandler Native Tools", () => { @@ -49,128 +55,76 @@ describe("LmStudioHandler Native Tools", () => { lmStudioBaseUrl: "http://localhost:1234", } handler = new LmStudioHandler(mockOptions) - - // Clear NativeToolCallParser state before each test - NativeToolCallParser.clearRawChunkState() }) describe("Native Tool Calling Support", () => { - it("should include tools in request when model supports native tools and tools are provided", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Test response" } }], - } - }, - })) + it("should include tools in request when tools are provided", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + }) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", tools: testTools, }) - await stream.next() + for await (const _chunk of stream) { + // consume stream + } - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - type: "function", - function: expect.objectContaining({ - name: "test_tool", - }), - }), - ]), - }), - ) - // parallel_tool_calls should be true by default when not explicitly set - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs).toHaveProperty("parallel_tool_calls", true) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.tools).toBeDefined() }) - it("should include tool_choice when provided", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Test response" } }], - } - }, - })) + it("should include toolChoice when provided", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + }) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", tools: testTools, tool_choice: "auto", }) - await stream.next() + for await (const _chunk of stream) { + // consume stream + } - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tool_choice: "auto", - }), - ) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.toolChoice).toBe("auto") }) - it("should always include tools and tool_choice in request (tools are always present after PR #10841)", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Test response" } }], - } - }, - })) + it("should yield tool_call_start, tool_call_delta, and tool_call_end chunks from AI SDK stream", async () => { + async function* mockFullStream() { + yield { + type: "tool-input-start", + id: "call_lmstudio_123", + toolName: "test_tool", + } + yield { + type: "tool-input-delta", + id: "call_lmstudio_123", + delta: '{"arg1":"value"}', + } + yield { + type: "tool-input-end", + id: "call_lmstudio_123", + } + } - const stream = handler.createMessage("test prompt", [], { - taskId: "test-task-id", + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), }) - await stream.next() - - const callArgs = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0] - // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS) - expect(callArgs).toHaveProperty("tools") - expect(callArgs).toHaveProperty("tool_choice") - // parallel_tool_calls should be true by default when not explicitly set - expect(callArgs).toHaveProperty("parallel_tool_calls", true) - }) - - it("should yield tool_call_partial chunks during streaming", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_lmstudio_123", - function: { - name: "test_tool", - arguments: '{"arg1":', - }, - }, - ], - }, - }, - ], - } - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { - arguments: '"value"}', - }, - }, - ], - }, - }, - ], - } - }, - })) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", @@ -182,168 +136,56 @@ describe("LmStudioHandler Native Tools", () => { chunks.push(chunk) } - expect(chunks).toContainEqual({ - type: "tool_call_partial", - index: 0, + const startChunks = chunks.filter((chunk) => chunk.type === "tool_call_start") + expect(startChunks).toHaveLength(1) + expect(startChunks[0]).toEqual({ + type: "tool_call_start", id: "call_lmstudio_123", name: "test_tool", - arguments: '{"arg1":', }) - expect(chunks).toContainEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: '"value"}', - }) - }) - - it("should set parallel_tool_calls based on metadata", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Test response" } }], - } - }, - })) - - const stream = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - parallelToolCalls: true, - }) - await stream.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - parallel_tool_calls: true, - }), - ) - }) - - it("should yield tool_call_end events when finish_reason is tool_calls", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_lmstudio_test", - function: { - name: "test_tool", - arguments: '{"arg1":"value"}', - }, - }, - ], - }, - }, - ], - } - yield { - choices: [ - { - delta: {}, - finish_reason: "tool_calls", - }, - ], - } - }, - })) - - const stream = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, + const deltaChunks = chunks.filter((chunk) => chunk.type === "tool_call_delta") + expect(deltaChunks).toHaveLength(1) + expect(deltaChunks[0]).toEqual({ + type: "tool_call_delta", + id: "call_lmstudio_123", + delta: '{"arg1":"value"}', }) - const chunks = [] - for await (const chunk of stream) { - // Simulate what Task.ts does: when we receive tool_call_partial, - // process it through NativeToolCallParser to populate rawChunkTracker - if (chunk.type === "tool_call_partial") { - NativeToolCallParser.processRawChunk({ - index: chunk.index, - id: chunk.id, - name: chunk.name, - arguments: chunk.arguments, - }) - } - chunks.push(chunk) - } - - // Should have tool_call_partial and tool_call_end - const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") - - expect(partialChunks).toHaveLength(1) expect(endChunks).toHaveLength(1) - expect(endChunks[0].id).toBe("call_lmstudio_test") - }) - - it("should work with parallel tool calls disabled (sends false)", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Response" } }], - } - }, - })) - - const stream = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - parallelToolCalls: false, + expect(endChunks[0]).toEqual({ + type: "tool_call_end", + id: "call_lmstudio_123", }) - await stream.next() - - // When parallelToolCalls is false, the parameter should be sent as false - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs).toHaveProperty("parallel_tool_calls", false) }) it("should handle reasoning content alongside tool calls", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - content: "Thinking about this...", - }, - }, - ], - } - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_after_think", - function: { - name: "test_tool", - arguments: '{"arg1":"result"}', - }, - }, - ], - }, - }, - ], - } - yield { - choices: [ - { - delta: {}, - finish_reason: "tool_calls", - }, - ], - } - }, - })) + async function* mockFullStream() { + yield { + type: "reasoning", + text: "Thinking about this...", + } + yield { + type: "tool-input-start", + id: "call_after_think", + toolName: "test_tool", + } + yield { + type: "tool-input-delta", + id: "call_after_think", + delta: '{"arg1":"result"}', + } + yield { + type: "tool-input-end", + id: "call_after_think", + } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + }) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", @@ -352,25 +194,60 @@ describe("LmStudioHandler Native Tools", () => { const chunks = [] for await (const chunk of stream) { - if (chunk.type === "tool_call_partial") { - NativeToolCallParser.processRawChunk({ - index: chunk.index, - id: chunk.id, - name: chunk.name, - arguments: chunk.arguments, - }) - } chunks.push(chunk) } - // Should have reasoning, tool_call_partial, and tool_call_end const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") - const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + const startChunks = chunks.filter((chunk) => chunk.type === "tool_call_start") const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") expect(reasoningChunks).toHaveLength(1) expect(reasoningChunks[0].text).toBe("Thinking about this...") - expect(partialChunks).toHaveLength(1) + expect(startChunks).toHaveLength(1) + expect(endChunks).toHaveLength(1) + }) + + it("should handle text and tool calls in the same response", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Here's the result: " } + yield { + type: "tool-input-start", + id: "call_mixed", + toolName: "test_tool", + } + yield { + type: "tool-input-delta", + id: "call_mixed", + delta: '{"arg1":"mixed"}', + } + yield { + type: "tool-input-end", + id: "call_mixed", + } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + }) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + }) + + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((chunk) => chunk.type === "text") + const startChunks = chunks.filter((chunk) => chunk.type === "tool_call_start") + const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Here's the result: ") + expect(startChunks).toHaveLength(1) expect(endChunks).toHaveLength(1) }) }) diff --git a/src/api/providers/__tests__/lmstudio.spec.ts b/src/api/providers/__tests__/lmstudio.spec.ts index 0adebdeea7..0f6944e8da 100644 --- a/src/api/providers/__tests__/lmstudio.spec.ts +++ b/src/api/providers/__tests__/lmstudio.spec.ts @@ -1,63 +1,29 @@ -// Mock OpenAI client - must come before other imports -const mockCreate = vi.fn() -vi.mock("openai", () => { - return { - __esModule: true, - default: vi.fn().mockImplementation(() => ({ - chat: { - completions: { - create: mockCreate.mockImplementation(async (options) => { - if (!options.stream) { - return { - id: "test-completion", - choices: [ - { - message: { role: "assistant", content: "Test response" }, - finish_reason: "stop", - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } - } +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText, mockWrapLanguageModel } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), + mockWrapLanguageModel: vi.fn((opts: any) => opts.model), +})) - return { - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { content: "Test response" }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } - }, - } - }), - }, - }, - })), +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + wrapLanguageModel: mockWrapLanguageModel, } }) +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + return vi.fn(() => ({ + modelId: "local-model", + provider: "lmstudio", + })) + }), +})) + import type { Anthropic } from "@anthropic-ai/sdk" import { LmStudioHandler } from "../lm-studio" @@ -74,7 +40,7 @@ describe("LmStudioHandler", () => { lmStudioBaseUrl: "http://localhost:1234", } handler = new LmStudioHandler(mockOptions) - mockCreate.mockClear() + vi.clearAllMocks() }) describe("constructor", () => { @@ -102,6 +68,20 @@ describe("LmStudioHandler", () => { ] it("should handle streaming responses", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { @@ -114,8 +94,43 @@ describe("LmStudioHandler", () => { expect(textChunks[0].text).toBe("Test response") }) + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(5) + }) + it("should handle API errors", async () => { - mockCreate.mockRejectedValueOnce(new Error("API Error")) + async function* mockFullStream(): AsyncGenerator<{ type: string; text: string }> { + yield { type: "text-delta", text: "" } + throw new Error("API Error") + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) const stream = handler.createMessage(systemPrompt, messages) @@ -123,36 +138,37 @@ describe("LmStudioHandler", () => { for await (const _chunk of stream) { // Should not reach here } - }).rejects.toThrow("Please check the LM Studio developer logs to debug what went wrong") + }).rejects.toThrow() }) }) describe("completePrompt", () => { it("should complete prompt successfully", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test response", + }) + const result = await handler.completePrompt("Test prompt") expect(result).toBe("Test response") - expect(mockCreate).toHaveBeenCalledWith({ - model: mockOptions.lmStudioModelId, - messages: [{ role: "user", content: "Test prompt" }], - temperature: 0, - stream: false, - }) - }) - - it("should handle API errors", async () => { - mockCreate.mockRejectedValueOnce(new Error("API Error")) - await expect(handler.completePrompt("Test prompt")).rejects.toThrow( - "Please check the LM Studio developer logs to debug what went wrong", + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), ) }) it("should handle empty response", async () => { - mockCreate.mockResolvedValueOnce({ - choices: [{ message: { content: "" } }], + mockGenerateText.mockResolvedValue({ + text: "", }) const result = await handler.completePrompt("Test prompt") expect(result).toBe("") }) + + it("should handle API errors with handleAiSdkError", async () => { + mockGenerateText.mockRejectedValueOnce(new Error("Connection refused")) + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("LM Studio") + }) }) describe("getModel", () => { @@ -164,4 +180,131 @@ describe("LmStudioHandler", () => { expect(modelInfo.info.contextWindow).toBe(128_000) }) }) + + describe("speculative decoding", () => { + it("should include draft_model in providerOptions when speculative decoding is enabled", async () => { + const speculativeHandler = new LmStudioHandler({ + ...mockOptions, + lmStudioSpeculativeDecodingEnabled: true, + lmStudioDraftModelId: "draft-model-id", + }) + + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 3 }), + }) + + const stream = speculativeHandler.createMessage("test prompt", []) + for await (const _chunk of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: { + lmstudio: { draft_model: "draft-model-id" }, + }, + }), + ) + }) + + it("should not include draft_model when speculative decoding is disabled", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 3 }), + }) + + const stream = handler.createMessage("test prompt", []) + for await (const _chunk of stream) { + // consume stream + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions).toBeUndefined() + }) + + it("should include draft_model in completePrompt when speculative decoding is enabled", async () => { + const speculativeHandler = new LmStudioHandler({ + ...mockOptions, + lmStudioSpeculativeDecodingEnabled: true, + lmStudioDraftModelId: "draft-model-id", + }) + + mockGenerateText.mockResolvedValue({ text: "Test" }) + + await speculativeHandler.completePrompt("Test prompt") + + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: { + lmstudio: { draft_model: "draft-model-id" }, + }, + }), + ) + }) + }) + + describe("reasoning middleware", () => { + it("should wrap the language model with extractReasoningMiddleware for tags", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 3 }), + }) + + const stream = handler.createMessage("test prompt", []) + for await (const _chunk of stream) { + // consume stream to trigger getLanguageModel() + } + + expect(mockWrapLanguageModel).toHaveBeenCalledWith( + expect.objectContaining({ + middleware: expect.any(Object), + }), + ) + }) + + it("should handle reasoning-delta chunks from middleware-processed stream", async () => { + async function* mockFullStream() { + yield { type: "reasoning-delta", text: "Let me think about this..." } + yield { type: "text-delta", text: "The answer is 42." } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 8 }), + }) + + const stream = handler.createMessage("test prompt", []) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const reasoningChunks = chunks.filter((c) => c.type === "reasoning") + const textChunks = chunks.filter((c) => c.type === "text") + + expect(reasoningChunks).toHaveLength(1) + expect(reasoningChunks[0].text).toBe("Let me think about this...") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("The answer is 42.") + }) + }) + + describe("isAiSdkProvider", () => { + it("should return true", () => { + expect(handler.isAiSdkProvider()).toBe(true) + }) + }) }) diff --git a/src/api/providers/__tests__/mistral.spec.ts b/src/api/providers/__tests__/mistral.spec.ts index 28aae09658..0cac881dff 100644 --- a/src/api/providers/__tests__/mistral.spec.ts +++ b/src/api/providers/__tests__/mistral.spec.ts @@ -1,59 +1,36 @@ -// Mock TelemetryService - must come before other imports -const mockCaptureException = vi.hoisted(() => vi.fn()) -vi.mock("@roo-code/telemetry", () => ({ - TelemetryService: { - instance: { - captureException: mockCaptureException, - }, - }, +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText, mockCreateMistral } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), + mockCreateMistral: vi.fn(() => { + // Return a function that returns a mock language model + return vi.fn(() => ({ + modelId: "codestral-latest", + provider: "mistral", + })) + }), })) -// Mock Mistral client - must come before other imports -const mockCreate = vi.fn() -const mockComplete = vi.fn() -vi.mock("@mistralai/mistralai", () => { +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - Mistral: vi.fn().mockImplementation(() => ({ - chat: { - stream: mockCreate.mockImplementation(async (_options) => { - const stream = { - [Symbol.asyncIterator]: async function* () { - yield { - data: { - choices: [ - { - delta: { content: "Test response" }, - index: 0, - }, - ], - }, - } - }, - } - return stream - }), - complete: mockComplete.mockImplementation(async (_options) => { - return { - choices: [ - { - message: { - content: "Test response", - }, - }, - ], - } - }), - }, - })), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) +vi.mock("@ai-sdk/mistral", () => ({ + createMistral: mockCreateMistral, +})) + import type { Anthropic } from "@anthropic-ai/sdk" -import type OpenAI from "openai" -import { MistralHandler } from "../mistral" + +import { mistralDefaultModelId, mistralModels, type MistralModelId } from "@roo-code/types" + import type { ApiHandlerOptions } from "../../../shared/api" -import type { ApiHandlerCreateMessageMetadata } from "../../index" -import type { ApiStreamTextChunk, ApiStreamReasoningChunk, ApiStreamToolCallPartialChunk } from "../../transform/stream" + +import { MistralHandler } from "../mistral" describe("MistralHandler", () => { let handler: MistralHandler @@ -61,15 +38,11 @@ describe("MistralHandler", () => { beforeEach(() => { mockOptions = { - apiModelId: "codestral-latest", // Update to match the actual model ID mistralApiKey: "test-api-key", - includeMaxTokens: true, - modelTemperature: 0, + apiModelId: "codestral-latest" as MistralModelId, } handler = new MistralHandler(mockOptions) - mockCreate.mockClear() - mockComplete.mockClear() - mockCaptureException.mockClear() + vi.clearAllMocks() }) describe("constructor", () => { @@ -78,32 +51,53 @@ describe("MistralHandler", () => { expect(handler.getModel().id).toBe(mockOptions.apiModelId) }) - it("should throw error if API key is missing", () => { - expect(() => { - new MistralHandler({ - ...mockOptions, - mistralApiKey: undefined, - }) - }).toThrow("Mistral API key is required") - }) - - it("should use custom base URL if provided", () => { - const customBaseUrl = "https://custom.mistral.ai/v1" - const handlerWithCustomUrl = new MistralHandler({ + it("should use default model ID if not provided", () => { + const handlerWithoutModel = new MistralHandler({ ...mockOptions, - mistralCodestralUrl: customBaseUrl, + apiModelId: undefined, }) - expect(handlerWithCustomUrl).toBeInstanceOf(MistralHandler) + expect(handlerWithoutModel.getModel().id).toBe(mistralDefaultModelId) }) }) describe("getModel", () => { - it("should return correct model info", () => { + it("should return model info for valid model ID", () => { const model = handler.getModel() expect(model.id).toBe(mockOptions.apiModelId) expect(model.info).toBeDefined() + expect(model.info.maxTokens).toBe(8192) + expect(model.info.contextWindow).toBe(256_000) + expect(model.info.supportsImages).toBe(false) expect(model.info.supportsPromptCache).toBe(false) }) + + it("should return provided model ID with default model info if model does not exist", () => { + const handlerWithInvalidModel = new MistralHandler({ + ...mockOptions, + apiModelId: "invalid-model", + }) + const model = handlerWithInvalidModel.getModel() + expect(model.id).toBe("invalid-model") // Returns provided ID + expect(model.info).toBeDefined() + // Should have the same base properties as default model + expect(model.info.contextWindow).toBe(mistralModels[mistralDefaultModelId].contextWindow) + }) + + it("should return default model if no model ID is provided", () => { + const handlerWithoutModel = new MistralHandler({ + ...mockOptions, + apiModelId: undefined, + }) + const model = handlerWithoutModel.getModel() + expect(model.id).toBe(mistralDefaultModelId) + expect(model.info).toBeDefined() + }) + + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") + }) }) describe("createMessage", () => { @@ -111,389 +105,446 @@ describe("MistralHandler", () => { const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", - content: [{ type: "text", text: "Hello!" }], + content: [ + { + type: "text" as const, + text: "Hello!", + }, + ], }, ] - it("should create message successfully", async () => { - const iterator = handler.createMessage(systemPrompt, messages) - const result = await iterator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: mockOptions.apiModelId, - messages: expect.any(Array), - maxTokens: expect.any(Number), - temperature: 0, - // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS) - tools: expect.any(Array), - toolChoice: "any", - }), - ) - - expect(result.value).toBeDefined() - expect(result.done).toBe(false) - }) - - it("should handle streaming response correctly", async () => { - const iterator = handler.createMessage(systemPrompt, messages) - const results: ApiStreamTextChunk[] = [] - - for await (const chunk of iterator) { - if ("text" in chunk) { - results.push(chunk as ApiStreamTextChunk) - } + it("should handle streaming responses", async () => { + // Mock the fullStream async generator + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } } - expect(results.length).toBeGreaterThan(0) - expect(results[0].text).toBe("Test response") - }) - - it("should handle errors gracefully", async () => { - mockCreate.mockRejectedValueOnce(new Error("API Error")) - await expect(handler.createMessage(systemPrompt, messages).next()).rejects.toThrow("API Error") - }) - - it("should handle thinking content as reasoning chunks", async () => { - // Mock stream with thinking content matching new SDK structure - mockCreate.mockImplementationOnce(async (_options) => { - const stream = { - [Symbol.asyncIterator]: async function* () { - yield { - data: { - choices: [ - { - delta: { - content: [ - { - type: "thinking", - thinking: [{ type: "text", text: "Let me think about this..." }], - }, - { type: "text", text: "Here's the answer" }, - ], - }, - index: 0, - }, - ], - }, - } - }, - } - return stream + // Mock usage promise + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, }) - const iterator = handler.createMessage(systemPrompt, messages) - const results: (ApiStreamTextChunk | ApiStreamReasoningChunk)[] = [] - - for await (const chunk of iterator) { - if ("text" in chunk) { - results.push(chunk as ApiStreamTextChunk | ApiStreamReasoningChunk) - } - } - - expect(results).toHaveLength(2) - expect(results[0]).toEqual({ type: "reasoning", text: "Let me think about this..." }) - expect(results[1]).toEqual({ type: "text", text: "Here's the answer" }) - }) - - it("should handle mixed content arrays correctly", async () => { - // Mock stream with mixed content matching new SDK structure - mockCreate.mockImplementationOnce(async (_options) => { - const stream = { - [Symbol.asyncIterator]: async function* () { - yield { - data: { - choices: [ - { - delta: { - content: [ - { type: "text", text: "First text" }, - { - type: "thinking", - thinking: [{ type: "text", text: "Some reasoning" }], - }, - { type: "text", text: "Second text" }, - ], - }, - index: 0, - }, - ], - }, - } - }, - } - return stream + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, }) - const iterator = handler.createMessage(systemPrompt, messages) - const results: (ApiStreamTextChunk | ApiStreamReasoningChunk)[] = [] - - for await (const chunk of iterator) { - if ("text" in chunk) { - results.push(chunk as ApiStreamTextChunk | ApiStreamReasoningChunk) - } + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - expect(results).toHaveLength(3) - expect(results[0]).toEqual({ type: "text", text: "First text" }) - expect(results[1]).toEqual({ type: "reasoning", text: "Some reasoning" }) - expect(results[2]).toEqual({ type: "text", text: "Second text" }) + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response") }) - }) - describe("native tool calling", () => { - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [{ type: "text", text: "What's the weather?" }], - }, - ] + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } - const mockTools: OpenAI.Chat.ChatCompletionTool[] = [ - { - type: "function", - function: { - name: "get_weather", - description: "Get the current weather", - parameters: { - type: "object", - properties: { - location: { type: "string" }, - }, - required: ["location"], - }, + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(5) + }) + + it("should handle reasoning content in streaming responses", async () => { + // Mock the fullStream async generator with reasoning content + async function* mockFullStream() { + yield { type: "reasoning", text: "Let me think about this..." } + yield { type: "reasoning", text: " I'll analyze step by step." } + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: { + reasoningTokens: 15, }, - }, - ] - - it("should include tools in request by default (native is default)", async () => { - const metadata: ApiHandlerCreateMessageMetadata = { - taskId: "test-task", - tools: mockTools, - } - - const iterator = handler.createMessage(systemPrompt, messages, metadata) - await iterator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - type: "function", - function: expect.objectContaining({ - name: "get_weather", - description: "Get the current weather", - parameters: expect.any(Object), - }), - }), - ]), - toolChoice: "any", - }), - ) - }) - - it("should always include tools in request (tools are always present after PR #10841)", async () => { - const metadata: ApiHandlerCreateMessageMetadata = { - taskId: "test-task", - } - - const iterator = handler.createMessage(systemPrompt, messages, metadata) - await iterator.next() - - // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS) - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.any(Array), - toolChoice: "any", - }), - ) - }) - - it("should handle tool calls in streaming response", async () => { - // Mock stream with tool calls - mockCreate.mockImplementationOnce(async (_options) => { - const stream = { - [Symbol.asyncIterator]: async function* () { - yield { - data: { - choices: [ - { - delta: { - toolCalls: [ - { - id: "call_123", - type: "function", - function: { - name: "get_weather", - arguments: '{"location":"New York"}', - }, - }, - ], - }, - index: 0, - }, - ], - }, - } - }, - } - return stream }) - const metadata: ApiHandlerCreateMessageMetadata = { - taskId: "test-task", - tools: mockTools, - } - - const iterator = handler.createMessage(systemPrompt, messages, metadata) - const results: ApiStreamToolCallPartialChunk[] = [] - - for await (const chunk of iterator) { - if (chunk.type === "tool_call_partial") { - results.push(chunk) - } - } - - expect(results).toHaveLength(1) - expect(results[0]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "call_123", - name: "get_weather", - arguments: '{"location":"New York"}', - }) - }) - - it("should handle multiple tool calls in a single response", async () => { - // Mock stream with multiple tool calls - mockCreate.mockImplementationOnce(async (_options) => { - const stream = { - [Symbol.asyncIterator]: async function* () { - yield { - data: { - choices: [ - { - delta: { - toolCalls: [ - { - id: "call_1", - type: "function", - function: { - name: "get_weather", - arguments: '{"location":"NYC"}', - }, - }, - { - id: "call_2", - type: "function", - function: { - name: "get_weather", - arguments: '{"location":"LA"}', - }, - }, - ], - }, - index: 0, - }, - ], - }, - } - }, - } - return stream + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, }) - const metadata: ApiHandlerCreateMessageMetadata = { - taskId: "test-task", - tools: mockTools, + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - const iterator = handler.createMessage(systemPrompt, messages, metadata) - const results: ApiStreamToolCallPartialChunk[] = [] + // Should have reasoning chunks + const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") + expect(reasoningChunks.length).toBe(2) + expect(reasoningChunks[0].text).toBe("Let me think about this...") + expect(reasoningChunks[1].text).toBe(" I'll analyze step by step.") - for await (const chunk of iterator) { - if (chunk.type === "tool_call_partial") { - results.push(chunk) - } - } - - expect(results).toHaveLength(2) - expect(results[0]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "call_1", - name: "get_weather", - arguments: '{"location":"NYC"}', - }) - expect(results[1]).toEqual({ - type: "tool_call_partial", - index: 1, - id: "call_2", - name: "get_weather", - arguments: '{"location":"LA"}', - }) - }) - - it("should always set toolChoice to 'any' when tools are provided", async () => { - // Even if tool_choice is provided in metadata, we override it to "any" - const metadata: ApiHandlerCreateMessageMetadata = { - taskId: "test-task", - tools: mockTools, - tool_choice: "auto", // This should be ignored - } - - const iterator = handler.createMessage(systemPrompt, messages, metadata) - await iterator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - toolChoice: "any", - }), - ) + // Should also have text chunks + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks.length).toBe(1) + expect(textChunks[0].text).toBe("Test response") }) }) describe("completePrompt", () => { - it("should complete prompt successfully", async () => { - const prompt = "Test prompt" - const result = await handler.completePrompt(prompt) - - expect(mockComplete).toHaveBeenCalledWith({ - model: mockOptions.apiModelId, - messages: [{ role: "user", content: prompt }], - temperature: 0, + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", }) - expect(result).toBe("Test response") + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("Test completion") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) + }) + }) + + describe("processUsageMetrics", () => { + it("should correctly process usage metrics", () => { + // We need to access the protected method, so we'll create a test subclass + class TestMistralHandler extends MistralHandler { + public testProcessUsageMetrics(usage: any) { + return this.processUsageMetrics(usage) + } + } + + const testHandler = new TestMistralHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + details: { + cachedInputTokens: 20, + reasoningTokens: 30, + }, + } + + const result = testHandler.testProcessUsageMetrics(usage) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheReadTokens).toBe(20) + expect(result.reasoningTokens).toBe(30) }) - it("should filter out thinking content in completePrompt", async () => { - mockComplete.mockImplementationOnce(async (_options) => { - return { - choices: [ - { - message: { - content: [ - { type: "thinking", text: "Let me think..." }, - { type: "text", text: "Answer part 1" }, - { type: "text", text: "Answer part 2" }, - ], + it("should handle missing cache metrics gracefully", () => { + class TestMistralHandler extends MistralHandler { + public testProcessUsageMetrics(usage: any) { + return this.processUsageMetrics(usage) + } + } + + const testHandler = new TestMistralHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + } + + const result = testHandler.testProcessUsageMetrics(usage) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheReadTokens).toBeUndefined() + expect(result.reasoningTokens).toBeUndefined() + }) + }) + + describe("getMaxOutputTokens", () => { + it("should return maxTokens from model info", () => { + class TestMistralHandler extends MistralHandler { + public testGetMaxOutputTokens() { + return this.getMaxOutputTokens() + } + } + + const testHandler = new TestMistralHandler(mockOptions) + const result = testHandler.testGetMaxOutputTokens() + + // codestral-latest maxTokens is 8192 + expect(result).toBe(8192) + }) + + it("should use modelMaxTokens when provided", () => { + class TestMistralHandler extends MistralHandler { + public testGetMaxOutputTokens() { + return this.getMaxOutputTokens() + } + } + + const customMaxTokens = 5000 + const testHandler = new TestMistralHandler({ + ...mockOptions, + modelMaxTokens: customMaxTokens, + }) + + const result = testHandler.testGetMaxOutputTokens() + expect(result).toBe(customMaxTokens) + }) + + it("should fall back to modelInfo.maxTokens when modelMaxTokens is not provided", () => { + class TestMistralHandler extends MistralHandler { + public testGetMaxOutputTokens() { + return this.getMaxOutputTokens() + } + } + + const testHandler = new TestMistralHandler(mockOptions) + const result = testHandler.testGetMaxOutputTokens() + + // codestral-latest has maxTokens of 8192 + expect(result).toBe(8192) + }) + }) + + describe("tool handling", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle tool calls in streaming", async () => { + async function* mockFullStream() { + yield { + type: "tool-input-start", + id: "tool-call-1", + toolName: "read_file", + } + yield { + type: "tool-input-delta", + id: "tool-call-1", + delta: '{"path":"test.ts"}', + } + yield { + type: "tool-input-end", + id: "tool-call-1", + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], }, }, - ], - } + }, + ], }) - const prompt = "Test prompt" - const result = await handler.completePrompt(prompt) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } - expect(result).toBe("Answer part 1Answer part 2") + const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start") + const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end") + + expect(toolCallStartChunks.length).toBe(1) + expect(toolCallStartChunks[0].id).toBe("tool-call-1") + expect(toolCallStartChunks[0].name).toBe("read_file") + + expect(toolCallDeltaChunks.length).toBe(1) + expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}') + + expect(toolCallEndChunks.length).toBe(1) + expect(toolCallEndChunks[0].id).toBe("tool-call-1") }) - it("should handle errors in completePrompt", async () => { - mockComplete.mockRejectedValueOnce(new Error("API Error")) - await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Mistral completion error: API Error") + it("should ignore tool-call events to prevent duplicate tools in UI", async () => { + // tool-call events are intentionally ignored because tool-input-start/delta/end + // already provide complete tool call information. Emitting tool-call would cause + // duplicate tools in the UI for AI SDK providers. + async function* mockFullStream() { + yield { + type: "tool-call", + toolCallId: "tool-call-1", + toolName: "read_file", + input: { path: "test.ts" }, + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + ], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // tool-call events are ignored, so no tool_call chunks should be emitted + const toolCallChunks = chunks.filter((c) => c.type === "tool_call") + expect(toolCallChunks.length).toBe(0) + }) + }) + + describe("mapToolChoice", () => { + it("should handle string tool choices", () => { + class TestMistralHandler extends MistralHandler { + public testMapToolChoice(toolChoice: any) { + return this.mapToolChoice(toolChoice) + } + } + + const testHandler = new TestMistralHandler(mockOptions) + + expect(testHandler.testMapToolChoice("auto")).toBe("auto") + expect(testHandler.testMapToolChoice("none")).toBe("none") + expect(testHandler.testMapToolChoice("required")).toBe("required") + expect(testHandler.testMapToolChoice("any")).toBe("required") + expect(testHandler.testMapToolChoice("unknown")).toBe("auto") + }) + + it("should handle object tool choice with function name", () => { + class TestMistralHandler extends MistralHandler { + public testMapToolChoice(toolChoice: any) { + return this.mapToolChoice(toolChoice) + } + } + + const testHandler = new TestMistralHandler(mockOptions) + + const result = testHandler.testMapToolChoice({ + type: "function", + function: { name: "my_tool" }, + }) + + expect(result).toEqual({ type: "tool", toolName: "my_tool" }) + }) + + it("should return undefined for null or undefined", () => { + class TestMistralHandler extends MistralHandler { + public testMapToolChoice(toolChoice: any) { + return this.mapToolChoice(toolChoice) + } + } + + const testHandler = new TestMistralHandler(mockOptions) + + expect(testHandler.testMapToolChoice(null)).toBeUndefined() + expect(testHandler.testMapToolChoice(undefined)).toBeUndefined() + }) + }) + + describe("Codestral URL handling", () => { + beforeEach(() => { + mockCreateMistral.mockClear() + }) + + it("should use default Codestral URL for codestral models", () => { + new MistralHandler({ + ...mockOptions, + apiModelId: "codestral-latest", + }) + + expect(mockCreateMistral).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "https://codestral.mistral.ai/v1", + }), + ) + }) + + it("should use custom Codestral URL when provided", () => { + new MistralHandler({ + ...mockOptions, + apiModelId: "codestral-latest", + mistralCodestralUrl: "https://custom.codestral.url/v1", + }) + + expect(mockCreateMistral).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "https://custom.codestral.url/v1", + }), + ) + }) + + it("should use default Mistral URL for non-codestral models", () => { + new MistralHandler({ + ...mockOptions, + apiModelId: "mistral-large-latest", + }) + + expect(mockCreateMistral).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "https://api.mistral.ai/v1", + }), + ) }) }) }) diff --git a/src/api/providers/__tests__/openai-codex.spec.ts b/src/api/providers/__tests__/openai-codex.spec.ts index f35d6e61ee..26a0e83c45 100644 --- a/src/api/providers/__tests__/openai-codex.spec.ts +++ b/src/api/providers/__tests__/openai-codex.spec.ts @@ -20,7 +20,7 @@ describe("OpenAiCodexHandler.getModel", () => { const handler = new OpenAiCodexHandler({ apiModelId: "not-a-real-model" }) const model = handler.getModel() - expect(model.id).toBe("gpt-5.2-codex") + expect(model.id).toBe("gpt-5.3-codex") expect(model.info).toBeDefined() }) }) diff --git a/src/api/providers/__tests__/openai-native-reasoning.spec.ts b/src/api/providers/__tests__/openai-native-reasoning.spec.ts new file mode 100644 index 0000000000..ebad23ee11 --- /dev/null +++ b/src/api/providers/__tests__/openai-native-reasoning.spec.ts @@ -0,0 +1,565 @@ +// npx vitest run api/providers/__tests__/openai-native-reasoning.spec.ts + +import type { Anthropic } from "@anthropic-ai/sdk" +import type { ModelMessage } from "ai" + +import { + stripPlainTextReasoningBlocks, + collectEncryptedReasoningItems, + injectEncryptedReasoning, + type EncryptedReasoningItem, +} from "../openai-native" + +describe("OpenAI Native reasoning helpers", () => { + // ─────────────────────────────────────────────────────────── + // stripPlainTextReasoningBlocks + // ─────────────────────────────────────────────────────────── + describe("stripPlainTextReasoningBlocks", () => { + it("passes through user messages unchanged", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + const result = stripPlainTextReasoningBlocks(messages) + expect(result).toEqual(messages) + }) + + it("passes through assistant messages with only text blocks", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "assistant", content: [{ type: "text", text: "Hi there" }] }, + ] + const result = stripPlainTextReasoningBlocks(messages) + expect(result).toEqual(messages) + }) + + it("passes through string-content assistant messages", () => { + const messages: Anthropic.Messages.MessageParam[] = [{ role: "assistant", content: "Hello" }] + const result = stripPlainTextReasoningBlocks(messages) + expect(result).toEqual(messages) + }) + + it("strips plain-text reasoning blocks from assistant content", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Let me think...", + } as unknown as Anthropic.Messages.ContentBlockParam, + { type: "text", text: "The answer is 42" }, + ], + }, + ] + const result = stripPlainTextReasoningBlocks(messages) + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([{ type: "text", text: "The answer is 42" }]) + }) + + it("removes assistant messages whose content becomes empty after filtering", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Thinking only...", + } as unknown as Anthropic.Messages.ContentBlockParam, + ], + }, + ] + const result = stripPlainTextReasoningBlocks(messages) + expect(result).toHaveLength(0) + }) + + it("preserves tool_use blocks alongside stripped reasoning", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Thinking..." } as unknown as Anthropic.Messages.ContentBlockParam, + { type: "tool_use", id: "call_1", name: "read_file", input: { path: "a.ts" } }, + ], + }, + ] + const result = stripPlainTextReasoningBlocks(messages) + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([ + { type: "tool_use", id: "call_1", name: "read_file", input: { path: "a.ts" } }, + ]) + }) + + it("does NOT strip blocks that have encrypted_content (those are not plain-text reasoning)", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "summary", + encrypted_content: "abc123", + } as unknown as Anthropic.Messages.ContentBlockParam, + { type: "text", text: "Response" }, + ], + }, + ] + const result = stripPlainTextReasoningBlocks(messages) + expect(result).toHaveLength(1) + // Both blocks should remain + expect(result[0].content).toHaveLength(2) + }) + + it("handles multiple messages correctly", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Q1" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "Think1" } as unknown as Anthropic.Messages.ContentBlockParam, + { type: "text", text: "A1" }, + ], + }, + { role: "user", content: [{ type: "text", text: "Q2" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "Think2" } as unknown as Anthropic.Messages.ContentBlockParam, + { type: "text", text: "A2" }, + ], + }, + ] + const result = stripPlainTextReasoningBlocks(messages) + expect(result).toHaveLength(4) + expect(result[1].content).toEqual([{ type: "text", text: "A1" }]) + expect(result[3].content).toEqual([{ type: "text", text: "A2" }]) + }) + }) + + // ─────────────────────────────────────────────────────────── + // collectEncryptedReasoningItems + // ─────────────────────────────────────────────────────────── + describe("collectEncryptedReasoningItems", () => { + it("returns empty array when no encrypted reasoning items exist", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { role: "assistant", content: [{ type: "text", text: "Hi" }] }, + ] + const result = collectEncryptedReasoningItems(messages) + expect(result).toEqual([]) + }) + + it("collects a single encrypted reasoning item", () => { + const messages = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { + type: "reasoning", + id: "rs_abc", + encrypted_content: "encrypted_data_1", + summary: [{ type: "summary_text", text: "I thought about it" }], + }, + { role: "assistant", content: [{ type: "text", text: "Hi" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const result = collectEncryptedReasoningItems(messages) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + id: "rs_abc", + encrypted_content: "encrypted_data_1", + summary: [{ type: "summary_text", text: "I thought about it" }], + originalIndex: 1, + }) + }) + + it("collects multiple encrypted reasoning items with correct indices", () => { + const messages = [ + { role: "user", content: [{ type: "text", text: "Q1" }] }, + { + type: "reasoning", + id: "rs_1", + encrypted_content: "enc_1", + summary: [{ type: "summary_text", text: "Summary 1" }], + }, + { role: "assistant", content: [{ type: "text", text: "A1" }] }, + { role: "user", content: [{ type: "text", text: "Q2" }] }, + { + type: "reasoning", + id: "rs_2", + encrypted_content: "enc_2", + summary: [{ type: "summary_text", text: "Summary 2" }], + }, + { role: "assistant", content: [{ type: "text", text: "A2" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const result = collectEncryptedReasoningItems(messages) + expect(result).toHaveLength(2) + expect(result[0].id).toBe("rs_1") + expect(result[0].originalIndex).toBe(1) + expect(result[1].id).toBe("rs_2") + expect(result[1].originalIndex).toBe(4) + }) + + it("ignores messages that have type 'reasoning' but no encrypted_content", () => { + const messages = [ + { type: "reasoning", id: "rs_x", text: "plain reasoning" }, + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const result = collectEncryptedReasoningItems(messages) + expect(result).toEqual([]) + }) + + it("handles items without summary", () => { + const messages = [ + { + type: "reasoning", + id: "rs_no_summary", + encrypted_content: "enc_data", + }, + { role: "assistant", content: [{ type: "text", text: "Hi" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const result = collectEncryptedReasoningItems(messages) + expect(result).toHaveLength(1) + expect(result[0].summary).toBeUndefined() + }) + }) + + // ─────────────────────────────────────────────────────────── + // injectEncryptedReasoning + // ─────────────────────────────────────────────────────────── + describe("injectEncryptedReasoning", () => { + it("does nothing when encryptedItems is empty", () => { + const aiSdkMessages: ModelMessage[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: [{ type: "text", text: "Hi" }] }, + ] + const original = JSON.parse(JSON.stringify(aiSdkMessages)) + injectEncryptedReasoning(aiSdkMessages, [], []) + expect(aiSdkMessages).toEqual(original) + }) + + it("injects a single encrypted reasoning part into the next assistant message", () => { + // Original messages: [user, encrypted_reasoning, assistant] + const originalMessages = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { + type: "reasoning", + id: "rs_abc", + encrypted_content: "enc_123", + summary: [{ type: "summary_text", text: "I considered the question" }], + }, + { role: "assistant", content: [{ type: "text", text: "Hi there" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + // AI SDK messages (after filtering encrypted items + converting) + const aiSdkMessages: ModelMessage[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: [{ type: "text", text: "Hi there" }] }, + ] + + const encryptedItems: EncryptedReasoningItem[] = [ + { + id: "rs_abc", + encrypted_content: "enc_123", + summary: [{ type: "summary_text", text: "I considered the question" }], + originalIndex: 1, + }, + ] + + injectEncryptedReasoning(aiSdkMessages, encryptedItems, originalMessages) + + const assistantMsg = aiSdkMessages[1] as Record + const content = assistantMsg.content as unknown[] + expect(content).toHaveLength(2) + + // First part should be the injected reasoning + const reasoningPart = content[0] as Record + expect(reasoningPart.type).toBe("reasoning") + expect(reasoningPart.text).toBe("I considered the question") + + const providerOptions = reasoningPart.providerOptions as Record> + expect(providerOptions.openai.itemId).toBe("rs_abc") + expect(providerOptions.openai.reasoningEncryptedContent).toBe("enc_123") + + // Second part should be the original text + const textPart = content[1] as Record + expect(textPart.type).toBe("text") + expect(textPart.text).toBe("Hi there") + }) + + it("handles multiple encrypted reasoning items across different assistant messages", () => { + const originalMessages = [ + { role: "user", content: [{ type: "text", text: "Q1" }] }, + { + type: "reasoning", + id: "rs_1", + encrypted_content: "enc_1", + summary: [{ type: "summary_text", text: "Thought 1" }], + }, + { role: "assistant", content: [{ type: "text", text: "A1" }] }, + { role: "user", content: [{ type: "text", text: "Q2" }] }, + { + type: "reasoning", + id: "rs_2", + encrypted_content: "enc_2", + summary: [{ type: "summary_text", text: "Thought 2" }], + }, + { role: "assistant", content: [{ type: "text", text: "A2" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const aiSdkMessages: ModelMessage[] = [ + { role: "user", content: "Q1" }, + { role: "assistant", content: [{ type: "text", text: "A1" }] }, + { role: "user", content: "Q2" }, + { role: "assistant", content: [{ type: "text", text: "A2" }] }, + ] + + const encryptedItems: EncryptedReasoningItem[] = [ + { + id: "rs_1", + encrypted_content: "enc_1", + summary: [{ type: "summary_text", text: "Thought 1" }], + originalIndex: 1, + }, + { + id: "rs_2", + encrypted_content: "enc_2", + summary: [{ type: "summary_text", text: "Thought 2" }], + originalIndex: 4, + }, + ] + + injectEncryptedReasoning(aiSdkMessages, encryptedItems, originalMessages) + + // First assistant message + const content1 = (aiSdkMessages[1] as Record).content as unknown[] + expect(content1).toHaveLength(2) + expect((content1[0] as Record).type).toBe("reasoning") + expect( + ((content1[0] as Record).providerOptions as Record>) + .openai.itemId, + ).toBe("rs_1") + + // Second assistant message + const content2 = (aiSdkMessages[3] as Record).content as unknown[] + expect(content2).toHaveLength(2) + expect((content2[0] as Record).type).toBe("reasoning") + expect( + ((content2[0] as Record).providerOptions as Record>) + .openai.itemId, + ).toBe("rs_2") + }) + + it("joins multiple summary texts with newlines", () => { + const originalMessages = [ + { role: "user", content: [{ type: "text", text: "Hi" }] }, + { + type: "reasoning", + id: "rs_multi", + encrypted_content: "enc_multi", + summary: [ + { type: "summary_text", text: "First thought" }, + { type: "summary_text", text: "Second thought" }, + ], + }, + { role: "assistant", content: [{ type: "text", text: "Response" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const aiSdkMessages: ModelMessage[] = [ + { role: "user", content: "Hi" }, + { role: "assistant", content: [{ type: "text", text: "Response" }] }, + ] + + const encryptedItems: EncryptedReasoningItem[] = [ + { + id: "rs_multi", + encrypted_content: "enc_multi", + summary: [ + { type: "summary_text", text: "First thought" }, + { type: "summary_text", text: "Second thought" }, + ], + originalIndex: 1, + }, + ] + + injectEncryptedReasoning(aiSdkMessages, encryptedItems, originalMessages) + + const content = (aiSdkMessages[1] as Record).content as unknown[] + const reasoningPart = content[0] as Record + expect(reasoningPart.text).toBe("First thought\nSecond thought") + }) + + it("uses empty string when summary is undefined", () => { + const originalMessages = [ + { role: "user", content: [{ type: "text", text: "Hi" }] }, + { + type: "reasoning", + id: "rs_nosummary", + encrypted_content: "enc_nosummary", + }, + { role: "assistant", content: [{ type: "text", text: "Response" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const aiSdkMessages: ModelMessage[] = [ + { role: "user", content: "Hi" }, + { role: "assistant", content: [{ type: "text", text: "Response" }] }, + ] + + const encryptedItems: EncryptedReasoningItem[] = [ + { + id: "rs_nosummary", + encrypted_content: "enc_nosummary", + summary: undefined, + originalIndex: 1, + }, + ] + + injectEncryptedReasoning(aiSdkMessages, encryptedItems, originalMessages) + + const content = (aiSdkMessages[1] as Record).content as unknown[] + const reasoningPart = content[0] as Record + expect(reasoningPart.text).toBe("") + }) + + it("handles consecutive encrypted items before the same assistant message", () => { + // Two encrypted reasoning items before one assistant message + const originalMessages = [ + { role: "user", content: [{ type: "text", text: "Hi" }] }, + { + type: "reasoning", + id: "rs_a", + encrypted_content: "enc_a", + summary: [{ type: "summary_text", text: "Step A" }], + }, + { + type: "reasoning", + id: "rs_b", + encrypted_content: "enc_b", + summary: [{ type: "summary_text", text: "Step B" }], + }, + { role: "assistant", content: [{ type: "text", text: "Done" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const aiSdkMessages: ModelMessage[] = [ + { role: "user", content: "Hi" }, + { role: "assistant", content: [{ type: "text", text: "Done" }] }, + ] + + const encryptedItems: EncryptedReasoningItem[] = [ + { + id: "rs_a", + encrypted_content: "enc_a", + summary: [{ type: "summary_text", text: "Step A" }], + originalIndex: 1, + }, + { + id: "rs_b", + encrypted_content: "enc_b", + summary: [{ type: "summary_text", text: "Step B" }], + originalIndex: 2, + }, + ] + + injectEncryptedReasoning(aiSdkMessages, encryptedItems, originalMessages) + + const content = (aiSdkMessages[1] as Record).content as unknown[] + // Both reasoning parts should be injected before the text + expect(content).toHaveLength(3) + expect((content[0] as Record).type).toBe("reasoning") + expect( + ((content[0] as Record).providerOptions as Record>) + .openai.itemId, + ).toBe("rs_a") + expect((content[1] as Record).type).toBe("reasoning") + expect( + ((content[1] as Record).providerOptions as Record>) + .openai.itemId, + ).toBe("rs_b") + expect((content[2] as Record).type).toBe("text") + }) + + it("handles tool messages splitting (user messages with tool_results create extra tool-role messages)", () => { + // Original: [user_with_tool_result, encrypted_reasoning, assistant] + // After filtering: [user_with_tool_result, assistant] + // AI SDK: [tool, user, assistant] (tool_result split into tool + user messages) + const originalMessages = [ + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "call_1", content: "result" }, + { type: "text", text: "Continue" }, + ], + }, + { + type: "reasoning", + id: "rs_tool", + encrypted_content: "enc_tool", + summary: [{ type: "summary_text", text: "Thought after tool" }], + }, + { role: "assistant", content: [{ type: "text", text: "OK" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + // AI SDK messages after conversion (tool_result splits into tool + user) + const aiSdkMessages: ModelMessage[] = [ + { + role: "tool", + content: [ + { type: "tool-result", toolCallId: "call_1", toolName: "unknown_tool", result: "result" }, + ], + } as unknown as ModelMessage, + { role: "user", content: [{ type: "text", text: "Continue" }] }, + { role: "assistant", content: [{ type: "text", text: "OK" }] }, + ] + + const encryptedItems: EncryptedReasoningItem[] = [ + { + id: "rs_tool", + encrypted_content: "enc_tool", + summary: [{ type: "summary_text", text: "Thought after tool" }], + originalIndex: 1, + }, + ] + + injectEncryptedReasoning(aiSdkMessages, encryptedItems, originalMessages) + + // The assistant message (index 2) should have the reasoning injected + const content = (aiSdkMessages[2] as Record).content as unknown[] + expect(content).toHaveLength(2) + expect((content[0] as Record).type).toBe("reasoning") + expect( + ((content[0] as Record).providerOptions as Record>) + .openai.itemId, + ).toBe("rs_tool") + }) + + it("gracefully handles encrypted items with no following assistant message", () => { + const originalMessages = [ + { role: "user", content: [{ type: "text", text: "Hi" }] }, + { + type: "reasoning", + id: "rs_orphan", + encrypted_content: "enc_orphan", + }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const aiSdkMessages: ModelMessage[] = [{ role: "user", content: "Hi" }] + + const encryptedItems: EncryptedReasoningItem[] = [ + { + id: "rs_orphan", + encrypted_content: "enc_orphan", + summary: undefined, + originalIndex: 1, + }, + ] + + // Should not throw + expect(() => { + injectEncryptedReasoning(aiSdkMessages, encryptedItems, originalMessages) + }).not.toThrow() + + // User message unchanged + expect(aiSdkMessages).toHaveLength(1) + expect(aiSdkMessages[0].role).toBe("user") + }) + }) +}) diff --git a/src/api/providers/__tests__/openai-native-tools.spec.ts b/src/api/providers/__tests__/openai-native-tools.spec.ts index e0746f792e..d873b7457b 100644 --- a/src/api/providers/__tests__/openai-native-tools.spec.ts +++ b/src/api/providers/__tests__/openai-native-tools.spec.ts @@ -1,8 +1,8 @@ +// npx vitest run api/providers/__tests__/openai-native-tools.spec.ts + import OpenAI from "openai" import { OpenAiHandler } from "../openai" -import { OpenAiNativeHandler } from "../openai-native" -import type { ApiHandlerOptions } from "../../../shared/api" describe("OpenAiHandler native tools", () => { it("includes tools in request when tools are provided via metadata (regression test)", async () => { @@ -68,35 +68,103 @@ describe("OpenAiHandler native tools", () => { }) }) -describe("OpenAiNativeHandler MCP tool schema handling", () => { - it("should add additionalProperties: false to MCP tools while keeping strict: false", async () => { - let capturedRequestBody: any +// Use vi.hoisted to define mock functions for AI SDK +const { mockStreamText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: vi.fn(), + } +}) + +vi.mock("@ai-sdk/openai", () => ({ + createOpenAI: vi.fn(() => { + const provider = vi.fn(() => ({ + modelId: "gpt-4o", + provider: "openai", + })) + ;(provider as any).responses = vi.fn(() => ({ + modelId: "gpt-4o", + provider: "openai.responses", + })) + return provider + }), +})) + +import { OpenAiNativeHandler } from "../openai-native" +import type { ApiHandlerOptions } from "../../../shared/api" + +describe("OpenAiNativeHandler tool handling with AI SDK", () => { + function createMockStreamReturn() { + async function* mockFullStream() { + yield { type: "text-delta", text: "test" } + } + + return { + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + } + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should pass tools through convertToolsForOpenAI and convertToolsForAiSdk to streamText", async () => { + mockStreamText.mockReturnValue(createMockStreamReturn()) const handler = new OpenAiNativeHandler({ openAiNativeApiKey: "test-key", apiModelId: "gpt-4o", } as ApiHandlerOptions) - // Mock the responses API call - const mockClient = { - responses: { - create: vi.fn().mockImplementation((body: any) => { - capturedRequestBody = body - return { - [Symbol.asyncIterator]: async function* () { - yield { - type: "response.done", - response: { - output: [{ type: "message", content: [{ type: "output_text", text: "test" }] }], - usage: { input_tokens: 10, output_tokens: 5 }, - }, - } + const tools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file from the filesystem", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path" }, }, - } - }), + }, + }, }, + ] + + const stream = handler.createMessage("system prompt", [], { + taskId: "test-task-id", + tools, + }) + for await (const _ of stream) { + // consume } - ;(handler as any).client = mockClient + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ + read_file: expect.anything(), + }), + }), + ) + }) + + it("should pass MCP tools to streamText", async () => { + mockStreamText.mockReturnValue(createMockStreamReturn()) + + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4o", + } as ApiHandlerOptions) const mcpTools: OpenAI.Chat.ChatCompletionTool[] = [ { @@ -119,120 +187,36 @@ describe("OpenAiNativeHandler MCP tool schema handling", () => { taskId: "test-task-id", tools: mcpTools, }) - - // Consume the stream for await (const _ of stream) { - // Just consume + // consume } - // Verify the request body - expect(capturedRequestBody.tools).toBeDefined() - expect(capturedRequestBody.tools.length).toBe(1) - - const tool = capturedRequestBody.tools[0] - expect(tool.name).toBe("mcp--github--get_me") - expect(tool.strict).toBe(false) // MCP tools should have strict: false - expect(tool.parameters.additionalProperties).toBe(false) // Should have additionalProperties: false - expect(tool.parameters.required).toEqual(["token"]) // Should preserve original required array + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ + "mcp--github--get_me": expect.anything(), + }), + }), + ) }) - it("should add additionalProperties: false and required array to non-MCP tools with strict: true", async () => { - let capturedRequestBody: any + it("should pass both regular and MCP tools together", async () => { + mockStreamText.mockReturnValue(createMockStreamReturn()) const handler = new OpenAiNativeHandler({ openAiNativeApiKey: "test-key", apiModelId: "gpt-4o", } as ApiHandlerOptions) - // Mock the responses API call - const mockClient = { - responses: { - create: vi.fn().mockImplementation((body: any) => { - capturedRequestBody = body - return { - [Symbol.asyncIterator]: async function* () { - yield { - type: "response.done", - response: { - output: [{ type: "message", content: [{ type: "output_text", text: "test" }] }], - usage: { input_tokens: 10, output_tokens: 5 }, - }, - } - }, - } - }), - }, - } - ;(handler as any).client = mockClient - - const regularTools: OpenAI.Chat.ChatCompletionTool[] = [ + const mixedTools: OpenAI.Chat.ChatCompletionTool[] = [ { type: "function", function: { name: "read_file", - description: "Read a file from the filesystem", - parameters: { - type: "object", - properties: { - path: { type: "string", description: "File path" }, - encoding: { type: "string", description: "File encoding" }, - }, - }, + description: "Read a file", + parameters: { type: "object", properties: { path: { type: "string" } } }, }, }, - ] - - const stream = handler.createMessage("system prompt", [], { - taskId: "test-task-id", - tools: regularTools, - }) - - // Consume the stream - for await (const _ of stream) { - // Just consume - } - - // Verify the request body - expect(capturedRequestBody.tools).toBeDefined() - expect(capturedRequestBody.tools.length).toBe(1) - - const tool = capturedRequestBody.tools[0] - expect(tool.name).toBe("read_file") - expect(tool.strict).toBe(true) // Non-MCP tools should have strict: true - expect(tool.parameters.additionalProperties).toBe(false) // Should have additionalProperties: false - expect(tool.parameters.required).toEqual(["path", "encoding"]) // Should have all properties as required - }) - - it("should recursively add additionalProperties: false to nested objects in MCP tools", async () => { - let capturedRequestBody: any - - const handler = new OpenAiNativeHandler({ - openAiNativeApiKey: "test-key", - apiModelId: "gpt-4o", - } as ApiHandlerOptions) - - // Mock the responses API call - const mockClient = { - responses: { - create: vi.fn().mockImplementation((body: any) => { - capturedRequestBody = body - return { - [Symbol.asyncIterator]: async function* () { - yield { - type: "response.done", - response: { - output: [{ type: "message", content: [{ type: "output_text", text: "test" }] }], - usage: { input_tokens: 10, output_tokens: 5 }, - }, - } - }, - } - }), - }, - } - ;(handler as any).client = mockClient - - const mcpToolsWithNestedObjects: OpenAI.Chat.ChatCompletionTool[] = [ { type: "function", function: { @@ -240,24 +224,8 @@ describe("OpenAiNativeHandler MCP tool schema handling", () => { description: "Create a Linear issue", parameters: { type: "object", - properties: { - title: { type: "string" }, - metadata: { - type: "object", - properties: { - priority: { type: "number" }, - labels: { - type: "array", - items: { - type: "object", - properties: { - name: { type: "string" }, - }, - }, - }, - }, - }, - }, + properties: { title: { type: "string" } }, + required: ["title"], }, }, }, @@ -265,72 +233,64 @@ describe("OpenAiNativeHandler MCP tool schema handling", () => { const stream = handler.createMessage("system prompt", [], { taskId: "test-task-id", - tools: mcpToolsWithNestedObjects, + tools: mixedTools, }) - - // Consume the stream for await (const _ of stream) { - // Just consume + // consume } - // Verify the request body - const tool = capturedRequestBody.tools[0] - expect(tool.strict).toBe(false) // MCP tool should have strict: false - expect(tool.parameters.additionalProperties).toBe(false) // Root level - expect(tool.parameters.properties.metadata.additionalProperties).toBe(false) // Nested object - expect(tool.parameters.properties.metadata.properties.labels.items.additionalProperties).toBe(false) // Array items + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.tools).toBeDefined() + expect(callArgs.tools.read_file).toBeDefined() + expect(callArgs.tools["mcp--linear--create_issue"]).toBeDefined() }) - it("should handle missing call_id and name in tool_call_arguments.delta by using pending tool identity", async () => { + it("should pass parallelToolCalls in provider options", async () => { + mockStreamText.mockReturnValue(createMockStreamReturn()) + const handler = new OpenAiNativeHandler({ openAiNativeApiKey: "test-key", apiModelId: "gpt-4o", } as ApiHandlerOptions) - const mockClient = { - responses: { - create: vi.fn().mockImplementation(() => { - return { - [Symbol.asyncIterator]: async function* () { - // 1. Emit output_item.added with tool identity - yield { - type: "response.output_item.added", - item: { - type: "function_call", - call_id: "call_123", - name: "read_file", - arguments: "", - }, - } - - // 2. Emit tool_call_arguments.delta WITHOUT identity (just args) - yield { - type: "response.function_call_arguments.delta", - delta: '{"path":', - } - - // 3. Emit another delta - yield { - type: "response.function_call_arguments.delta", - delta: '"/tmp/test.txt"}', - } - - // 4. Emit output_item.done - yield { - type: "response.output_item.done", - item: { - type: "function_call", - call_id: "call_123", - name: "read_file", - arguments: '{"path":"/tmp/test.txt"}', - }, - } - }, - } - }), - }, + const stream = handler.createMessage("system prompt", [], { + taskId: "test-task-id", + parallelToolCalls: false, + }) + for await (const _ of stream) { + // consume } - ;(handler as any).client = mockClient + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: expect.objectContaining({ + openai: expect.objectContaining({ + parallelToolCalls: false, + }), + }), + }), + ) + }) + + it("should handle tool call streaming events", async () => { + async function* mockFullStream() { + yield { type: "tool-input-start", id: "call_abc", toolName: "read_file" } + yield { type: "tool-input-delta", id: "call_abc", delta: '{"path":' } + yield { type: "tool-input-delta", id: "call_abc", delta: '"/tmp/test.txt"}' } + yield { type: "tool-input-end", id: "call_abc" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4o", + } as ApiHandlerOptions) const stream = handler.createMessage("system prompt", [], { taskId: "test-task-id", @@ -338,25 +298,19 @@ describe("OpenAiNativeHandler MCP tool schema handling", () => { const chunks: any[] = [] for await (const chunk of stream) { - if (chunk.type === "tool_call_partial") { - chunks.push(chunk) - } + chunks.push(chunk) } - expect(chunks.length).toBe(2) - expect(chunks[0]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "call_123", // Should be filled from pendingToolCallId - name: "read_file", // Should be filled from pendingToolCallName - arguments: '{"path":', - }) - expect(chunks[1]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "call_123", - name: "read_file", - arguments: '"/tmp/test.txt"}', - }) + const toolStart = chunks.filter((c) => c.type === "tool_call_start") + expect(toolStart).toHaveLength(1) + expect(toolStart[0].id).toBe("call_abc") + expect(toolStart[0].name).toBe("read_file") + + const toolDeltas = chunks.filter((c) => c.type === "tool_call_delta") + expect(toolDeltas).toHaveLength(2) + + const toolEnd = chunks.filter((c) => c.type === "tool_call_end") + expect(toolEnd).toHaveLength(1) + expect(toolEnd[0].id).toBe("call_abc") }) }) diff --git a/src/api/providers/__tests__/openai-native-usage.spec.ts b/src/api/providers/__tests__/openai-native-usage.spec.ts index 48e1c26877..5742d7282b 100644 --- a/src/api/providers/__tests__/openai-native-usage.spec.ts +++ b/src/api/providers/__tests__/openai-native-usage.spec.ts @@ -1,422 +1,355 @@ -import { describe, it, expect, beforeEach } from "vitest" -import { OpenAiNativeHandler } from "../openai-native" +// npx vitest run api/providers/__tests__/openai-native-usage.spec.ts + +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) + +vi.mock("@ai-sdk/openai", () => ({ + createOpenAI: vi.fn(() => { + const provider = vi.fn(() => ({ + modelId: "gpt-4.1", + provider: "openai", + })) + ;(provider as any).responses = vi.fn(() => ({ + modelId: "gpt-4.1", + provider: "openai.responses", + })) + return provider + }), +})) + +import type { Anthropic } from "@anthropic-ai/sdk" + import { openAiNativeModels } from "@roo-code/types" -describe("OpenAiNativeHandler - normalizeUsage", () => { +import { OpenAiNativeHandler } from "../openai-native" +import type { ApiHandlerOptions } from "../../../shared/api" + +describe("OpenAiNativeHandler - usage metrics", () => { let handler: OpenAiNativeHandler - const mockModel = { - id: "gpt-4o", - info: openAiNativeModels["gpt-4o"], - } + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello!" }] beforeEach(() => { handler = new OpenAiNativeHandler({ openAiNativeApiKey: "test-key", + apiModelId: "gpt-4.1", + }) + vi.clearAllMocks() + }) + + describe("basic token counts", () => { + it("should handle basic input and output tokens", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].inputTokens).toBe(100) + expect(usageChunks[0].outputTokens).toBe(50) + }) + + it("should handle zero tokens", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].inputTokens).toBe(0) + expect(usageChunks[0].outputTokens).toBe(0) }) }) - describe("detailed token shapes (Responses API)", () => { - it("should handle detailed shapes with cached and miss tokens", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - input_tokens_details: { - cached_tokens: 30, - cache_miss_tokens: 70, - }, + describe("cache metrics", () => { + it("should handle cached input tokens from usage details", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } } - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 30, - cacheWriteTokens: 0, // miss tokens are NOT cache writes + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + details: { + cachedInputTokens: 30, + }, + }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].cacheReadTokens).toBe(30) }) - it("should derive total input tokens from details when totals are missing", () => { - const usage = { - // No input_tokens or prompt_tokens - output_tokens: 50, - input_tokens_details: { - cached_tokens: 30, - cache_miss_tokens: 70, - }, + it("should handle no cache information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } } - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, // Derived from 30 + 70 - outputTokens: 50, - cacheReadTokens: 30, - cacheWriteTokens: 0, // miss tokens are NOT cache writes + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 50, outputTokens: 25 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) - }) - it("should handle prompt_tokens_details variant", () => { - const usage = { - prompt_tokens: 100, - completion_tokens: 50, - prompt_tokens_details: { - cached_tokens: 30, - cache_miss_tokens: 70, - }, + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 30, - cacheWriteTokens: 0, // miss tokens are NOT cache writes - }) - }) - - it("should handle cache_creation_input_tokens for actual cache writes", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - cache_creation_input_tokens: 20, - input_tokens_details: { - cached_tokens: 30, - cache_miss_tokens: 50, // 50 miss + 30 cached + 20 creation = 100 total - }, - } - - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 30, - cacheWriteTokens: 20, // Actual cache writes from cache_creation_input_tokens - }) - }) - - it("should handle reasoning tokens in output details", () => { - const usage = { - input_tokens: 100, - output_tokens: 150, - output_tokens_details: { - reasoning_tokens: 50, - }, - } - - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 150, - reasoningTokens: 50, - }) + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].cacheReadTokens).toBeUndefined() + expect(usageChunks[0].cacheWriteTokens).toBeUndefined() }) }) - describe("legacy field names", () => { - it("should handle cache_creation_input_tokens and cache_read_input_tokens", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - cache_creation_input_tokens: 20, - cache_read_input_tokens: 30, + describe("reasoning tokens", () => { + it("should handle reasoning tokens in usage details", async () => { + async function* mockFullStream() { + yield { type: "reasoning-delta", text: "thinking..." } + yield { type: "text-delta", text: "answer" } } - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 30, - cacheWriteTokens: 20, + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + details: { + reasoningTokens: 30, + }, + }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) - }) - it("should handle cache_write_tokens and cache_read_tokens", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - cache_write_tokens: 20, - cache_read_tokens: 30, + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 30, - cacheWriteTokens: 20, - }) + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].reasoningTokens).toBe(30) }) - it("should handle cached_tokens field", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - cached_tokens: 30, + it("should omit reasoning tokens when not present", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "answer" } } - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 30, + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) - }) - it("should handle prompt_tokens and completion_tokens", () => { - const usage = { - prompt_tokens: 100, - completion_tokens: 50, + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }) - }) - }) - - describe("SSE-only events", () => { - it("should handle SSE events with minimal usage data", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - } - - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }) - }) - - it("should handle SSE events with no cache information", () => { - const usage = { - prompt_tokens: 100, - completion_tokens: 50, - } - - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }) - }) - }) - - describe("edge cases", () => { - it("should handle undefined usage", () => { - const result = (handler as any).normalizeUsage(undefined, mockModel) - expect(result).toBeUndefined() - }) - - it("should handle null usage", () => { - const result = (handler as any).normalizeUsage(null, mockModel) - expect(result).toBeUndefined() - }) - - it("should handle empty usage object", () => { - const usage = {} - - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }) - }) - - it("should handle missing details but with cache fields", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - cache_read_input_tokens: 30, - // No input_tokens_details - } - - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 30, - cacheWriteTokens: 0, - }) - }) - - it("should use all available cache information with proper fallbacks", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - cached_tokens: 20, // Legacy field (will be used as fallback) - input_tokens_details: { - cached_tokens: 30, // Detailed shape - cache_miss_tokens: 70, - }, - } - - const result = (handler as any).normalizeUsage(usage, mockModel) - - // The implementation uses nullish coalescing, so it will use the first non-nullish value: - // cache_read_input_tokens ?? cache_read_tokens ?? cached_tokens ?? cachedFromDetails - // Since none of the first two exist, it falls back to cached_tokens (20) before cachedFromDetails - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 20, // From cached_tokens (legacy field comes before details in fallback chain) - cacheWriteTokens: 0, // miss tokens are NOT cache writes - }) - }) - - it("should use detailed shapes when legacy fields are not present", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - // No cached_tokens legacy field - input_tokens_details: { - cached_tokens: 30, - cache_miss_tokens: 70, - }, - } - - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 30, // From details since no legacy field exists - cacheWriteTokens: 0, // miss tokens are NOT cache writes - }) - }) - - it("should handle totals missing with only partial details", () => { - const usage = { - // No input_tokens or prompt_tokens - output_tokens: 50, - input_tokens_details: { - cached_tokens: 30, - // No cache_miss_tokens - }, - } - - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 30, // Derived from cached_tokens only - outputTokens: 50, - cacheReadTokens: 30, - cacheWriteTokens: 0, - }) - }) - }) - - describe("OpenAiNativeHandler - prompt cache retention", () => { - let handler: OpenAiNativeHandler - - beforeEach(() => { - handler = new OpenAiNativeHandler({ - openAiNativeApiKey: "test-key", - }) - }) - - const buildRequestBodyForModel = (modelId: string) => { - // Force the handler to use the requested model ID - ;(handler as any).options.apiModelId = modelId - const model = handler.getModel() - // Minimal formatted input/systemPrompt/verbosity/metadata for building the body - return (handler as any).buildRequestBody(model, [], "", model.verbosity, undefined, undefined) - } - - it("should set prompt_cache_retention=24h for gpt-5.1 models that support prompt caching", () => { - const body = buildRequestBodyForModel("gpt-5.1") - expect(body.prompt_cache_retention).toBe("24h") - - const codexBody = buildRequestBodyForModel("gpt-5.1-codex") - expect(codexBody.prompt_cache_retention).toBe("24h") - - const codexMiniBody = buildRequestBodyForModel("gpt-5.1-codex-mini") - expect(codexMiniBody.prompt_cache_retention).toBe("24h") - }) - - it("should not set prompt_cache_retention for non-gpt-5.1 models even if they support prompt caching", () => { - const body = buildRequestBodyForModel("gpt-5") - expect(body.prompt_cache_retention).toBeUndefined() - - const fourOBody = buildRequestBodyForModel("gpt-4o") - expect(fourOBody.prompt_cache_retention).toBeUndefined() - }) - - it("should not set prompt_cache_retention when the model does not support prompt caching", () => { - const modelId = "codex-mini-latest" - expect(openAiNativeModels[modelId as keyof typeof openAiNativeModels].supportsPromptCache).toBe(false) - - const body = buildRequestBodyForModel(modelId) - expect(body.prompt_cache_retention).toBeUndefined() + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].reasoningTokens).toBeUndefined() }) }) describe("cost calculation", () => { - it("should pass total input tokens to calculateApiCostOpenAI", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - cache_read_input_tokens: 30, - cache_creation_input_tokens: 20, + it("should include totalCost in usage metrics", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } } - const result = (handler as any).normalizeUsage(usage, mockModel) + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 1000, + outputTokens: 500, + }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) - expect(result).toHaveProperty("totalCost") - expect(result.totalCost).toBeGreaterThan(0) - // calculateApiCostOpenAI handles subtracting cache tokens internally - // It will compute: 100 - 30 - 20 = 50 uncached input tokens + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(typeof usageChunks[0].totalCost).toBe("number") + expect(usageChunks[0].totalCost).toBeGreaterThanOrEqual(0) }) - it("should handle cost calculation with no cache reads", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, + it("should handle all details together", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } } - const result = (handler as any).normalizeUsage(usage, mockModel) + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 200, + outputTokens: 100, + details: { + cachedInputTokens: 50, + reasoningTokens: 25, + }, + }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) - expect(result).toHaveProperty("totalCost") - expect(result.totalCost).toBeGreaterThan(0) - // Cost should be calculated with full input tokens since no cache reads + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].inputTokens).toBe(200) + expect(usageChunks[0].outputTokens).toBe(100) + expect(usageChunks[0].cacheReadTokens).toBe(50) + expect(usageChunks[0].reasoningTokens).toBe(25) + expect(typeof usageChunks[0].totalCost).toBe("number") + }) + }) + + describe("prompt cache retention", () => { + it("should set promptCacheRetention=24h for gpt-5.1 models that support prompt caching", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const h = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-5.1", + }) + + const stream = h.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + const modelInfo = openAiNativeModels["gpt-5.1"] + if (modelInfo.supportsPromptCache && modelInfo.promptCacheRetention === "24h") { + expect(callArgs.providerOptions.openai.promptCacheRetention).toBe("24h") + } + }) + + it("should not set promptCacheRetention for non-gpt-5.1 models", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions.openai.promptCacheRetention).toBeUndefined() + }) + + it("should not set promptCacheRetention when the model does not support prompt caching", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + // o3-mini doesn't support prompt caching + const h = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "o3-mini-high", + }) + + const stream = h.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions.openai.promptCacheRetention).toBeUndefined() }) }) }) diff --git a/src/api/providers/__tests__/openai-native.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts index 86bb0e9721..e7981520c3 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/src/api/providers/__tests__/openai-native.spec.ts @@ -1,36 +1,42 @@ // npx vitest run api/providers/__tests__/openai-native.spec.ts -const mockCaptureException = vitest.fn() - -vitest.mock("@roo-code/telemetry", () => ({ - TelemetryService: { - instance: { - captureException: (...args: unknown[]) => mockCaptureException(...args), - }, - }, +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), })) -import { Anthropic } from "@anthropic-ai/sdk" - -import { ApiProviderError } from "@roo-code/types" - -import { OpenAiNativeHandler } from "../openai-native" -import { ApiHandlerOptions } from "../../../shared/api" - -// Mock OpenAI client - now everything uses Responses API -const mockResponsesCreate = vitest.fn() - -vitest.mock("openai", () => { +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - __esModule: true, - default: vitest.fn().mockImplementation(() => ({ - responses: { - create: mockResponsesCreate, - }, - })), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) +vi.mock("@ai-sdk/openai", () => ({ + createOpenAI: vi.fn(() => { + const provider = vi.fn(() => ({ + modelId: "gpt-4.1", + provider: "openai", + })) + // Add .responses() method that returns the same mock model + ;(provider as any).responses = vi.fn(() => ({ + modelId: "gpt-4.1", + provider: "openai.responses", + })) + return provider + }), +})) + +import type { Anthropic } from "@anthropic-ai/sdk" + +import { openAiNativeDefaultModelId, openAiNativeModels } from "@roo-code/types" + +import { OpenAiNativeHandler } from "../openai-native" +import type { ApiHandlerOptions } from "../../../shared/api" + describe("OpenAiNativeHandler", () => { let handler: OpenAiNativeHandler let mockOptions: ApiHandlerOptions @@ -38,7 +44,12 @@ describe("OpenAiNativeHandler", () => { const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", - content: "Hello!", + content: [ + { + type: "text" as const, + text: "Hello!", + }, + ], }, ] @@ -48,19 +59,7 @@ describe("OpenAiNativeHandler", () => { openAiNativeApiKey: "test-api-key", } handler = new OpenAiNativeHandler(mockOptions) - mockResponsesCreate.mockClear() - mockCaptureException.mockClear() - // Clear fetch mock if it exists - if ((global as any).fetch) { - delete (global as any).fetch - } - }) - - afterEach(() => { - // Clean up fetch mock - if ((global as any).fetch) { - delete (global as any).fetch - } + vi.clearAllMocks() }) describe("constructor", () => { @@ -76,194 +75,83 @@ describe("OpenAiNativeHandler", () => { }) expect(handlerWithoutKey).toBeInstanceOf(OpenAiNativeHandler) }) - }) - describe("createMessage", () => { - it("should handle streaming responses via Responses API", async () => { - // Mock fetch for Responses API fallback - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.text.delta","delta":"Test"}\n\n'), - ) - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.text.delta","delta":" response"}\n\n'), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.done","response":{"usage":{"prompt_tokens":10,"completion_tokens":2}}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail so it falls back to fetch - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) + it("should default enableResponsesReasoningSummary to true", () => { + const opts: ApiHandlerOptions = { + apiModelId: "gpt-4.1", + openAiNativeApiKey: "test-key", } - - expect(chunks.length).toBeGreaterThan(0) - const textChunks = chunks.filter((chunk) => chunk.type === "text") - expect(textChunks).toHaveLength(2) - expect(textChunks[0].text).toBe("Test") - expect(textChunks[1].text).toBe(" response") + const h = new OpenAiNativeHandler(opts) + expect(h).toBeInstanceOf(OpenAiNativeHandler) + // enableResponsesReasoningSummary should have been set to true in constructor + expect(opts.enableResponsesReasoningSummary).toBe(true) }) - it("should handle API errors", async () => { - // Mock fetch to return error - const mockFetch = vitest.fn().mockResolvedValue({ - ok: false, - status: 500, - text: async () => "Internal Server Error", - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - const stream = handler.createMessage(systemPrompt, messages) - await expect(async () => { - for await (const _chunk of stream) { - // Should not reach here - } - }).rejects.toThrow("OpenAI service error") - }) - }) - - describe("completePrompt", () => { - it("should handle non-streaming completion using Responses API", async () => { - // Mock the responses.create method to return a non-streaming response - mockResponsesCreate.mockResolvedValue({ - output: [ - { - type: "message", - content: [ - { - type: "output_text", - text: "This is the completion response", - }, - ], - }, - ], - }) - - const result = await handler.completePrompt("Test prompt") - - expect(result).toBe("This is the completion response") - expect(mockResponsesCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: "gpt-4.1", - stream: false, - store: false, - input: [ - { - role: "user", - content: [{ type: "input_text", text: "Test prompt" }], - }, - ], - }), - expect.objectContaining({ - signal: expect.any(Object), - }), - ) - }) - - it("should handle SDK errors in completePrompt", async () => { - // Mock SDK to throw an error - mockResponsesCreate.mockRejectedValue(new Error("API Error")) - - await expect(handler.completePrompt("Test prompt")).rejects.toThrow( - "OpenAI Native completion error: API Error", - ) - }) - - it("should return empty string when no text in response", async () => { - // Mock the responses.create method to return a response without text - mockResponsesCreate.mockResolvedValue({ - output: [ - { - type: "message", - content: [], - }, - ], - }) - - const result = await handler.completePrompt("Test prompt") - - expect(result).toBe("") + it("should preserve explicit enableResponsesReasoningSummary=false", () => { + const opts: ApiHandlerOptions = { + apiModelId: "gpt-4.1", + openAiNativeApiKey: "test-key", + enableResponsesReasoningSummary: false, + } + new OpenAiNativeHandler(opts) + expect(opts.enableResponsesReasoningSummary).toBe(false) }) }) describe("getModel", () => { - it("should return model info", () => { + it("should return model info for gpt-4.1", () => { const modelInfo = handler.getModel() - expect(modelInfo.id).toBe(mockOptions.apiModelId) + expect(modelInfo.id).toBe("gpt-4.1") expect(modelInfo.info).toBeDefined() expect(modelInfo.info.maxTokens).toBe(32768) expect(modelInfo.info.contextWindow).toBe(1047576) }) - it("should handle undefined model ID", () => { + it("should handle undefined model ID and return default", () => { const handlerWithoutModel = new OpenAiNativeHandler({ openAiNativeApiKey: "test-api-key", }) const modelInfo = handlerWithoutModel.getModel() - expect(modelInfo.id).toBe("gpt-5.1-codex-max") // Default model + expect(modelInfo.id).toBe(openAiNativeDefaultModelId) expect(modelInfo.info).toBeDefined() }) + + it("should fall back to default model for invalid model ID", () => { + const handlerWithInvalidModel = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "invalid-model", + }) + const model = handlerWithInvalidModel.getModel() + expect(model.id).toBe(openAiNativeDefaultModelId) + }) + + it("should strip o3-mini suffix from model ID", () => { + const handlerO3 = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "o3-mini-high", + }) + const model = handlerO3.getModel() + expect(model.id).toBe("o3-mini") + }) + + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("maxTokens") + }) }) - describe("GPT-5 models", () => { - it("should handle GPT-5 model with Responses API", async () => { - // Mock fetch for Responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - // Simulate actual GPT-5 Responses API SSE stream format - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.created","response":{"id":"test","status":"in_progress"}}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"Hello"}}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":" world"}}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.done","response":{"usage":{"prompt_tokens":10,"completion_tokens":2}}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any + describe("createMessage", () => { + it("should handle streaming responses", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + yield { type: "text-delta", text: " response" } + } - // Mock SDK to fail so it uses fetch - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1", + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 2 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) const stream = handler.createMessage(systemPrompt, messages) @@ -272,67 +160,22 @@ describe("OpenAiNativeHandler", () => { chunks.push(chunk) } - // Verify Responses API is called with correct parameters - expect(mockFetch).toHaveBeenCalledWith( - "https://api.openai.com/v1/responses", - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - "Content-Type": "application/json", - Authorization: "Bearer test-api-key", - }), - body: expect.any(String), - }), - ) - const body1 = (mockFetch.mock.calls[0][1] as any).body as string - const parsedBody = JSON.parse(body1) - expect(parsedBody.model).toBe("gpt-5.1") - expect(parsedBody.instructions).toBe("You are a helpful assistant.") - // Now using structured format with content arrays (no system prompt in input; it's provided via `instructions`) - expect(parsedBody.input).toEqual([ - { - role: "user", - content: [{ type: "input_text", text: "Hello!" }], - }, - ]) - expect(parsedBody.reasoning?.effort).toBe("medium") - expect(parsedBody.reasoning?.summary).toBe("auto") - expect(parsedBody.text?.verbosity).toBe("medium") - // GPT-5 models don't include temperature - expect(parsedBody.temperature).toBeUndefined() - expect(parsedBody.max_output_tokens).toBeDefined() - - // Verify the streamed content const textChunks = chunks.filter((c) => c.type === "text") expect(textChunks).toHaveLength(2) - expect(textChunks[0].text).toBe("Hello") - expect(textChunks[1].text).toBe(" world") + expect(textChunks[0].text).toBe("Test") + expect(textChunks[1].text).toBe(" response") }) - it("should handle GPT-5-mini model with Responses API", async () => { - // Mock fetch for Responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"Response"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5-mini-2025-08-07", + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) const stream = handler.createMessage(systemPrompt, messages) @@ -341,39 +184,29 @@ describe("OpenAiNativeHandler", () => { chunks.push(chunk) } - // Verify correct model and default parameters - expect(mockFetch).toHaveBeenCalledWith( - "https://api.openai.com/v1/responses", - expect.objectContaining({ - body: expect.stringContaining('"model":"gpt-5-mini-2025-08-07"'), - }), - ) + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(20) }) - it("should handle GPT-5-nano model with Responses API", async () => { - // Mock fetch for Responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"Nano response"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() + it("should handle cached tokens in usage details", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + details: { + cachedInputTokens: 30, + reasoningTokens: 10, }, }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5-nano-2025-08-07", + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) const stream = handler.createMessage(systemPrompt, messages) @@ -382,83 +215,25 @@ describe("OpenAiNativeHandler", () => { chunks.push(chunk) } - // Verify correct model - expect(mockFetch).toHaveBeenCalledWith( - "https://api.openai.com/v1/responses", - expect.objectContaining({ - body: expect.stringContaining('"model":"gpt-5-nano-2025-08-07"'), - }), - ) + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].inputTokens).toBe(100) + expect(usageChunks[0].outputTokens).toBe(50) + expect(usageChunks[0].cacheReadTokens).toBe(30) + expect(usageChunks[0].reasoningTokens).toBe(10) }) - it("should support verbosity control for GPT-5", async () => { - // Mock fetch for Responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"Low verbosity"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1", - verbosity: "low", // Set verbosity through options - }) - - // Create a message to verify verbosity is passed - const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) + it("should handle reasoning stream parts", async () => { + async function* mockFullStream() { + yield { type: "reasoning-delta", text: "thinking..." } + yield { type: "text-delta", text: "answer" } } - // Verify that verbosity is passed in the request - expect(mockFetch).toHaveBeenCalledWith( - "https://api.openai.com/v1/responses", - expect.objectContaining({ - body: expect.stringContaining('"verbosity":"low"'), - }), - ) - }) - - it("should support minimal reasoning effort for GPT-5", async () => { - // Mock fetch for Responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"Minimal effort"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1", - reasoningEffort: "minimal" as any, // GPT-5 supports minimal + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) const stream = handler.createMessage(systemPrompt, messages) @@ -467,1093 +242,693 @@ describe("OpenAiNativeHandler", () => { chunks.push(chunk) } - // With minimal reasoning effort, the model should pass it through - expect(mockFetch).toHaveBeenCalledWith( - "https://api.openai.com/v1/responses", - expect.objectContaining({ - body: expect.stringContaining('"effort":"minimal"'), - }), - ) + const reasoningChunks = chunks.filter((c) => c.type === "reasoning") + expect(reasoningChunks).toHaveLength(1) + expect(reasoningChunks[0].text).toBe("thinking...") + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("answer") }) - it("should support xhigh reasoning effort for GPT-5.1 Codex Max", async () => { - // Mock fetch for Responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"XHigh effort"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any + it("should handle tool calls in stream", async () => { + async function* mockFullStream() { + yield { type: "tool-input-start", id: "call_1", toolName: "test_tool" } + yield { type: "tool-input-delta", id: "call_1", delta: '{"arg":"val"}' } + yield { type: "tool-input-end", id: "call_1" } + } - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1-codex-max", - reasoningEffort: "xhigh", + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) const stream = handler.createMessage(systemPrompt, messages) - for await (const _chunk of stream) { - // drain + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - expect(mockFetch).toHaveBeenCalledWith( - "https://api.openai.com/v1/responses", - expect.objectContaining({ - body: expect.stringContaining('"effort":"xhigh"'), - }), - ) + expect(chunks.some((c) => c.type === "tool_call_start")).toBe(true) + expect(chunks.some((c) => c.type === "tool_call_delta")).toBe(true) + expect(chunks.some((c) => c.type === "tool_call_end")).toBe(true) }) - it("should omit reasoning when selection is 'disable'", async () => { - // Mock fetch for Responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"No reasoning"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), + it("should handle API errors", async () => { + const error = new Error("API Error") + ;(error as any).name = "AI_APICallError" + ;(error as any).status = 500 + + // Suppress unhandled rejection warnings for dangling promises + const rejectedUsage = Promise.reject(error) + const rejectedMeta = Promise.reject(error) + const rejectedContent = Promise.reject(error) + rejectedUsage.catch(() => {}) + rejectedMeta.catch(() => {}) + rejectedContent.catch(() => {}) + + async function* errorStream() { + yield { type: "text-delta", text: "" } + throw error + } + + mockStreamText.mockReturnValue({ + fullStream: errorStream(), + usage: rejectedUsage, + providerMetadata: rejectedMeta, + content: rejectedContent, }) - global.fetch = mockFetch as any - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) + const stream = handler.createMessage(systemPrompt, messages) + await expect(async () => { + for await (const _chunk of stream) { + // drain + } + }).rejects.toThrow("OpenAI Native") + }) - const handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1", - reasoningEffort: "disable" as any, + it("should pass system prompt to streamText", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) const stream = handler.createMessage(systemPrompt, messages) for await (const _ of stream) { - // drain + // consume } - const bodyStr = (mockFetch.mock.calls[0][1] as any).body as string - const parsed = JSON.parse(bodyStr) - expect(parsed.reasoning).toBeUndefined() - expect(parsed.include).toBeUndefined() - }) - - it("should support low reasoning effort for GPT-5", async () => { - // Mock fetch for Responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"Low effort response"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1", - reasoningEffort: "low", - }) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Should use Responses API with low reasoning effort - expect(mockFetch).toHaveBeenCalledWith( - "https://api.openai.com/v1/responses", + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - body: expect.any(String), + system: systemPrompt, }), ) - const body2 = (mockFetch.mock.calls[0][1] as any).body as string - const parsedBody = JSON.parse(body2) - expect(parsedBody.model).toBe("gpt-5.1") - expect(parsedBody.reasoning?.effort).toBe("low") - expect(parsedBody.reasoning?.summary).toBe("auto") - expect(parsedBody.text?.verbosity).toBe("medium") - // GPT-5 models don't include temperature - expect(parsedBody.temperature).toBeUndefined() - expect(parsedBody.max_output_tokens).toBeDefined() }) - it("should support both verbosity and reasoning effort together for GPT-5", async () => { - // Mock fetch for Responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"High verbosity minimal effort"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any + it("should pass temperature when model supports it", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1", - verbosity: "high", - reasoningEffort: "minimal" as any, + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) + for await (const _ of stream) { + // consume } - // Should use Responses API with both parameters - expect(mockFetch).toHaveBeenCalledWith( - "https://api.openai.com/v1/responses", + // gpt-4.1 supports temperature + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - body: expect.any(String), + temperature: expect.any(Number), }), ) - const body3 = (mockFetch.mock.calls[0][1] as any).body as string - const parsedBody = JSON.parse(body3) - expect(parsedBody.model).toBe("gpt-5.1") - expect(parsedBody.reasoning?.effort).toBe("minimal") - expect(parsedBody.reasoning?.summary).toBe("auto") - expect(parsedBody.text?.verbosity).toBe("high") - // GPT-5 models don't include temperature - expect(parsedBody.temperature).toBeUndefined() - expect(parsedBody.max_output_tokens).toBeDefined() }) - it("should handle actual GPT-5 Responses API format", async () => { - // Mock fetch with actual response format from GPT-5 - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - // Test actual GPT-5 response format - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.created","response":{"id":"test","status":"in_progress"}}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.in_progress","response":{"status":"in_progress"}}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"First text"}}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":" Second text"}}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"reasoning","text":"Some reasoning"}}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.done","response":{"usage":{"prompt_tokens":100,"completion_tokens":20}}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), + it("should use user-specified temperature", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) - global.fetch = mockFetch as any - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ + const handlerWithTemp = new OpenAiNativeHandler({ ...mockOptions, - apiModelId: "gpt-5.1", + modelTemperature: 0.7, + }) + + const stream = handlerWithTemp.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + }), + ) + }) + + it("should pass store: false in provider options", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) + for await (const _ of stream) { + // consume } - // Should handle the actual format correctly - const textChunks = chunks.filter((c) => c.type === "text") - const reasoningChunks = chunks.filter((c) => c.type === "reasoning") - - expect(textChunks).toHaveLength(2) - expect(textChunks[0].text).toBe("First text") - expect(textChunks[1].text).toBe(" Second text") - - expect(reasoningChunks).toHaveLength(1) - expect(reasoningChunks[0].text).toBe("Some reasoning") - - // Should also have usage information with cost - const usageChunks = chunks.filter((c) => c.type === "usage") - expect(usageChunks).toHaveLength(1) - expect(usageChunks[0]).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 20, - totalCost: expect.any(Number), - }) - - // Verify cost calculation (GPT-5 pricing: input $1.25/M, output $10/M) - const expectedInputCost = (100 / 1_000_000) * 1.25 - const expectedOutputCost = (20 / 1_000_000) * 10.0 - const expectedTotalCost = expectedInputCost + expectedOutputCost - expect(usageChunks[0].totalCost).toBeCloseTo(expectedTotalCost, 10) + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: expect.objectContaining({ + openai: expect.objectContaining({ + store: false, + }), + }), + }), + ) }) - it("should handle Responses API with no content gracefully", async () => { - // Mock fetch with empty response - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('data: {"someField":"value"}\n\n')) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() + it("should capture responseId from provider metadata", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({ + openai: { + responseId: "resp_test123", + serviceTier: "default", }, }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1", + content: Promise.resolve([]), }) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - - // Should not throw, just warn - for await (const chunk of stream) { - chunks.push(chunk) + for await (const _ of stream) { + // consume } - // Should have no content chunks when stream is empty - const contentChunks = chunks.filter((c) => c.type === "text" || c.type === "reasoning") - - expect(contentChunks).toHaveLength(0) + expect(handler.getResponseId()).toBe("resp_test123") }) - it("should handle unhandled stream events gracefully", async () => { - // Mock fetch for the fallback SSE path - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"Hello"}}\n\n', - ), - ) - // This event is not handled, so it should be ignored - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.audio.delta","delta":"..."}\n\n'), - ) - controller.enqueue(new TextEncoder().encode('data: {"type":"response.done","response":{}}\n\n')) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, + it("should capture encrypted content from reasoning parts", async () => { + async function* mockFullStream() { + yield { type: "reasoning-delta", text: "thinking" } + yield { type: "text-delta", text: "answer" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({ + openai: { responseId: "resp_test" }, }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1", + content: Promise.resolve([ + { + type: "reasoning", + text: "thinking", + providerMetadata: { + openai: { + reasoningEncryptedContent: "encrypted_payload", + itemId: "item_123", + }, + }, + }, + { + type: "text", + text: "answer", + }, + ]), }) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - const errors: any[] = [] - - try { - for await (const chunk of stream) { - chunks.push(chunk) - } - } catch (error) { - errors.push(error) + for await (const _ of stream) { + // consume } - expect(errors.length).toBe(0) - const textChunks = chunks.filter((c) => c.type === "text") - expect(textChunks.length).toBeGreaterThan(0) - expect(textChunks[0].text).toBe("Hello") + const encrypted = handler.getEncryptedContent() + expect(encrypted).toBeDefined() + expect(encrypted!.encrypted_content).toBe("encrypted_payload") + expect(encrypted!.id).toBe("item_123") }) - it("should format full conversation correctly", async () => { - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"Response"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, + it("should reset state between requests", async () => { + // First request with metadata + async function* mockFullStream1() { + yield { type: "text-delta", text: "first" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream1(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({ + openai: { responseId: "resp_1" }, }), + content: Promise.resolve([]), + }) + + let stream = handler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + expect(handler.getResponseId()).toBe("resp_1") + + // Second request should reset state + async function* mockFullStream2() { + yield { type: "text-delta", text: "second" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream2(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + stream = handler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + // Should be reset since second request had no responseId + expect(handler.getResponseId()).toBeUndefined() + expect(handler.getEncryptedContent()).toBeUndefined() + }) + }) + + describe("GPT-5 models", () => { + it("should pass reasoning effort in provider options for GPT-5", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) - global.fetch = mockFetch as any - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) const gpt5Handler = new OpenAiNativeHandler({ ...mockOptions, apiModelId: "gpt-5.1", }) - const stream = gpt5Handler.createMessage(systemPrompt, messages, { - taskId: "task1", - }) - for await (const chunk of stream) { + const stream = gpt5Handler.createMessage(systemPrompt, messages) + for await (const _ of stream) { // consume } - const callBody = JSON.parse(mockFetch.mock.calls[0][1].body) - expect(callBody.input).toEqual([ - { - role: "user", - content: [{ type: "input_text", text: "Hello!" }], - }, - ]) - expect(callBody.previous_response_id).toBeUndefined() + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: expect.objectContaining({ + openai: expect.objectContaining({ + reasoningEffort: expect.any(String), + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }), + }), + }), + ) }) - it("should provide helpful error messages for different error codes", async () => { - const testCases = [ - { status: 400, expectedMessage: "Invalid request to Responses API" }, - { status: 401, expectedMessage: "Authentication failed" }, - { status: 403, expectedMessage: "Access denied" }, - { status: 404, expectedMessage: "Responses API endpoint not found" }, - { status: 429, expectedMessage: "Rate limit exceeded" }, - { status: 500, expectedMessage: "OpenAI service error" }, - ] + it("should pass verbosity in provider options for models that support it", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } - for (const { status, expectedMessage } of testCases) { - // Mock fetch with error response - const mockFetch = vitest.fn().mockResolvedValue({ - ok: false, - status, - statusText: "Error", - text: async () => JSON.stringify({ error: { message: "Test error" } }), - }) - global.fetch = mockFetch as any + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) + const gpt5Handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1", + verbosity: "low", + }) - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1", - }) + const stream = gpt5Handler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } - const stream = handler.createMessage(systemPrompt, messages) + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: expect.objectContaining({ + openai: expect.objectContaining({ + textVerbosity: "low", + }), + }), + }), + ) + }) - await expect(async () => { - for await (const chunk of stream) { - // Should throw before yielding anything - } - }).rejects.toThrow(expectedMessage) + it("should support xhigh reasoning effort for GPT-5.1 Codex Max", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } - // Clean up - delete (global as any).fetch + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const codexHandler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1-codex-max", + reasoningEffort: "xhigh", + }) + + const stream = codexHandler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: expect.objectContaining({ + openai: expect.objectContaining({ + reasoningEffort: "xhigh", + }), + }), + }), + ) + }) + + it("should omit reasoning when selection is 'disable'", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "No reasoning" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const h = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1", + reasoningEffort: "disable" as any, + }) + + const stream = h.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions.openai.reasoningEffort).toBeUndefined() + expect(callArgs.providerOptions.openai.include).toBeUndefined() + expect(callArgs.providerOptions.openai.reasoningSummary).toBeUndefined() + }) + + it("should not pass temperature for models that don't support it", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const gpt5Handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1", + }) + + const stream = gpt5Handler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + // GPT-5 models have supportsTemperature: false + const gpt51Info = openAiNativeModels["gpt-5.1"] + if (gpt51Info.supportsTemperature === false) { + expect(callArgs.temperature).toBeUndefined() } }) - }) - describe("error telemetry", () => { - const errorMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello", - }, - ] + it("should not include verbosity for non-GPT-5 models", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } - const errorSystemPrompt = "You are a helpful assistant" - - beforeEach(() => { - mockCaptureException.mockClear() - }) - - it("should capture telemetry on createMessage error", async () => { - // Mock fetch to return error - const mockFetch = vitest.fn().mockResolvedValue({ - ok: false, - status: 500, - text: async () => "Internal Server Error", - }) - global.fetch = mockFetch as any - - // Mock SDK to fail so it falls back to fetch - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - const stream = handler.createMessage(errorSystemPrompt, errorMessages) - - await expect(async () => { - for await (const _chunk of stream) { - // Should throw before yielding any chunks - } - }).rejects.toThrow() - - // Verify telemetry was captured - expect(mockCaptureException).toHaveBeenCalledTimes(1) - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining("OpenAI service error"), - provider: "OpenAI Native", - modelId: "gpt-4.1", - operation: "createMessage", - }), - ) - - // Verify it's an ApiProviderError - const capturedError = mockCaptureException.mock.calls[0][0] - expect(capturedError).toBeInstanceOf(ApiProviderError) - }) - - it("should capture telemetry on stream processing error", async () => { - // Mock fetch to return a stream with an error event - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.error","error":{"message":"Model overloaded"}}\n\n', - ), - ) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail so it falls back to fetch - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - const stream = handler.createMessage(errorSystemPrompt, errorMessages) - - await expect(async () => { - for await (const _chunk of stream) { - // Should throw when encountering error event - } - }).rejects.toThrow() - - // Verify telemetry was captured (may be called multiple times due to error propagation) - expect(mockCaptureException).toHaveBeenCalled() - - // Find the call with the stream error message - const streamErrorCall = mockCaptureException.mock.calls.find((call: any[]) => - call[0]?.message?.includes("Model overloaded"), - ) - expect(streamErrorCall).toBeDefined() - expect(streamErrorCall![0]).toMatchObject({ - provider: "OpenAI Native", - modelId: "gpt-4.1", - operation: "createMessage", + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) - // Verify it's an ApiProviderError - expect(streamErrorCall![0]).toBeInstanceOf(ApiProviderError) - }) - - it("should capture telemetry on completePrompt error", async () => { - // Mock SDK to throw an error - mockResponsesCreate.mockRejectedValue(new Error("API Error")) - - await expect(handler.completePrompt("Test prompt")).rejects.toThrow() - - // Verify telemetry was captured - expect(mockCaptureException).toHaveBeenCalledTimes(1) - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "API Error", - provider: "OpenAI Native", - modelId: "gpt-4.1", - operation: "completePrompt", - }), - ) - - // Verify it's an ApiProviderError - const capturedError = mockCaptureException.mock.calls[0][0] - expect(capturedError).toBeInstanceOf(ApiProviderError) - }) - - it("should still throw the error after capturing telemetry", async () => { - // Mock fetch to return error - const mockFetch = vitest.fn().mockResolvedValue({ - ok: false, - status: 500, - text: async () => "Internal Server Error", - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - const stream = handler.createMessage(errorSystemPrompt, errorMessages) - - // Verify the error is still thrown - await expect(async () => { - for await (const _chunk of stream) { - // Should throw - } - }).rejects.toThrow() - - // Telemetry should have been captured before the error was thrown - expect(mockCaptureException).toHaveBeenCalled() - }) - }) -}) - -// Additional tests for GPT-5 streaming event coverage -describe("GPT-5 streaming event coverage (additional)", () => { - afterEach(() => { - if ((global as any).fetch) { - delete (global as any).fetch - } - }) - - it("should handle reasoning delta events for GPT-5", async () => { - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.reasoning.delta","delta":"Thinking about the problem..."}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.text.delta","delta":"The answer is..."}\n\n'), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - const handler = new OpenAiNativeHandler({ - apiModelId: "gpt-5.1", - openAiNativeApiKey: "test-api-key", - }) - - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello!" }] - const stream = handler.createMessage(systemPrompt, messages) - - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - const reasoningChunks = chunks.filter((c) => c.type === "reasoning") - const textChunks = chunks.filter((c) => c.type === "text") - - expect(reasoningChunks).toHaveLength(1) - expect(reasoningChunks[0].text).toBe("Thinking about the problem...") - expect(textChunks).toHaveLength(1) - expect(textChunks[0].text).toBe("The answer is...") - }) - - it("should handle refusal delta events for GPT-5 and prefix output", async () => { - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.refusal.delta","delta":"I cannot comply with this request."}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - const handler = new OpenAiNativeHandler({ - apiModelId: "gpt-5.1", - openAiNativeApiKey: "test-api-key", - }) - - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Do something disallowed" }] - const stream = handler.createMessage(systemPrompt, messages) - - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - const textChunks = chunks.filter((c) => c.type === "text") - expect(textChunks).toHaveLength(1) - expect(textChunks[0].text).toBe("[Refusal] I cannot comply with this request.") - }) - - it("should ignore malformed JSON lines in SSE stream", async () => { - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"Before"}}\n\n', - ), - ) - // Malformed JSON line - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.text.delta","delta":"Bad"\n\n'), - ) - // Valid line after malformed - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"After"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - const handler = new OpenAiNativeHandler({ - apiModelId: "gpt-5.1", - openAiNativeApiKey: "test-api-key", - }) - - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello!" }] - const stream = handler.createMessage(systemPrompt, messages) - - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // It should not throw and still capture the valid texts around the malformed line - const textChunks = chunks.filter((c) => c.type === "text") - expect(textChunks.map((c: any) => c.text)).toEqual(["Before", "After"]) - }) - - describe("Codex Mini Model", () => { - let handler: OpenAiNativeHandler - const mockOptions: ApiHandlerOptions = { - openAiNativeApiKey: "test-api-key", - apiModelId: "codex-mini-latest", - } - - it("should handle codex-mini-latest streaming response", async () => { - // Mock fetch for Codex Mini responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - // Codex Mini uses the same responses API format - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.output_text.delta","delta":"Hello"}\n\n'), - ) - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.output_text.delta","delta":" from"}\n\n'), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_text.delta","delta":" Codex"}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_text.delta","delta":" Mini!"}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.done","response":{"usage":{"prompt_tokens":50,"completion_tokens":10}}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "codex-mini-latest", - }) - - const systemPrompt = "You are a helpful coding assistant." - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Write a hello world function" }, - ] - + // gpt-4.1 does not support verbosity const stream = handler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions.openai.textVerbosity).toBeUndefined() + }) + + it("should handle GPT-5 models with multiple stream chunks", async () => { + async function* mockFullStream() { + yield { type: "reasoning-delta", text: "reasoning step 1" } + yield { type: "reasoning-delta", text: " step 2" } + yield { type: "text-delta", text: "Hello" } + yield { type: "text-delta", text: " world" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + details: { reasoningTokens: 20 }, + }), + providerMetadata: Promise.resolve({ + openai: { responseId: "resp_gpt5_test" }, + }), + content: Promise.resolve([]), + }) + + const gpt5Handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1", + }) + + const stream = gpt5Handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { chunks.push(chunk) } - // Verify text chunks - const textChunks = chunks.filter((c) => c.type === "text") - expect(textChunks).toHaveLength(4) - expect(textChunks.map((c) => c.text).join("")).toBe("Hello from Codex Mini!") + const reasoning = chunks.filter((c) => c.type === "reasoning") + expect(reasoning).toHaveLength(2) - // Verify usage data from API + const text = chunks.filter((c) => c.type === "text") + expect(text).toHaveLength(2) + + const usage = chunks.filter((c) => c.type === "usage") + expect(usage).toHaveLength(1) + expect(usage[0].reasoningTokens).toBe(20) + }) + }) + + describe("service tier", () => { + it("should pass service tier in provider options when supported", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const tierHandler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1", + openAiNativeServiceTier: "flex", + }) + + const stream = tierHandler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + // Tier should be passed when model supports it + const model = tierHandler.getModel() + const allowedTiers = new Set(model.info.tiers?.map((t) => t.name).filter(Boolean) || []) + if (allowedTiers.has("flex")) { + expect(callArgs.providerOptions.openai.serviceTier).toBe("flex") + } + }) + + it("should capture service tier from provider metadata", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({ + openai: { + responseId: "resp_123", + serviceTier: "flex", + }, + }), + content: Promise.resolve([]), + }) + + const tierHandler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1", + openAiNativeServiceTier: "flex", + }) + + const stream = tierHandler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Usage should include totalCost (calculated with tier pricing) const usageChunks = chunks.filter((c) => c.type === "usage") expect(usageChunks).toHaveLength(1) - expect(usageChunks[0]).toMatchObject({ - type: "usage", - inputTokens: 50, - outputTokens: 10, - totalCost: expect.any(Number), // Codex Mini has pricing: $1.5/M input, $6/M output - }) - - // Verify cost is calculated correctly based on API usage data - const expectedCost = (50 / 1_000_000) * 1.5 + (10 / 1_000_000) * 6 - expect(usageChunks[0].totalCost).toBeCloseTo(expectedCost, 10) - - // Verify the request was made with correct parameters - expect(mockFetch).toHaveBeenCalledWith( - "https://api.openai.com/v1/responses", - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - "Content-Type": "application/json", - Authorization: "Bearer test-api-key", - }), - body: expect.any(String), - }), - ) - - const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body) - expect(requestBody).toMatchObject({ - model: "codex-mini-latest", - instructions: "You are a helpful coding assistant.", - input: [ - { - role: "user", - content: [{ type: "input_text", text: "Write a hello world function" }], - }, - ], - stream: true, - }) + expect(typeof usageChunks[0].totalCost).toBe("number") }) + }) - it("should handle codex-mini-latest non-streaming completion", async () => { - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "codex-mini-latest", - }) - - // Mock the responses.create method to return a non-streaming response - mockResponsesCreate.mockResolvedValue({ - output: [ - { - type: "message", - content: [ - { - type: "output_text", - text: "def hello_world():\n print('Hello, World!')", - }, - ], - }, - ], - }) - - const result = await handler.completePrompt("Write a hello world function in Python") - - expect(result).toBe("def hello_world():\n print('Hello, World!')") - expect(mockResponsesCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: "codex-mini-latest", - stream: false, - store: false, - }), - expect.objectContaining({ - signal: expect.any(Object), - }), - ) - }) - - it("should handle codex-mini-latest API errors", async () => { - // Mock fetch with error response - const mockFetch = vitest.fn().mockResolvedValue({ - ok: false, - status: 429, - statusText: "Too Many Requests", - text: async () => "Rate limit exceeded", - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "codex-mini-latest", - }) - - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] - - const stream = handler.createMessage(systemPrompt, messages) - - // Should throw an error (using the same error format as GPT-5) - await expect(async () => { - for await (const chunk of stream) { - // consume stream - } - }).rejects.toThrow("Rate limit exceeded") - }) - - it("should handle codex-mini-latest with multiple user messages", async () => { - // Mock fetch for streaming response - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_text.delta","delta":"Combined response"}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode('data: {"type":"response.completed"}\n\n')) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "codex-mini-latest", - }) - - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "First question" }, - { role: "assistant", content: "First answer" }, - { role: "user", content: "Second question" }, - ] - - const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) + describe("prompt cache retention", () => { + it("should pass promptCacheRetention for models that support it", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } } - // Verify the request body includes full conversation in structured format (without embedding system prompt) - const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body) - expect(requestBody.instructions).toBe("You are a helpful assistant.") - expect(requestBody.input).toEqual([ - { - role: "user", - content: [{ type: "input_text", text: "First question" }], - }, - { - role: "assistant", - content: [{ type: "output_text", text: "First answer" }], - }, - { - role: "user", - content: [{ type: "input_text", text: "Second question" }], - }, - ]) - }) - - it("should handle codex-mini-latest stream error events", async () => { - // Mock fetch with error event in stream - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_text.delta","delta":"Partial"}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.error","error":{"message":"Model overloaded"}}\n\n', - ), - ) - // The error handler will throw, but we still need to close the stream - controller.close() - }, - }), + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) - global.fetch = mockFetch as any - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ + const h = new OpenAiNativeHandler({ ...mockOptions, - apiModelId: "codex-mini-latest", + apiModelId: "gpt-5.1", }) - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] + const stream = h.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } - const stream = handler.createMessage(systemPrompt, messages) - - // Should throw an error when encountering error event - await expect(async () => { - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - }).rejects.toThrow("Responses API error: Model overloaded") + const callArgs = mockStreamText.mock.calls[0][0] + const modelInfo = openAiNativeModels["gpt-5.1"] + if (modelInfo.supportsPromptCache && modelInfo.promptCacheRetention === "24h") { + expect(callArgs.providerOptions.openai.promptCacheRetention).toBe("24h") + } }) - // New tests: ensure text.verbosity is omitted for models without supportsVerbosity - describe("Verbosity gating for non-GPT-5 models", () => { - it("should omit text.verbosity for gpt-4.1", async () => { - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.done","response":{}}\n\n'), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - ;(global as any).fetch = mockFetch as any + it("should not pass promptCacheRetention for models without support", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } - // Force SDK path to fail so we use fetch fallback - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - const handler = new OpenAiNativeHandler({ - apiModelId: "gpt-4.1", - openAiNativeApiKey: "test-api-key", - verbosity: "high", - }) - - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello!" }] - const stream = handler.createMessage(systemPrompt, messages) - - for await (const _ of stream) { - // drain - } - - const bodyStr = (mockFetch.mock.calls[0][1] as any).body as string - const parsedBody = JSON.parse(bodyStr) - expect(parsedBody.model).toBe("gpt-4.1") - expect(parsedBody.text).toBeUndefined() - expect(bodyStr).not.toContain('"verbosity"') + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) - it("should omit text.verbosity for gpt-4o", async () => { - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.done","response":{}}\n\n'), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - ;(global as any).fetch = mockFetch as any + // gpt-4.1 doesn't have promptCacheRetention: "24h" + const stream = handler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } - // Force SDK path to fail so we use fetch fallback - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions.openai.promptCacheRetention).toBeUndefined() + }) + }) - const handler = new OpenAiNativeHandler({ - apiModelId: "gpt-4o", - openAiNativeApiKey: "test-api-key", - verbosity: "low", - }) - - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello!" }] - const stream = handler.createMessage(systemPrompt, messages) - - for await (const _ of stream) { - // drain - } - - const bodyStr = (mockFetch.mock.calls[0][1] as any).body as string - const parsedBody = JSON.parse(bodyStr) - expect(parsedBody.model).toBe("gpt-4o") - expect(parsedBody.text).toBeUndefined() - expect(bodyStr).not.toContain('"verbosity"') + describe("completePrompt", () => { + it("should complete prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "This is the completion response", + usage: { inputTokens: 10, outputTokens: 5 }, }) + + const result = await handler.completePrompt("Test prompt") + expect(result).toBe("This is the completion response") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + providerOptions: expect.objectContaining({ + openai: expect.objectContaining({ + store: false, + }), + }), + }), + ) + }) + + it("should handle errors in completePrompt", async () => { + mockGenerateText.mockRejectedValue(new Error("API Error")) + + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("OpenAI Native") + }) + + it("should return empty string when no text in response", async () => { + mockGenerateText.mockResolvedValue({ + text: "", + usage: { inputTokens: 10, outputTokens: 0 }, + }) + + const result = await handler.completePrompt("Test prompt") + expect(result).toBe("") + }) + }) + + describe("isAiSdkProvider", () => { + it("should return true", () => { + expect(handler.isAiSdkProvider()).toBe(true) + }) + }) + + describe("getEncryptedContent", () => { + it("should return undefined when no encrypted content has been captured", () => { + expect(handler.getEncryptedContent()).toBeUndefined() + }) + }) + + describe("getResponseId", () => { + it("should return undefined when no response ID has been captured", () => { + expect(handler.getResponseId()).toBeUndefined() }) }) }) diff --git a/src/api/providers/__tests__/sambanova.spec.ts b/src/api/providers/__tests__/sambanova.spec.ts index 685cedf34c..51bc256b76 100644 --- a/src/api/providers/__tests__/sambanova.spec.ts +++ b/src/api/providers/__tests__/sambanova.spec.ts @@ -1,152 +1,628 @@ // npx vitest run src/api/providers/__tests__/sambanova.spec.ts -import OpenAI from "openai" -import { Anthropic } from "@anthropic-ai/sdk" +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) -import { type SambaNovaModelId, sambaNovaDefaultModelId, sambaNovaModels } from "@roo-code/types" - -import { SambaNovaHandler } from "../sambanova" - -vitest.mock("openai", () => { - const createMock = vitest.fn() +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - default: vitest.fn(() => ({ chat: { completions: { create: createMock } } })), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) +vi.mock("sambanova-ai-provider", () => ({ + createSambaNova: vi.fn(() => { + // Return a function that returns a mock language model + return vi.fn(() => ({ + modelId: "Meta-Llama-3.3-70B-Instruct", + provider: "sambanova", + })) + }), +})) + +import type { Anthropic } from "@anthropic-ai/sdk" + +import { sambaNovaDefaultModelId, sambaNovaModels, type SambaNovaModelId } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../../shared/api" + +import { SambaNovaHandler } from "../sambanova" + describe("SambaNovaHandler", () => { let handler: SambaNovaHandler - let mockCreate: any + let mockOptions: ApiHandlerOptions beforeEach(() => { - vitest.clearAllMocks() - mockCreate = (OpenAI as unknown as any)().chat.completions.create - handler = new SambaNovaHandler({ sambaNovaApiKey: "test-sambanova-api-key" }) - }) - - it("should use the correct SambaNova base URL", () => { - new SambaNovaHandler({ sambaNovaApiKey: "test-sambanova-api-key" }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://api.sambanova.ai/v1" })) - }) - - it("should use the provided API key", () => { - const sambaNovaApiKey = "test-sambanova-api-key" - new SambaNovaHandler({ sambaNovaApiKey }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: sambaNovaApiKey })) - }) - - it("should return default model when no model is specified", () => { - const model = handler.getModel() - expect(model.id).toBe(sambaNovaDefaultModelId) - expect(model.info).toEqual(sambaNovaModels[sambaNovaDefaultModelId]) - }) - - it("should return specified model when valid model is provided", () => { - const testModelId: SambaNovaModelId = "Meta-Llama-3.3-70B-Instruct" - const handlerWithModel = new SambaNovaHandler({ - apiModelId: testModelId, + mockOptions = { sambaNovaApiKey: "test-sambanova-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual(sambaNovaModels[testModelId]) + apiModelId: "Meta-Llama-3.3-70B-Instruct", + } + handler = new SambaNovaHandler(mockOptions) + vi.clearAllMocks() }) - it("completePrompt method should return text from SambaNova API", async () => { - const expectedResponse = "This is a test response from SambaNova" - mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] }) - const result = await handler.completePrompt("test prompt") - expect(result).toBe(expectedResponse) - }) - - it("should handle errors in completePrompt", async () => { - const errorMessage = "SambaNova API error" - mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - `SambaNova completion error: ${errorMessage}`, - ) - }) - - it("createMessage should yield text content from stream", async () => { - const testContent = "This is test content from SambaNova stream" - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vitest - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: testContent } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(SambaNovaHandler) + expect(handler.getModel().id).toBe(mockOptions.apiModelId) }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) + it("should use default model ID if not provided", () => { + const handlerWithoutModel = new SambaNovaHandler({ + ...mockOptions, + apiModelId: undefined, + }) + expect(handlerWithoutModel.getModel().id).toBe(sambaNovaDefaultModelId) + }) }) - it("createMessage should yield usage data from stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vitest - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } + describe("getModel", () => { + it("should return default model when no model is specified", () => { + const handlerWithoutModel = new SambaNovaHandler({ + sambaNovaApiKey: "test-sambanova-api-key", + }) + const model = handlerWithoutModel.getModel() + expect(model.id).toBe(sambaNovaDefaultModelId) + expect(model.info).toEqual(sambaNovaModels[sambaNovaDefaultModelId]) }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + it("should return specified model when valid model is provided", () => { + const testModelId: SambaNovaModelId = "Meta-Llama-3.3-70B-Instruct" + const handlerWithModel = new SambaNovaHandler({ + apiModelId: testModelId, + sambaNovaApiKey: "test-sambanova-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual(sambaNovaModels[testModelId]) + }) - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 }) + it("should return Meta-Llama-3.1-8B-Instruct model with correct configuration", () => { + const testModelId: SambaNovaModelId = "Meta-Llama-3.1-8B-Instruct" + const handlerWithModel = new SambaNovaHandler({ + apiModelId: testModelId, + sambaNovaApiKey: "test-sambanova-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toBeDefined() + expect(model.info.maxTokens).toBeDefined() + expect(model.info.contextWindow).toBeDefined() + }) + + it("should return provided model ID with default model info if model does not exist", () => { + const handlerWithInvalidModel = new SambaNovaHandler({ + ...mockOptions, + apiModelId: "invalid-model", + }) + const model = handlerWithInvalidModel.getModel() + expect(model.id).toBe("invalid-model") + expect(model.info).toBeDefined() + // Should use default model info + expect(model.info).toBe(sambaNovaModels[sambaNovaDefaultModelId]) + }) + + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") + }) }) - it("createMessage should pass correct parameters to SambaNova client", async () => { - const modelId: SambaNovaModelId = "Meta-Llama-3.3-70B-Instruct" - const modelInfo = sambaNovaModels[modelId] - const handlerWithModel = new SambaNovaHandler({ - apiModelId: modelId, - sambaNovaApiKey: "test-sambanova-api-key", - }) - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text" as const, + text: "Hello!", }, - }), + ], + }, + ] + + it("should handle streaming responses", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response from SambaNova" } } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response from SambaNova") }) - const systemPrompt = "Test system prompt for SambaNova" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for SambaNova" }] + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } - const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages) - await messageGenerator.next() + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 20, + }) - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: modelId, - max_tokens: modelInfo.maxTokens, - temperature: 0.7, - messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), - stream: true, - stream_options: { include_usage: true }, - }), - undefined, - ) + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(20) + }) + + it("should handle cached tokens in usage data from providerMetadata", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + }) + + // SambaNova provides cache metrics via providerMetadata for supported models + const mockProviderMetadata = Promise.resolve({ + sambanova: { + promptCacheHitTokens: 30, + promptCacheMissTokens: 70, + }, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(100) + expect(usageChunks[0].outputTokens).toBe(50) + expect(usageChunks[0].cacheReadTokens).toBe(30) + expect(usageChunks[0].cacheWriteTokens).toBe(70) + }) + + it("should handle usage with details.cachedInputTokens when providerMetadata is not available", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + details: { + cachedInputTokens: 25, + }, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].cacheReadTokens).toBe(25) + expect(usageChunks[0].cacheWriteTokens).toBeUndefined() + }) + + it("should pass correct temperature (0.7 default) to streamText", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const handlerWithDefaultTemp = new SambaNovaHandler({ + sambaNovaApiKey: "test-key", + apiModelId: "Meta-Llama-3.3-70B-Instruct", + }) + + const stream = handlerWithDefaultTemp.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + }), + ) + }) + + it("should use user-specified temperature over model and provider defaults", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const handlerWithCustomTemp = new SambaNovaHandler({ + sambaNovaApiKey: "test-key", + apiModelId: "Meta-Llama-3.3-70B-Instruct", + modelTemperature: 0.7, + }) + + const stream = handlerWithCustomTemp.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume stream + } + + // User-specified temperature should take precedence over everything + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + }), + ) + }) + + it("should handle stream with multiple chunks", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Hello" } + yield { type: "text-delta", text: " world" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 10 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks[0]).toEqual({ type: "text", text: "Hello" }) + expect(textChunks[1]).toEqual({ type: "text", text: " world" }) + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks[0]).toMatchObject({ type: "usage", inputTokens: 5, outputTokens: 10 }) + }) + }) + + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion from SambaNova", + }) + + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("Test completion from SambaNova") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) + }) + + it("should use default temperature in completePrompt", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", + }) + + await handler.completePrompt("Test prompt") + + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + }), + ) + }) + }) + + describe("processUsageMetrics", () => { + it("should correctly process usage metrics including cache information from providerMetadata", () => { + class TestSambaNovaHandler extends SambaNovaHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestSambaNovaHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + } + + const providerMetadata = { + sambanova: { + promptCacheHitTokens: 20, + promptCacheMissTokens: 80, + }, + } + + const result = testHandler.testProcessUsageMetrics(usage, providerMetadata) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBe(80) + expect(result.cacheReadTokens).toBe(20) + }) + + it("should handle missing cache metrics gracefully", () => { + class TestSambaNovaHandler extends SambaNovaHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestSambaNovaHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + } + + const result = testHandler.testProcessUsageMetrics(usage) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBeUndefined() + expect(result.cacheReadTokens).toBeUndefined() + }) + + it("should include reasoning tokens when provided", () => { + class TestSambaNovaHandler extends SambaNovaHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestSambaNovaHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + details: { + reasoningTokens: 30, + }, + } + + const result = testHandler.testProcessUsageMetrics(usage) + + expect(result.reasoningTokens).toBe(30) + }) + }) + + describe("tool handling", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle tool calls in streaming", async () => { + async function* mockFullStream() { + yield { + type: "tool-input-start", + id: "tool-call-1", + toolName: "read_file", + } + yield { + type: "tool-input-delta", + id: "tool-call-1", + delta: '{"path":"test.ts"}', + } + yield { + type: "tool-input-end", + id: "tool-call-1", + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + ], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start") + const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end") + + expect(toolCallStartChunks.length).toBe(1) + expect(toolCallStartChunks[0].id).toBe("tool-call-1") + expect(toolCallStartChunks[0].name).toBe("read_file") + + expect(toolCallDeltaChunks.length).toBe(1) + expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}') + + expect(toolCallEndChunks.length).toBe(1) + expect(toolCallEndChunks[0].id).toBe("tool-call-1") + }) + + it("should ignore tool-call events to prevent duplicate tools in UI", async () => { + async function* mockFullStream() { + yield { + type: "tool-call", + toolCallId: "tool-call-1", + toolName: "read_file", + input: { path: "test.ts" }, + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // tool-call events should be ignored (only tool-input-start/delta/end are processed) + const toolCallChunks = chunks.filter( + (c) => c.type === "tool_call_start" || c.type === "tool_call_delta" || c.type === "tool_call_end", + ) + expect(toolCallChunks.length).toBe(0) + }) + }) + + describe("error handling", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle AI SDK errors with handleAiSdkError", async () => { + // eslint-disable-next-line require-yield + async function* mockFullStream(): AsyncGenerator { + throw new Error("API Error") + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, messages) + + await expect(async () => { + for await (const _ of stream) { + // consume stream + } + }).rejects.toThrow("SambaNova: API Error") + }) + + it("should preserve status codes in error handling", async () => { + const apiError = new Error("Rate limit exceeded") + ;(apiError as any).status = 429 + + // eslint-disable-next-line require-yield + async function* mockFullStream(): AsyncGenerator { + throw apiError + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, messages) + + try { + for await (const _ of stream) { + // consume stream + } + expect.fail("Should have thrown an error") + } catch (error: any) { + expect(error.message).toContain("SambaNova") + expect(error.status).toBe(429) + } + }) }) }) diff --git a/src/api/providers/__tests__/unbound.spec.ts b/src/api/providers/__tests__/unbound.spec.ts deleted file mode 100644 index e95586dc6b..0000000000 --- a/src/api/providers/__tests__/unbound.spec.ts +++ /dev/null @@ -1,549 +0,0 @@ -// npx vitest run src/api/providers/__tests__/unbound.spec.ts - -import { Anthropic } from "@anthropic-ai/sdk" - -import { ApiHandlerOptions } from "../../../shared/api" - -import { UnboundHandler } from "../unbound" - -// Mock dependencies -vitest.mock("../fetchers/modelCache", () => ({ - getModels: vitest.fn().mockImplementation(() => { - return Promise.resolve({ - "anthropic/claude-3-5-sonnet-20241022": { - maxTokens: 8192, - contextWindow: 200000, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 3, - outputPrice: 15, - cacheWritesPrice: 3.75, - cacheReadsPrice: 0.3, - description: "Claude 3.5 Sonnet", - thinking: false, - }, - "anthropic/claude-sonnet-4-5": { - maxTokens: 8192, - contextWindow: 200000, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 3, - outputPrice: 15, - cacheWritesPrice: 3.75, - cacheReadsPrice: 0.3, - description: "Claude 4.5 Sonnet", - thinking: false, - }, - "anthropic/claude-3-7-sonnet-20250219": { - maxTokens: 8192, - contextWindow: 200000, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 3, - outputPrice: 15, - cacheWritesPrice: 3.75, - cacheReadsPrice: 0.3, - description: "Claude 3.7 Sonnet", - thinking: false, - }, - "openai/gpt-4o": { - maxTokens: 4096, - contextWindow: 128000, - supportsImages: true, - supportsPromptCache: false, - inputPrice: 5, - outputPrice: 15, - description: "GPT-4o", - }, - "openai/o3-mini": { - maxTokens: 4096, - contextWindow: 128000, - supportsImages: true, - supportsPromptCache: false, - inputPrice: 1, - outputPrice: 3, - description: "O3 Mini", - }, - }) - }), - getModelsFromCache: vitest.fn().mockReturnValue(undefined), -})) - -// Mock OpenAI client -const mockCreate = vitest.fn() -const mockWithResponse = vitest.fn() - -vitest.mock("openai", () => { - return { - __esModule: true, - default: vitest.fn().mockImplementation(() => ({ - chat: { - completions: { - create: (...args: any[]) => { - const stream = { - [Symbol.asyncIterator]: async function* () { - // First chunk with content - yield { - choices: [{ delta: { content: "Test response" }, index: 0 }], - } - // Second chunk with usage data - yield { - choices: [{ delta: {}, index: 0 }], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } - // Third chunk with cache usage data - yield { - choices: [{ delta: {}, index: 0 }], - usage: { - prompt_tokens: 8, - completion_tokens: 4, - total_tokens: 12, - cache_creation_input_tokens: 3, - cache_read_input_tokens: 2, - }, - } - }, - } - - const result = mockCreate(...args) - - if (args[0].stream) { - mockWithResponse.mockReturnValue( - Promise.resolve({ data: stream, response: { headers: new Map() } }), - ) - result.withResponse = mockWithResponse - } - - return result - }, - }, - }, - })), - } -}) - -describe("UnboundHandler", () => { - let handler: UnboundHandler - let mockOptions: ApiHandlerOptions - - beforeEach(() => { - mockOptions = { - unboundApiKey: "test-api-key", - unboundModelId: "anthropic/claude-3-5-sonnet-20241022", - } - - handler = new UnboundHandler(mockOptions) - mockCreate.mockClear() - mockWithResponse.mockClear() - - // Default mock implementation for non-streaming responses - mockCreate.mockResolvedValue({ - id: "test-completion", - choices: [ - { - message: { role: "assistant", content: "Test response" }, - finish_reason: "stop", - index: 0, - }, - ], - }) - }) - - describe("constructor", () => { - it("should initialize with provided options", async () => { - expect(handler).toBeInstanceOf(UnboundHandler) - expect((await handler.fetchModel()).id).toBe(mockOptions.unboundModelId) - }) - }) - - describe("createMessage", () => { - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello!", - }, - ] - - it("should handle streaming responses with text and usage data", async () => { - const stream = handler.createMessage(systemPrompt, messages) - const chunks: Array<{ type: string } & Record> = [] - - for await (const chunk of stream) { - chunks.push(chunk) - } - - expect(chunks.length).toBe(3) - - // Verify text chunk - expect(chunks[0]).toEqual({ type: "text", text: "Test response" }) - - // Verify regular usage data - expect(chunks[1]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 5 }) - - // Verify usage data with cache information - expect(chunks[2]).toEqual({ - type: "usage", - inputTokens: 8, - outputTokens: 4, - cacheWriteTokens: 3, - cacheReadTokens: 2, - }) - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: "claude-3-5-sonnet-20241022", - messages: expect.any(Array), - stream: true, - }), - - expect.objectContaining({ - headers: { - "X-Unbound-Metadata": expect.stringContaining("roo-code"), - }, - }), - ) - }) - - it("should handle API errors", async () => { - mockCreate.mockImplementationOnce(() => { - throw new Error("API Error") - }) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks = [] - - try { - for await (const chunk of stream) { - chunks.push(chunk) - } - - expect.fail("Expected error to be thrown") - } catch (error) { - expect(error).toBeInstanceOf(Error) - expect(error.message).toBe("API Error") - } - }) - }) - - describe("completePrompt", () => { - it("should complete prompt successfully", async () => { - const result = await handler.completePrompt("Test prompt") - expect(result).toBe("Test response") - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: "claude-3-5-sonnet-20241022", - messages: [{ role: "user", content: "Test prompt" }], - temperature: 0, - max_tokens: 8192, - }), - expect.objectContaining({ - headers: expect.objectContaining({ - "X-Unbound-Metadata": expect.stringContaining("roo-code"), - }), - }), - ) - }) - - it("should handle API errors", async () => { - mockCreate.mockRejectedValueOnce(new Error("API Error")) - await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Unbound completion error: API Error") - }) - - it("should handle empty response", async () => { - mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "" } }] }) - const result = await handler.completePrompt("Test prompt") - expect(result).toBe("") - }) - - it("should not set max_tokens for non-Anthropic models", async () => { - mockCreate.mockClear() - - const nonAnthropicHandler = new UnboundHandler({ - apiModelId: "openai/gpt-4o", - unboundApiKey: "test-key", - unboundModelId: "openai/gpt-4o", - }) - - await nonAnthropicHandler.completePrompt("Test prompt") - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: "gpt-4o", - messages: [{ role: "user", content: "Test prompt" }], - temperature: 0, - }), - expect.objectContaining({ - headers: expect.objectContaining({ - "X-Unbound-Metadata": expect.stringContaining("roo-code"), - }), - }), - ) - - expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("max_tokens") - }) - - it("should not set temperature for openai/o3-mini", async () => { - mockCreate.mockClear() - - const openaiHandler = new UnboundHandler({ - apiModelId: "openai/o3-mini", - unboundApiKey: "test-key", - unboundModelId: "openai/o3-mini", - }) - - await openaiHandler.completePrompt("Test prompt") - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: "o3-mini", - messages: [{ role: "user", content: "Test prompt" }], - }), - expect.objectContaining({ - headers: expect.objectContaining({ - "X-Unbound-Metadata": expect.stringContaining("roo-code"), - }), - }), - ) - - expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("temperature") - }) - }) - - describe("fetchModel", () => { - it("should return model info", async () => { - const modelInfo = await handler.fetchModel() - expect(modelInfo.id).toBe(mockOptions.unboundModelId) - expect(modelInfo.info).toBeDefined() - }) - - it("should return default model when invalid model provided", async () => { - const handlerWithInvalidModel = new UnboundHandler({ ...mockOptions, unboundModelId: "invalid/model" }) - const modelInfo = await handlerWithInvalidModel.fetchModel() - expect(modelInfo.id).toBe("anthropic/claude-sonnet-4-5") - expect(modelInfo.info).toBeDefined() - }) - }) - - describe("Native Tool Calling", () => { - const testTools = [ - { - type: "function" as const, - function: { - name: "test_tool", - description: "A test tool", - parameters: { - type: "object", - properties: { - arg1: { type: "string", description: "First argument" }, - }, - required: ["arg1"], - }, - }, - }, - ] - - it("should include tools in request when tools are provided", async () => { - mockWithResponse.mockResolvedValueOnce({ - data: { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - }, - }) - - const messageGenerator = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - }) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - type: "function", - function: expect.objectContaining({ - name: "test_tool", - }), - }), - ]), - parallel_tool_calls: true, - }), - expect.objectContaining({ - headers: { - "X-Unbound-Metadata": expect.stringContaining("roo-code"), - }, - }), - ) - }) - - it("should include tool_choice when provided", async () => { - mockWithResponse.mockResolvedValueOnce({ - data: { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - }, - }) - - const messageGenerator = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - tool_choice: "auto", - }) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tool_choice: "auto", - }), - expect.objectContaining({ - headers: { - "X-Unbound-Metadata": expect.stringContaining("roo-code"), - }, - }), - ) - }) - - it("should always include tools and tool_choice (tools are guaranteed to be present after ALWAYS_AVAILABLE_TOOLS)", async () => { - mockWithResponse.mockResolvedValueOnce({ - data: { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - }, - }) - - const messageGenerator = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - }) - await messageGenerator.next() - - // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS) - const callArgs = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0] - expect(callArgs).toHaveProperty("tools") - expect(callArgs).toHaveProperty("tool_choice") - expect(callArgs).toHaveProperty("parallel_tool_calls", true) - }) - - it("should yield tool_call_partial chunks during streaming", async () => { - mockWithResponse.mockResolvedValueOnce({ - data: { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_123", - function: { - name: "test_tool", - arguments: '{"arg1":', - }, - }, - ], - }, - }, - ], - }, - }) - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { - arguments: '"value"}', - }, - }, - ], - }, - }, - ], - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - }, - }) - - const stream = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - }) - - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - expect(chunks).toContainEqual({ - type: "tool_call_partial", - index: 0, - id: "call_123", - name: "test_tool", - arguments: '{"arg1":', - }) - - expect(chunks).toContainEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: '"value"}', - }) - }) - - it("should set parallel_tool_calls based on metadata", async () => { - mockWithResponse.mockResolvedValueOnce({ - data: { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - }, - }) - - const messageGenerator = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - parallelToolCalls: true, - }) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - parallel_tool_calls: true, - }), - expect.objectContaining({ - headers: { - "X-Unbound-Metadata": expect.stringContaining("roo-code"), - }, - }), - ) - }) - }) -}) diff --git a/src/api/providers/__tests__/vertex.spec.ts b/src/api/providers/__tests__/vertex.spec.ts index 1420b05c7a..cc90c144b2 100644 --- a/src/api/providers/__tests__/vertex.spec.ts +++ b/src/api/providers/__tests__/vertex.spec.ts @@ -3,6 +3,32 @@ // Mock vscode first to avoid import errors vitest.mock("vscode", () => ({})) +// Mock the createVertex function from @ai-sdk/google-vertex +const mockCreateVertex = vitest.fn() + +vitest.mock("@ai-sdk/google-vertex", () => ({ + createVertex: (...args: unknown[]) => { + mockCreateVertex(...args) + const provider = Object.assign((modelId: string) => ({ modelId }), { + tools: {}, + }) + return provider + }, +})) + +// Mock the AI SDK functions +const mockStreamText = vitest.fn() +const mockGenerateText = vitest.fn() + +vitest.mock("ai", async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + streamText: (...args: unknown[]) => mockStreamText(...args), + generateText: (...args: unknown[]) => mockGenerateText(...args), + } +}) + import { Anthropic } from "@anthropic-ai/sdk" import { ApiStreamChunk } from "../../transform/stream" @@ -14,25 +40,103 @@ describe("VertexHandler", () => { let handler: VertexHandler beforeEach(() => { - // Create mock functions - const mockGenerateContentStream = vitest.fn() - const mockGenerateContent = vitest.fn() - const mockGetGenerativeModel = vitest.fn() + mockStreamText.mockClear() + mockGenerateText.mockClear() + mockCreateVertex.mockClear() handler = new VertexHandler({ apiModelId: "gemini-1.5-pro-001", vertexProjectId: "test-project", vertexRegion: "us-central1", }) + }) - // Replace the client with our mock - handler["client"] = { - models: { - generateContentStream: mockGenerateContentStream, - generateContent: mockGenerateContent, - getGenerativeModel: mockGetGenerativeModel, - }, - } as any + describe("constructor", () => { + it("should create provider with project and location", () => { + new VertexHandler({ + apiModelId: "gemini-1.5-pro-001", + vertexProjectId: "my-project", + vertexRegion: "europe-west1", + }) + + expect(mockCreateVertex).toHaveBeenCalledWith( + expect.objectContaining({ + project: "my-project", + location: "europe-west1", + }), + ) + }) + + it("should create provider with JSON credentials", () => { + const credentials = { type: "service_account", project_id: "test" } + + new VertexHandler({ + apiModelId: "gemini-1.5-pro-001", + vertexProjectId: "my-project", + vertexRegion: "us-central1", + vertexJsonCredentials: JSON.stringify(credentials), + }) + + expect(mockCreateVertex).toHaveBeenCalledWith( + expect.objectContaining({ + project: "my-project", + location: "us-central1", + googleAuthOptions: { credentials }, + }), + ) + }) + + it("should create provider with key file", () => { + new VertexHandler({ + apiModelId: "gemini-1.5-pro-001", + vertexProjectId: "my-project", + vertexRegion: "us-central1", + vertexKeyFile: "/path/to/keyfile.json", + }) + + expect(mockCreateVertex).toHaveBeenCalledWith( + expect.objectContaining({ + project: "my-project", + location: "us-central1", + googleAuthOptions: { keyFile: "/path/to/keyfile.json" }, + }), + ) + }) + + it("should prefer JSON credentials over key file", () => { + const credentials = { type: "service_account", project_id: "test" } + + new VertexHandler({ + apiModelId: "gemini-1.5-pro-001", + vertexProjectId: "my-project", + vertexRegion: "us-central1", + vertexJsonCredentials: JSON.stringify(credentials), + vertexKeyFile: "/path/to/keyfile.json", + }) + + expect(mockCreateVertex).toHaveBeenCalledWith( + expect.objectContaining({ + googleAuthOptions: { credentials }, + }), + ) + }) + + it("should handle invalid JSON credentials gracefully", () => { + new VertexHandler({ + apiModelId: "gemini-1.5-pro-001", + vertexProjectId: "my-project", + vertexRegion: "us-central1", + vertexJsonCredentials: "invalid-json", + }) + + // Should not throw and should create provider without credentials + expect(mockCreateVertex).toHaveBeenCalledWith( + expect.objectContaining({ + project: "my-project", + googleAuthOptions: undefined, + }), + ) + }) }) describe("createMessage", () => { @@ -43,19 +147,11 @@ describe("VertexHandler", () => { const systemPrompt = "You are a helpful assistant" - it("should handle streaming responses correctly for Gemini", async () => { - // Let's examine the test expectations and adjust our mock accordingly - // The test expects 4 chunks: - // 1. Usage chunk with input tokens - // 2. Text chunk with "Gemini response part 1" - // 3. Text chunk with " part 2" - // 4. Usage chunk with output tokens - - // Let's modify our approach and directly mock the createMessage method - // instead of mocking the client + it("should handle streaming responses correctly", async () => { + // Mock the createMessage method to test the streaming behavior vitest.spyOn(handler, "createMessage").mockImplementation(async function* () { yield { type: "usage", inputTokens: 10, outputTokens: 0 } - yield { type: "text", text: "Gemini response part 1" } + yield { type: "text", text: "Vertex response part 1" } yield { type: "text", text: " part 2" } yield { type: "usage", inputTokens: 0, outputTokens: 5 } }) @@ -70,50 +166,69 @@ describe("VertexHandler", () => { expect(chunks.length).toBe(4) expect(chunks[0]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 0 }) - expect(chunks[1]).toEqual({ type: "text", text: "Gemini response part 1" }) + expect(chunks[1]).toEqual({ type: "text", text: "Vertex response part 1" }) expect(chunks[2]).toEqual({ type: "text", text: " part 2" }) expect(chunks[3]).toEqual({ type: "usage", inputTokens: 0, outputTokens: 5 }) + }) - // Since we're directly mocking createMessage, we don't need to verify - // that generateContentStream was called + it("should call streamText with correct options", async () => { + const mockFullStream = (async function* () { + yield { type: "text-delta", textDelta: "Hello" } + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks: ApiStreamChunk[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + system: systemPrompt, + temperature: 1, + }), + ) }) }) describe("completePrompt", () => { - it("should complete prompt successfully for Gemini", async () => { - // Mock the response with text property - ;(handler["client"].models.generateContent as any).mockResolvedValue({ - text: "Test Gemini response", + it("should complete prompt successfully", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test Vertex response", + providerMetadata: {}, }) const result = await handler.completePrompt("Test prompt") - expect(result).toBe("Test Gemini response") + expect(result).toBe("Test Vertex response") - // Verify the call to generateContent - expect(handler["client"].models.generateContent).toHaveBeenCalledWith( + // Verify generateText was called with the prompt + expect(mockGenerateText).toHaveBeenCalledWith( expect.objectContaining({ - model: expect.any(String), - contents: [{ role: "user", parts: [{ text: "Test prompt" }] }], - config: expect.objectContaining({ - temperature: 1, - }), + prompt: "Test prompt", + temperature: 1, }), ) }) - it("should handle API errors for Gemini", async () => { + it("should handle API errors", async () => { const mockError = new Error("Vertex API error") - ;(handler["client"].models.generateContent as any).mockRejectedValue(mockError) + mockGenerateText.mockRejectedValue(mockError) await expect(handler.completePrompt("Test prompt")).rejects.toThrow( t("common:errors.gemini.generate_complete_prompt", { error: "Vertex API error" }), ) }) - it("should handle empty response for Gemini", async () => { - // Mock the response with empty text - ;(handler["client"].models.generateContent as any).mockResolvedValue({ + it("should handle empty response", async () => { + mockGenerateText.mockResolvedValue({ text: "", + providerMetadata: {}, }) const result = await handler.completePrompt("Test prompt") @@ -122,7 +237,7 @@ describe("VertexHandler", () => { }) describe("getModel", () => { - it("should return correct model info for Gemini", () => { + it("should return correct model info", () => { // Create a new instance with specific model ID const testHandler = new VertexHandler({ apiModelId: "gemini-2.0-flash-001", @@ -130,12 +245,135 @@ describe("VertexHandler", () => { vertexRegion: "us-central1", }) - // Don't mock getModel here as we want to test the actual implementation const modelInfo = testHandler.getModel() expect(modelInfo.id).toBe("gemini-2.0-flash-001") expect(modelInfo.info).toBeDefined() expect(modelInfo.info.maxTokens).toBe(8192) expect(modelInfo.info.contextWindow).toBe(1048576) }) + + it("should return default model when invalid ID provided", () => { + const testHandler = new VertexHandler({ + apiModelId: "invalid-model-id", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const modelInfo = testHandler.getModel() + // Should fall back to default model + expect(modelInfo.info).toBeDefined() + }) + + it("should strip :thinking suffix from model ID", () => { + const testHandler = new VertexHandler({ + apiModelId: "gemini-2.5-flash-preview-05-20:thinking", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const modelInfo = testHandler.getModel() + expect(modelInfo.id).toBe("gemini-2.5-flash-preview-05-20") + }) + }) + + describe("calculateCost", () => { + it("should calculate cost correctly", () => { + const result = handler.calculateCost({ + info: { + maxTokens: 8192, + contextWindow: 1048576, + supportsPromptCache: false, + inputPrice: 1.25, + outputPrice: 5.0, + }, + inputTokens: 1000, + outputTokens: 500, + }) + + // Input: 1.25 * (1000 / 1_000_000) = 0.00125 + // Output: 5.0 * (500 / 1_000_000) = 0.0025 + // Total: 0.00375 + expect(result).toBeCloseTo(0.00375, 5) + }) + + it("should handle cache read tokens", () => { + const result = handler.calculateCost({ + info: { + maxTokens: 8192, + contextWindow: 1048576, + supportsPromptCache: true, + inputPrice: 1.25, + outputPrice: 5.0, + cacheReadsPrice: 0.3125, + }, + inputTokens: 1000, + outputTokens: 500, + cacheReadTokens: 400, + }) + + // Uncached input: 600 tokens at 1.25/M = 0.00075 + // Cache read: 400 tokens at 0.3125/M = 0.000125 + // Output: 500 tokens at 5.0/M = 0.0025 + // Total: 0.003375 + expect(result).toBeCloseTo(0.003375, 5) + }) + + it("should handle reasoning tokens", () => { + const result = handler.calculateCost({ + info: { + maxTokens: 8192, + contextWindow: 1048576, + supportsPromptCache: false, + inputPrice: 1.25, + outputPrice: 5.0, + }, + inputTokens: 1000, + outputTokens: 500, + reasoningTokens: 200, + }) + + // Input: 1.25 * (1000 / 1_000_000) = 0.00125 + // Output + Reasoning: 5.0 * (700 / 1_000_000) = 0.0035 + // Total: 0.00475 + expect(result).toBeCloseTo(0.00475, 5) + }) + + it("should return undefined when prices are missing", () => { + const result = handler.calculateCost({ + info: { + maxTokens: 8192, + contextWindow: 1048576, + supportsPromptCache: false, + }, + inputTokens: 1000, + outputTokens: 500, + }) + + expect(result).toBeUndefined() + }) + + it("should use tiered pricing when available", () => { + const result = handler.calculateCost({ + info: { + maxTokens: 8192, + contextWindow: 1048576, + supportsPromptCache: false, + inputPrice: 1.25, + outputPrice: 5.0, + tiers: [ + { contextWindow: 128000, inputPrice: 0.5, outputPrice: 2.0 }, + { contextWindow: 1048576, inputPrice: 1.0, outputPrice: 4.0 }, + ], + }, + inputTokens: 50000, // Falls into first tier + outputTokens: 500, + }) + + // Uses tier 1 pricing: inputPrice=0.5, outputPrice=2.0 + // Input: 0.5 * (50000 / 1_000_000) = 0.025 + // Output: 2.0 * (500 / 1_000_000) = 0.001 + // Total: 0.026 + expect(result).toBeCloseTo(0.026, 5) + }) }) }) diff --git a/src/api/providers/__tests__/xai.spec.ts b/src/api/providers/__tests__/xai.spec.ts index c622c9d4fc..27e0a25f5c 100644 --- a/src/api/providers/__tests__/xai.spec.ts +++ b/src/api/providers/__tests__/xai.spec.ts @@ -1,587 +1,731 @@ -// npx vitest api/providers/__tests__/xai.spec.ts +// npx vitest run api/providers/__tests__/xai.spec.ts -// Mock TelemetryService - must come before other imports -const mockCaptureException = vitest.hoisted(() => vitest.fn()) -vitest.mock("@roo-code/telemetry", () => ({ - TelemetryService: { - instance: { - captureException: mockCaptureException, - }, - }, +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), })) -const mockCreate = vitest.fn() - -vitest.mock("openai", () => { - const mockConstructor = vitest.fn() - +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - __esModule: true, - default: mockConstructor.mockImplementation(() => ({ chat: { completions: { create: mockCreate } } })), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) -import OpenAI from "openai" +vi.mock("@ai-sdk/xai", () => ({ + createXai: vi.fn(() => { + // Return a function that returns a mock language model + return vi.fn(() => ({ + modelId: "grok-code-fast-1", + provider: "xai", + })) + }), +})) + import type { Anthropic } from "@anthropic-ai/sdk" -import { xaiDefaultModelId, xaiModels } from "@roo-code/types" +import { xaiDefaultModelId, xaiModels, type XAIModelId } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../../shared/api" import { XAIHandler } from "../xai" describe("XAIHandler", () => { let handler: XAIHandler + let mockOptions: ApiHandlerOptions beforeEach(() => { - // Reset all mocks + mockOptions = { + xaiApiKey: "test-xai-api-key", + apiModelId: "grok-code-fast-1", + } + handler = new XAIHandler(mockOptions) vi.clearAllMocks() - mockCreate.mockClear() - mockCaptureException.mockClear() - - // Create handler with mock - handler = new XAIHandler({}) }) - it("should use the correct X.AI base URL", () => { - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: "https://api.x.ai/v1", - }), - ) - }) - - it("should use the provided API key", () => { - // Clear mocks before this specific test - vi.clearAllMocks() - - // Create a handler with our API key - const xaiApiKey = "test-api-key" - new XAIHandler({ xaiApiKey }) - - // Verify the OpenAI constructor was called with our API key - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ - apiKey: xaiApiKey, - }), - ) - }) - - it("should return default model when no model is specified", () => { - const model = handler.getModel() - expect(model.id).toBe(xaiDefaultModelId) - expect(model.info).toEqual(xaiModels[xaiDefaultModelId]) - }) - - test("should return specified model when valid model is provided", () => { - const testModelId = "grok-3" - const handlerWithModel = new XAIHandler({ apiModelId: testModelId }) - const model = handlerWithModel.getModel() - - expect(model.id).toBe(testModelId) - expect(model.info).toEqual(xaiModels[testModelId]) - }) - - it("should include reasoning_effort parameter for mini models", async () => { - const miniModelHandler = new XAIHandler({ - apiModelId: "grok-3-mini", - reasoningEffort: "high", + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(XAIHandler) + expect(handler.getModel().id).toBe(mockOptions.apiModelId) }) - // Setup mock for streaming response - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, + it("should use default model ID if not provided", () => { + const handlerWithoutModel = new XAIHandler({ + ...mockOptions, + apiModelId: undefined, + }) + expect(handlerWithoutModel.getModel().id).toBe(xaiDefaultModelId) + }) + }) + + describe("getModel", () => { + it("should return default model when no model is specified", () => { + const handlerWithoutModel = new XAIHandler({ + xaiApiKey: "test-xai-api-key", + }) + const model = handlerWithoutModel.getModel() + expect(model.id).toBe(xaiDefaultModelId) + expect(model.info).toEqual(xaiModels[xaiDefaultModelId]) + }) + + it("should return specified model when valid model is provided", () => { + const testModelId: XAIModelId = "grok-3" + const handlerWithModel = new XAIHandler({ + apiModelId: testModelId, + xaiApiKey: "test-xai-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual(xaiModels[testModelId]) + }) + + it("should return grok-3-mini model with correct configuration", () => { + const testModelId: XAIModelId = "grok-3-mini" + const handlerWithModel = new XAIHandler({ + apiModelId: testModelId, + xaiApiKey: "test-xai-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 8192, + contextWindow: 131072, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 0.3, + outputPrice: 0.5, }), - } + ) }) - // Start generating a message - const messageGenerator = miniModelHandler.createMessage("test prompt", []) - await messageGenerator.next() // Start the generator - - // Check that reasoning_effort was included - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - reasoning_effort: "high", - }), - ) - }) - - it("should not include reasoning_effort parameter for non-mini models", async () => { - const regularModelHandler = new XAIHandler({ - apiModelId: "grok-3", - reasoningEffort: "high", - }) - - // Setup mock for streaming response - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, + it("should return grok-4-0709 model with correct configuration", () => { + const testModelId: XAIModelId = "grok-4-0709" + const handlerWithModel = new XAIHandler({ + apiModelId: testModelId, + xaiApiKey: "test-xai-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 8192, + contextWindow: 256_000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 3.0, + outputPrice: 15.0, }), - } + ) }) - // Start generating a message - const messageGenerator = regularModelHandler.createMessage("test prompt", []) - await messageGenerator.next() // Start the generator - - // Check call args for reasoning_effort - const calls = mockCreate.mock.calls - const lastCall = calls[calls.length - 1][0] - expect(lastCall).not.toHaveProperty("reasoning_effort") - }) - - it("completePrompt method should return text from OpenAI API", async () => { - const expectedResponse = "This is a test response" - mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] }) - - const result = await handler.completePrompt("test prompt") - expect(result).toBe(expectedResponse) - }) - - it("should handle errors in completePrompt", async () => { - const errorMessage = "API error" - mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - - await expect(handler.completePrompt("test prompt")).rejects.toThrow(`xAI completion error: ${errorMessage}`) - }) - - it("createMessage should yield text content from stream", async () => { - const testContent = "This is test content" - - // Setup mock for streaming response - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [{ delta: { content: testContent } }], - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } + it("should fall back to default model for invalid model ID", () => { + const handlerWithInvalidModel = new XAIHandler({ + ...mockOptions, + apiModelId: "invalid-model", + }) + const model = handlerWithInvalidModel.getModel() + expect(model.id).toBe(xaiDefaultModelId) + expect(model.info).toBe(xaiModels[xaiDefaultModelId]) }) - // Create and consume the stream - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - // Verify the content - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ - type: "text", - text: testContent, + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") }) }) - it("createMessage should yield reasoning content from stream", async () => { - const testReasoning = "Test reasoning content" - - // Setup mock for streaming response - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [{ delta: { reasoning_content: testReasoning } }], - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - // Create and consume the stream - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - // Verify the reasoning content - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ - type: "reasoning", - text: testReasoning, - }) - }) - - it("createMessage should yield usage data from stream", async () => { - // Setup mock for streaming response that includes usage data - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [{ delta: {} }], // Needs to have choices array to avoid error - usage: { - prompt_tokens: 10, - completion_tokens: 20, - cache_read_input_tokens: 5, - cache_creation_input_tokens: 15, - }, - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - // Create and consume the stream - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - // Verify the usage data - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ - type: "usage", - inputTokens: 10, - outputTokens: 20, - cacheReadTokens: 5, - cacheWriteTokens: 15, - }) - }) - - it("createMessage should pass correct parameters to OpenAI client", async () => { - // Setup a handler with specific model - const modelId = "grok-3" - const modelInfo = xaiModels[modelId] - const handlerWithModel = new XAIHandler({ apiModelId: modelId }) - - // Setup mock for streaming response - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } - }) - - // System prompt and messages - const systemPrompt = "Test system prompt" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message" }] - - // Start generating a message - const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages) - await messageGenerator.next() // Start the generator - - // Check that all parameters were passed correctly - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: modelId, - max_tokens: modelInfo.maxTokens, - temperature: 0, - messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), - stream: true, - stream_options: { include_usage: true }, - }), - ) - }) - - describe("Native Tool Calling", () => { - const testTools = [ + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ { - type: "function" as const, - function: { - name: "test_tool", - description: "A test tool", - parameters: { - type: "object", - properties: { - arg1: { type: "string", description: "First argument" }, - }, - required: ["arg1"], + role: "user", + content: [ + { + type: "text" as const, + text: "Hello!", }, - }, + ], }, ] - it("should include tools in request when model supports native tools and tools are provided (native is default)", async () => { - const handlerWithTools = new XAIHandler({ apiModelId: "grok-3" }) + it("should handle streaming responses", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response from xAI" } + } - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, }) - const messageGenerator = handlerWithTools.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - }) - await messageGenerator.next() + const mockProviderMetadata = Promise.resolve({}) - expect(mockCreate).toHaveBeenCalledWith( + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response from xAI") + }) + + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 20, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(20) + }) + + it("should handle cached tokens in usage data from providerMetadata", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + }) + + // xAI provides cache metrics via providerMetadata for supported models + const mockProviderMetadata = Promise.resolve({ + xai: { + cachedPromptTokens: 30, + }, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(100) + expect(usageChunks[0].outputTokens).toBe(50) + expect(usageChunks[0].cacheReadTokens).toBe(30) + }) + + it("should handle usage with details.cachedInputTokens when providerMetadata is not available", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + details: { + cachedInputTokens: 25, + }, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].cacheReadTokens).toBe(25) + expect(usageChunks[0].cacheWriteTokens).toBeUndefined() + }) + + it("should pass correct temperature (0 default) to streamText", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const handlerWithDefaultTemp = new XAIHandler({ + xaiApiKey: "test-key", + apiModelId: "grok-code-fast-1", + }) + + const stream = handlerWithDefaultTemp.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - type: "function", - function: expect.objectContaining({ - name: "test_tool", - }), - }), - ]), - parallel_tool_calls: true, + temperature: 0, }), ) }) - it("should include tool_choice when provided", async () => { - const handlerWithTools = new XAIHandler({ apiModelId: "grok-3" }) + it("should use user-specified temperature over default", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), }) - const messageGenerator = handlerWithTools.createMessage("test prompt", [], { - taskId: "test-task-id", + const handlerWithCustomTemp = new XAIHandler({ + xaiApiKey: "test-key", + apiModelId: "grok-3", + modelTemperature: 0.7, + }) + + const stream = handlerWithCustomTemp.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume stream + } + + // User-specified temperature should take precedence over everything + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + }), + ) + }) + + it("should handle stream with multiple chunks", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Hello" } + yield { type: "text-delta", text: " world" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 10 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks[0]).toEqual({ type: "text", text: "Hello" }) + expect(textChunks[1]).toEqual({ type: "text", text: " world" }) + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks[0]).toMatchObject({ type: "usage", inputTokens: 5, outputTokens: 10 }) + }) + + it("should handle reasoning content from stream", async () => { + async function* mockFullStream() { + yield { type: "reasoning-delta", text: "Let me think about this..." } + yield { type: "text-delta", text: "Here is my answer" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const reasoningChunks = chunks.filter((c) => c.type === "reasoning") + expect(reasoningChunks).toHaveLength(1) + expect(reasoningChunks[0].text).toBe("Let me think about this...") + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Here is my answer") + }) + + it("should handle errors during streaming", async () => { + const mockError = new Error("API error") + ;(mockError as any).name = "AI_APICallError" + ;(mockError as any).status = 500 + + async function* mockFullStream(): AsyncGenerator { + // This yield is unreachable but needed to satisfy the require-yield lint rule + yield undefined as never + throw mockError + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, messages) + + await expect(async () => { + for await (const _ of stream) { + // consume stream + } + }).rejects.toThrow("xAI") + }) + }) + + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion from xAI", + }) + + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("Test completion from xAI") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) + }) + + it("should use default temperature in completePrompt", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", + }) + + await handler.completePrompt("Test prompt") + + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0, + }), + ) + }) + + it("should handle errors in completePrompt", async () => { + const mockError = new Error("API error") + ;(mockError as any).name = "AI_APICallError" + mockGenerateText.mockRejectedValue(mockError) + + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("xAI") + }) + }) + + describe("processUsageMetrics", () => { + it("should correctly process usage metrics including cache information from providerMetadata", () => { + class TestXAIHandler extends XAIHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestXAIHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + } + + const providerMetadata = { + xai: { + cachedPromptTokens: 20, + }, + } + + const result = testHandler.testProcessUsageMetrics(usage, providerMetadata) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheReadTokens).toBe(20) + // xAI doesn't report cache write tokens separately + expect(result.cacheWriteTokens).toBeUndefined() + }) + + it("should handle missing cache metrics gracefully", () => { + class TestXAIHandler extends XAIHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestXAIHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + } + + const result = testHandler.testProcessUsageMetrics(usage) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBeUndefined() + expect(result.cacheReadTokens).toBeUndefined() + }) + + it("should include reasoning tokens when provided", () => { + class TestXAIHandler extends XAIHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestXAIHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + details: { + reasoningTokens: 30, + }, + } + + const result = testHandler.testProcessUsageMetrics(usage) + + expect(result.reasoningTokens).toBe(30) + }) + }) + + describe("tool handling", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle tool calls in streaming", async () => { + async function* mockFullStream() { + yield { + type: "tool-input-start", + id: "tool-call-1", + toolName: "read_file", + } + yield { + type: "tool-input-delta", + id: "tool-call-1", + delta: '{"path":"test.ts"}', + } + yield { + type: "tool-input-end", + id: "tool-call-1", + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + ], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start") + const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end") + + expect(toolCallStartChunks.length).toBe(1) + expect(toolCallStartChunks[0].id).toBe("tool-call-1") + expect(toolCallStartChunks[0].name).toBe("read_file") + + expect(toolCallDeltaChunks.length).toBe(1) + expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}') + + expect(toolCallEndChunks.length).toBe(1) + expect(toolCallEndChunks[0].id).toBe("tool-call-1") + }) + + it("should ignore tool-call events to prevent duplicate tools in UI", async () => { + async function* mockFullStream() { + yield { + type: "tool-call", + toolCallId: "tool-call-1", + toolName: "read_file", + input: { path: "test.ts" }, + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // tool-call events should be ignored (only tool-input-start/delta/end are processed) + const toolCallChunks = chunks.filter( + (c) => c.type === "tool_call_start" || c.type === "tool_call_delta" || c.type === "tool_call_end", + ) + expect(toolCallChunks.length).toBe(0) + }) + + it("should pass tools to streamText when provided", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const testTools = [ + { + type: "function" as const, + function: { + name: "test_tool", + description: "A test tool", + parameters: { + type: "object", + properties: { + arg1: { type: "string", description: "First argument" }, + }, + required: ["arg1"], + }, + }, + }, + ] + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", tools: testTools, tool_choice: "auto", }) - await messageGenerator.next() - expect(mockCreate).toHaveBeenCalledWith( + for await (const _ of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - tool_choice: "auto", + tools: expect.any(Object), + toolChoice: "auto", }), ) }) + }) - it("should always include tools and tool_choice (tools are guaranteed to be present after ALWAYS_AVAILABLE_TOOLS)", async () => { - const handlerWithTools = new XAIHandler({ apiModelId: "grok-3" }) - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } - }) - - const messageGenerator = handlerWithTools.createMessage("test prompt", [], { - taskId: "test-task-id", - }) - await messageGenerator.next() - - // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS) - const callArgs = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0] - expect(callArgs).toHaveProperty("tools") - expect(callArgs).toHaveProperty("tool_choice") - expect(callArgs).toHaveProperty("parallel_tool_calls", true) - }) - - it("should yield tool_call_partial chunks during streaming", async () => { - const handlerWithTools = new XAIHandler({ apiModelId: "grok-3" }) - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_123", - function: { - name: "test_tool", - arguments: '{"arg1":', - }, - }, - ], - }, - }, - ], - }, - }) - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { - arguments: '"value"}', - }, - }, - ], - }, - }, - ], - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handlerWithTools.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - }) - - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) + describe("reasoning effort (mini models)", () => { + it("should include reasoning effort for grok-3-mini model", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } } - expect(chunks).toContainEqual({ - type: "tool_call_partial", - index: 0, - id: "call_123", - name: "test_tool", - arguments: '{"arg1":', + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), }) - expect(chunks).toContainEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: '"value"}', - }) - }) - - it("should set parallel_tool_calls based on metadata", async () => { - const handlerWithTools = new XAIHandler({ apiModelId: "grok-3" }) - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } + const miniModelHandler = new XAIHandler({ + xaiApiKey: "test-key", + apiModelId: "grok-3-mini", + reasoningEffort: "high", }) - const messageGenerator = handlerWithTools.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - parallelToolCalls: true, - }) - await messageGenerator.next() + const stream = miniModelHandler.createMessage("test prompt", []) + for await (const _ of stream) { + // consume stream + } - expect(mockCreate).toHaveBeenCalledWith( + // Check that provider options are passed for reasoning + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - parallel_tool_calls: true, + providerOptions: expect.any(Object), }), ) }) - - it("should yield tool_call_end events when finish_reason is tool_calls", async () => { - // Import NativeToolCallParser to set up state - const { NativeToolCallParser } = await import("../../../core/assistant-message/NativeToolCallParser") - - // Clear any previous state - NativeToolCallParser.clearRawChunkState() - - const handlerWithTools = new XAIHandler({ apiModelId: "grok-3" }) - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_xai_test", - function: { - name: "test_tool", - arguments: '{"arg1":"value"}', - }, - }, - ], - }, - }, - ], - }, - }) - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: {}, - finish_reason: "tool_calls", - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handlerWithTools.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - }) - - const chunks = [] - for await (const chunk of stream) { - // Simulate what Task.ts does: when we receive tool_call_partial, - // process it through NativeToolCallParser to populate rawChunkTracker - if (chunk.type === "tool_call_partial") { - NativeToolCallParser.processRawChunk({ - index: chunk.index, - id: chunk.id, - name: chunk.name, - arguments: chunk.arguments, - }) - } - chunks.push(chunk) - } - - // Should have tool_call_partial and tool_call_end - const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") - const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") - - expect(partialChunks).toHaveLength(1) - expect(endChunks).toHaveLength(1) - expect(endChunks[0].id).toBe("call_xai_test") - }) }) }) diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index 34323b108d..af3154e778 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -1,7 +1,30 @@ // npx vitest run src/api/providers/__tests__/zai.spec.ts -import OpenAI from "openai" -import { Anthropic } from "@anthropic-ai/sdk" +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) + +vi.mock("zhipu-ai-provider", () => ({ + createZhipu: vi.fn(() => { + return vi.fn(() => ({ + modelId: "glm-4.6", + provider: "zhipu", + })) + }), +})) + +import type { Anthropic } from "@anthropic-ai/sdk" import { type InternationalZAiModelId, @@ -13,22 +36,36 @@ import { ZAI_DEFAULT_TEMPERATURE, } from "@roo-code/types" -import { ZAiHandler } from "../zai" +import type { ApiHandlerOptions } from "../../../shared/api" -vitest.mock("openai", () => { - const createMock = vitest.fn() - return { - default: vitest.fn(() => ({ chat: { completions: { create: createMock } } })), - } -}) +import { ZAiHandler } from "../zai" describe("ZAiHandler", () => { let handler: ZAiHandler - let mockCreate: any + let mockOptions: ApiHandlerOptions beforeEach(() => { - vitest.clearAllMocks() - mockCreate = (OpenAI as unknown as any)().chat.completions.create + mockOptions = { + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + apiModelId: "glm-4.6", + } + handler = new ZAiHandler(mockOptions) + vi.clearAllMocks() + }) + + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(ZAiHandler) + expect(handler.getModel().id).toBe(mockOptions.apiModelId) + }) + + it("should default to international when no zaiApiLine is specified", () => { + const handlerDefault = new ZAiHandler({ zaiApiKey: "test-zai-api-key" }) + const model = handlerDefault.getModel() + expect(model.id).toBe(internationalZAiDefaultModelId) + expect(model.info).toEqual(internationalZAiModels[internationalZAiDefaultModelId]) + }) }) describe("International Z AI", () => { @@ -36,21 +73,6 @@ describe("ZAiHandler", () => { handler = new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "international_coding" }) }) - it("should use the correct international Z AI base URL", () => { - new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "international_coding" }) - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: "https://api.z.ai/api/coding/paas/v4", - }), - ) - }) - - it("should use the provided API key for international", () => { - const zaiApiKey = "test-zai-api-key" - new ZAiHandler({ zaiApiKey, zaiApiLine: "international_coding" }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: zaiApiKey })) - }) - it("should return international default model when no model is specified", () => { const model = handler.getModel() expect(model.id).toBe(internationalZAiDefaultModelId) @@ -119,19 +141,6 @@ describe("ZAiHandler", () => { handler = new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "china_coding" }) }) - it("should use the correct China Z AI base URL", () => { - new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "china_coding" }) - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ baseURL: "https://open.bigmodel.cn/api/coding/paas/v4" }), - ) - }) - - it("should use the provided API key for China", () => { - const zaiApiKey = "test-zai-api-key" - new ZAiHandler({ zaiApiKey, zaiApiLine: "china_coding" }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: zaiApiKey })) - }) - it("should return China default model when no model is specified", () => { const model = handler.getModel() expect(model.id).toBe(mainlandZAiDefaultModelId) @@ -200,21 +209,6 @@ describe("ZAiHandler", () => { handler = new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "international_api" }) }) - it("should use the correct international API base URL", () => { - new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "international_api" }) - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: "https://api.z.ai/api/paas/v4", - }), - ) - }) - - it("should use the provided API key for international API", () => { - const zaiApiKey = "test-zai-api-key" - new ZAiHandler({ zaiApiKey, zaiApiLine: "international_api" }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: zaiApiKey })) - }) - it("should return international default model when no model is specified", () => { const model = handler.getModel() expect(model.id).toBe(internationalZAiDefaultModelId) @@ -239,21 +233,6 @@ describe("ZAiHandler", () => { handler = new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "china_api" }) }) - it("should use the correct China API base URL", () => { - new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "china_api" }) - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: "https://open.bigmodel.cn/api/paas/v4", - }), - ) - }) - - it("should use the provided API key for China API", () => { - const zaiApiKey = "test-zai-api-key" - new ZAiHandler({ zaiApiKey, zaiApiLine: "china_api" }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: zaiApiKey })) - }) - it("should return China default model when no model is specified", () => { const model = handler.getModel() expect(model.id).toBe(mainlandZAiDefaultModelId) @@ -273,133 +252,98 @@ describe("ZAiHandler", () => { }) }) - describe("Default behavior", () => { - it("should default to international when no zaiApiLine is specified", () => { - const handlerDefault = new ZAiHandler({ zaiApiKey: "test-zai-api-key" }) - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: "https://api.z.ai/api/coding/paas/v4", - }), - ) - - const model = handlerDefault.getModel() - expect(model.id).toBe(internationalZAiDefaultModelId) - expect(model.info).toEqual(internationalZAiModels[internationalZAiDefaultModelId]) - }) - - it("should use 'not-provided' as default API key when none is specified", () => { - new ZAiHandler({ zaiApiLine: "international_coding" }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "not-provided" })) + describe("getModel", () => { + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") }) }) - describe("API Methods", () => { - beforeEach(() => { - handler = new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "international_coding" }) - }) + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] - it("completePrompt method should return text from Z AI API", async () => { - const expectedResponse = "This is a test response from Z AI" - mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] }) - const result = await handler.completePrompt("test prompt") - expect(result).toBe(expectedResponse) - }) + it("should handle streaming responses", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response from Z.ai" } + } - it("should handle errors in completePrompt", async () => { - const errorMessage = "Z AI API error" - mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - `Z.ai completion error: ${errorMessage}`, - ) - }) - - it("createMessage should yield text content from stream", async () => { - const testContent = "This is test content from Z AI stream" - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vitest - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: testContent } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response from Z.ai") }) - it("createMessage should yield usage data from stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vitest - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [{ delta: {} }], - usage: { prompt_tokens: 10, completion_tokens: 20 }, - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 20, }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 }) + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(20) }) - it("createMessage should pass correct parameters to Z AI client", async () => { - const modelId: InternationalZAiModelId = "glm-4.5" - const modelInfo = internationalZAiModels[modelId] - const handlerWithModel = new ZAiHandler({ - apiModelId: modelId, - zaiApiKey: "test-zai-api-key", - zaiApiLine: "international_coding", + it("should pass correct parameters to streamText", async () => { + async function* mockFullStream() { + // empty stream + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } - }) + const stream = handler.createMessage(systemPrompt, messages) + // Consume the stream + for await (const _chunk of stream) { + // drain + } - const systemPrompt = "Test system prompt for Z AI" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for Z AI" }] - - const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages) - await messageGenerator.next() - - // Centralized 20% cap should apply to OpenAI-compatible providers like Z AI - const expectedMaxTokens = Math.min(modelInfo.maxTokens, Math.ceil(modelInfo.contextWindow * 0.2)) - - expect(mockCreate).toHaveBeenCalledWith( + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - model: modelId, - max_tokens: expectedMaxTokens, - temperature: ZAI_DEFAULT_TEMPERATURE, - messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), - stream: true, - stream_options: { include_usage: true }, + system: systemPrompt, + temperature: expect.any(Number), }), - undefined, ) }) }) @@ -410,27 +354,29 @@ describe("ZAiHandler", () => { apiModelId: "glm-4.7", zaiApiKey: "test-zai-api-key", zaiApiLine: "international_coding", - // No reasoningEffort setting - should use model default (medium) }) - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) - const messageGenerator = handlerWithModel.createMessage("system prompt", []) - await messageGenerator.next() + const stream = handlerWithModel.createMessage("system prompt", []) + for await (const _chunk of stream) { + // drain + } - // For GLM-4.7 with default reasoning (medium), thinking should be enabled - expect(mockCreate).toHaveBeenCalledWith( + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - model: "glm-4.7", - thinking: { type: "enabled" }, + providerOptions: { + zhipu: { + thinking: { type: "enabled" }, + }, + }, }), ) }) @@ -444,24 +390,27 @@ describe("ZAiHandler", () => { reasoningEffort: "disable", }) - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) - const messageGenerator = handlerWithModel.createMessage("system prompt", []) - await messageGenerator.next() + const stream = handlerWithModel.createMessage("system prompt", []) + for await (const _chunk of stream) { + // drain + } - // For GLM-4.7 with reasoning disabled, thinking should be disabled - expect(mockCreate).toHaveBeenCalledWith( + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - model: "glm-4.7", - thinking: { type: "disabled" }, + providerOptions: { + zhipu: { + thinking: { type: "disabled" }, + }, + }, }), ) }) @@ -475,51 +424,109 @@ describe("ZAiHandler", () => { reasoningEffort: "medium", }) - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) - const messageGenerator = handlerWithModel.createMessage("system prompt", []) - await messageGenerator.next() + const stream = handlerWithModel.createMessage("system prompt", []) + for await (const _chunk of stream) { + // drain + } - // For GLM-4.7 with reasoning set to medium, thinking should be enabled - expect(mockCreate).toHaveBeenCalledWith( + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - model: "glm-4.7", - thinking: { type: "enabled" }, + providerOptions: { + zhipu: { + thinking: { type: "enabled" }, + }, + }, }), ) }) - it("should NOT add thinking parameter for non-thinking models like GLM-4.6", async () => { + it("should NOT add providerOptions for non-thinking models like GLM-4.6", async () => { const handlerWithModel = new ZAiHandler({ apiModelId: "glm-4.6", zaiApiKey: "test-zai-api-key", zaiApiLine: "international_coding", }) - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) - const messageGenerator = handlerWithModel.createMessage("system prompt", []) - await messageGenerator.next() + const stream = handlerWithModel.createMessage("system prompt", []) + for await (const _chunk of stream) { + // drain + } - // For GLM-4.6 (no thinking support), thinking parameter should not be present - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs.thinking).toBeUndefined() + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions).toBeUndefined() + }) + + it("should handle reasoning content in streaming responses", async () => { + const handlerWithModel = new ZAiHandler({ + apiModelId: "glm-4.7", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + }) + + async function* mockFullStream() { + yield { type: "reasoning", text: "Let me think about this..." } + yield { type: "text-delta", text: "Here is my answer" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }), + }) + + const stream = handlerWithModel.createMessage("system prompt", []) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") + expect(reasoningChunks).toHaveLength(1) + expect(reasoningChunks[0].text).toBe("Let me think about this...") + + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Here is my answer") + }) + }) + + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion from Z.ai", + }) + + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("Test completion from Z.ai") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) + }) + }) + + describe("isAiSdkProvider", () => { + it("should return true", () => { + expect(handler.isAiSdkProvider()).toBe(true) }) }) }) diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index 63daf8a3aa..685c8628b0 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -1,6 +1,6 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import { AnthropicVertex } from "@anthropic-ai/vertex-sdk" -import { GoogleAuth, JWTInput } from "google-auth-library" +import type { Anthropic } from "@anthropic-ai/sdk" +import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic" +import { streamText, generateText, ToolSet } from "ai" import { type ModelInfo, @@ -9,58 +9,78 @@ import { vertexModels, ANTHROPIC_DEFAULT_MAX_TOKENS, VERTEX_1M_CONTEXT_MODEL_IDS, + ApiProviderError, } from "@roo-code/types" -import { safeJsonParse } from "@roo-code/core" +import { TelemetryService } from "@roo-code/telemetry" -import { ApiHandlerOptions } from "../../shared/api" +import type { ApiHandlerOptions } from "../../shared/api" +import { shouldUseReasoningBudget } from "../../shared/api" -import { ApiStream } from "../transform/stream" -import { addCacheBreakpoints } from "../transform/caching/vertex" +import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" -import { filterNonAnthropicBlocks } from "../transform/anthropic-filter" import { - convertOpenAIToolsToAnthropic, - convertOpenAIToolChoiceToAnthropic, -} from "../../core/prompts/tools/native-tools/converters" + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" +import { calculateApiCostAnthropic } from "../../shared/cost" +import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" // https://docs.anthropic.com/en/api/claude-on-vertex-ai export class AnthropicVertexHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - private client: AnthropicVertex + private provider: ReturnType + private readonly providerName = "Vertex (Anthropic)" + private lastThoughtSignature: string | undefined + private lastRedactedThinkingBlocks: Array<{ type: "redacted_thinking"; data: string }> = [] constructor(options: ApiHandlerOptions) { super() - this.options = options // https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions const projectId = this.options.vertexProjectId ?? "not-provided" const region = this.options.vertexRegion ?? "us-east5" - if (this.options.vertexJsonCredentials) { - this.client = new AnthropicVertex({ - projectId, - region, - googleAuth: new GoogleAuth({ - scopes: ["https://www.googleapis.com/auth/cloud-platform"], - credentials: safeJsonParse(this.options.vertexJsonCredentials, undefined), - }), - }) - } else if (this.options.vertexKeyFile) { - this.client = new AnthropicVertex({ - projectId, - region, - googleAuth: new GoogleAuth({ - scopes: ["https://www.googleapis.com/auth/cloud-platform"], - keyFile: this.options.vertexKeyFile, - }), - }) - } else { - this.client = new AnthropicVertex({ projectId, region }) + // Build googleAuthOptions based on provided credentials + let googleAuthOptions: { credentials?: object; keyFile?: string } | undefined + if (options.vertexJsonCredentials) { + try { + googleAuthOptions = { credentials: JSON.parse(options.vertexJsonCredentials) } + } catch { + // If JSON parsing fails, ignore and try other auth methods + } + } else if (options.vertexKeyFile) { + googleAuthOptions = { keyFile: options.vertexKeyFile } } + + // Build beta headers for 1M context support + const modelId = options.apiModelId + const betas: string[] = [] + + if (modelId) { + const supports1MContext = VERTEX_1M_CONTEXT_MODEL_IDS.includes( + modelId as (typeof VERTEX_1M_CONTEXT_MODEL_IDS)[number], + ) + if (supports1MContext && options.vertex1MContext) { + betas.push("context-1m-2025-08-07") + } + } + + this.provider = createVertexAnthropic({ + project: projectId, + location: region, + googleAuthOptions, + headers: { + ...DEFAULT_HEADERS, + ...(betas.length > 0 ? { "anthropic-beta": betas.join(",") } : {}), + }, + }) } override async *createMessage( @@ -68,16 +88,39 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - let { id, info, temperature, maxTokens, reasoning: thinking, betas } = this.getModel() + const modelConfig = this.getModel() - const { supportsPromptCache } = info + // Reset thinking state for this request + this.lastThoughtSignature = undefined + this.lastRedactedThinkingBlocks = [] - // Filter out non-Anthropic blocks (reasoning, thoughtSignature, etc.) before sending to the API - const sanitizedMessages = filterNonAnthropicBlocks(messages) + // Convert messages to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(messages) - const nativeToolParams = { - tools: convertOpenAIToolsToAnthropic(metadata?.tools ?? []), - tool_choice: convertOpenAIToolChoiceToAnthropic(metadata?.tool_choice, metadata?.parallelToolCalls), + // Convert tools to AI SDK format + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + // Build Anthropic provider options + const anthropicProviderOptions: Record = {} + + // Configure thinking/reasoning if the model supports it + const isThinkingEnabled = + shouldUseReasoningBudget({ model: modelConfig.info, settings: this.options }) && + modelConfig.reasoning && + modelConfig.reasoningBudget + + if (isThinkingEnabled) { + anthropicProviderOptions.thinking = { + type: "enabled", + budgetTokens: modelConfig.reasoningBudget, + } + } + + // Forward parallelToolCalls setting + // When parallelToolCalls is explicitly false, disable parallel tool use + if (metadata?.parallelToolCalls === false) { + anthropicProviderOptions.disableParallelToolUse = true } /** @@ -93,114 +136,178 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple * This ensures we stay under the 4-block limit while maintaining effective caching * for the most relevant context. */ - const params: Anthropic.Messages.MessageCreateParamsStreaming = { - model: id, - max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, - temperature, - thinking, - // Cache the system prompt if caching is enabled. - system: supportsPromptCache - ? [{ text: systemPrompt, type: "text" as const, cache_control: { type: "ephemeral" } }] - : systemPrompt, - messages: supportsPromptCache ? addCacheBreakpoints(sanitizedMessages) : sanitizedMessages, - stream: true, - ...nativeToolParams, + const cacheProviderOption = { anthropic: { cacheControl: { type: "ephemeral" as const } } } + + const userMsgIndices = messages.reduce( + (acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), + [] as number[], + ) + + const targetIndices = new Set() + const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 + const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 + + if (lastUserMsgIndex >= 0) targetIndices.add(lastUserMsgIndex) + if (secondLastUserMsgIndex >= 0) targetIndices.add(secondLastUserMsgIndex) + + if (targetIndices.size > 0) { + this.applyCacheControlToAiSdkMessages(messages, aiSdkMessages, targetIndices, cacheProviderOption) } - // and prompt caching - const requestOptions = betas?.length ? { headers: { "anthropic-beta": betas.join(",") } } : undefined + // Build streamText request + // Cast providerOptions to any to bypass strict JSONObject typing — the AI SDK accepts the correct runtime values + const requestOptions: Parameters[0] = { + model: this.provider(modelConfig.id), + system: systemPrompt, + ...({ + systemProviderOptions: { anthropic: { cacheControl: { type: "ephemeral" } } }, + } as Record), + messages: aiSdkMessages, + temperature: modelConfig.temperature, + maxOutputTokens: modelConfig.maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + ...(Object.keys(anthropicProviderOptions).length > 0 && { + providerOptions: { anthropic: anthropicProviderOptions } as any, + }), + } - const stream = await this.client.messages.create(params, requestOptions) + try { + const result = streamText(requestOptions) - for await (const chunk of stream) { - switch (chunk.type) { - case "message_start": { - const usage = chunk.message!.usage - - yield { - type: "usage", - inputTokens: usage.input_tokens || 0, - outputTokens: usage.output_tokens || 0, - cacheWriteTokens: usage.cache_creation_input_tokens || undefined, - cacheReadTokens: usage.cache_read_input_tokens || undefined, - } - - break + for await (const part of result.fullStream) { + // Capture thinking signature from stream events + // The AI SDK's @ai-sdk/anthropic emits the signature as a reasoning-delta + // event with providerMetadata.anthropic.signature + const partAny = part as any + if (partAny.providerMetadata?.anthropic?.signature) { + this.lastThoughtSignature = partAny.providerMetadata.anthropic.signature } - case "message_delta": { - yield { - type: "usage", - inputTokens: 0, - outputTokens: chunk.usage!.output_tokens || 0, - } - break + // Capture redacted thinking blocks from stream events + if (partAny.providerMetadata?.anthropic?.redactedData) { + this.lastRedactedThinkingBlocks.push({ + type: "redacted_thinking", + data: partAny.providerMetadata.anthropic.redactedData, + }) } - case "content_block_start": { - switch (chunk.content_block!.type) { - case "text": { - if (chunk.index! > 0) { - yield { type: "text", text: "\n" } - } - yield { type: "text", text: chunk.content_block!.text } - break - } - case "thinking": { - if (chunk.index! > 0) { - yield { type: "reasoning", text: "\n" } - } + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } - yield { type: "reasoning", text: (chunk.content_block as any).thinking } - break - } - case "tool_use": { - // Emit initial tool call partial with id and name - yield { - type: "tool_call_partial", - index: chunk.index, - id: chunk.content_block!.id, - name: chunk.content_block!.name, - arguments: undefined, - } - break + // Yield usage metrics at the end, including cache metrics from providerMetadata + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, modelConfig.info, providerMetadata) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + TelemetryService.instance.captureException( + new ApiProviderError(errorMessage, this.providerName, modelConfig.id, "createMessage"), + ) + throw handleAiSdkError(error, this.providerName) + } + } + + /** + * Process usage metrics from the AI SDK response, including Anthropic's cache metrics. + */ + private processUsageMetrics( + usage: { inputTokens?: number; outputTokens?: number }, + info: ModelInfo, + providerMetadata?: Record>, + ): ApiStreamUsageChunk { + const inputTokens = usage.inputTokens ?? 0 + const outputTokens = usage.outputTokens ?? 0 + + // Extract cache metrics from Anthropic's providerMetadata + const anthropicMeta = providerMetadata?.anthropic as + | { cacheCreationInputTokens?: number; cacheReadInputTokens?: number } + | undefined + const cacheWriteTokens = anthropicMeta?.cacheCreationInputTokens ?? 0 + const cacheReadTokens = anthropicMeta?.cacheReadInputTokens ?? 0 + + const { totalCost } = calculateApiCostAnthropic( + info, + inputTokens, + outputTokens, + cacheWriteTokens, + cacheReadTokens, + ) + + return { + type: "usage", + inputTokens, + outputTokens, + cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, + cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, + totalCost, + } + } + + /** + * Apply cacheControl providerOptions to the correct AI SDK messages by walking + * the original Anthropic messages and converted AI SDK messages in parallel. + * + * convertToAiSdkMessages() can split a single Anthropic user message (containing + * tool_results + text) into 2 AI SDK messages (tool role + user role). This method + * accounts for that split so cache control lands on the right message. + */ + private applyCacheControlToAiSdkMessages( + originalMessages: Anthropic.Messages.MessageParam[], + aiSdkMessages: { role: string; providerOptions?: Record> }[], + targetOriginalIndices: Set, + cacheProviderOption: Record>, + ): void { + let aiSdkIdx = 0 + for (let origIdx = 0; origIdx < originalMessages.length; origIdx++) { + const origMsg = originalMessages[origIdx] + + if (typeof origMsg.content === "string") { + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cacheProviderOption, + } + } + aiSdkIdx++ + } else if (origMsg.role === "user") { + const hasToolResults = origMsg.content.some((part) => (part as { type: string }).type === "tool_result") + const hasNonToolContent = origMsg.content.some( + (part) => (part as { type: string }).type === "text" || (part as { type: string }).type === "image", + ) + + if (hasToolResults && hasNonToolContent) { + const userMsgIdx = aiSdkIdx + 1 + if (targetOriginalIndices.has(origIdx) && userMsgIdx < aiSdkMessages.length) { + aiSdkMessages[userMsgIdx].providerOptions = { + ...aiSdkMessages[userMsgIdx].providerOptions, + ...cacheProviderOption, } } - - break - } - case "content_block_delta": { - switch (chunk.delta!.type) { - case "text_delta": { - yield { type: "text", text: chunk.delta!.text } - break - } - case "thinking_delta": { - yield { type: "reasoning", text: (chunk.delta as any).thinking } - break - } - case "input_json_delta": { - // Emit tool call partial chunks as arguments stream in - yield { - type: "tool_call_partial", - index: chunk.index, - id: undefined, - name: undefined, - arguments: (chunk.delta as any).partial_json, - } - break + aiSdkIdx += 2 + } else if (hasToolResults) { + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cacheProviderOption, } } - - break - } - case "content_block_stop": { - // Block complete - no action needed for now. - // NativeToolCallParser handles tool call completion - // Note: Signature for multi-turn thinking would require using stream.finalMessage() - // after iteration completes, which requires restructuring the streaming approach. - break + aiSdkIdx++ + } else { + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cacheProviderOption, + } + } + aiSdkIdx++ } + } else { + aiSdkIdx++ } } } @@ -231,12 +338,17 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple } } - const params = getModelParams({ format: "anthropic", modelId: id, model: info, settings: this.options }) + const params = getModelParams({ + format: "anthropic", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) - // Build betas array for request headers + // Build betas array for request headers (kept for backward compatibility / testing) const betas: string[] = [] - // Add 1M context beta flag if enabled for supported models if (enable1MContext) { betas.push("context-1m-2025-08-07") } @@ -253,46 +365,49 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple } } - async completePrompt(prompt: string) { + async completePrompt(prompt: string): Promise { + const { id, temperature } = this.getModel() + try { - let { - id, - info: { supportsPromptCache }, + const { text } = await generateText({ + model: this.provider(id), + prompt, + maxOutputTokens: ANTHROPIC_DEFAULT_MAX_TOKENS, temperature, - maxTokens = ANTHROPIC_DEFAULT_MAX_TOKENS, - reasoning: thinking, - } = this.getModel() + }) - const params: Anthropic.Messages.MessageCreateParamsNonStreaming = { - model: id, - max_tokens: maxTokens, - temperature, - thinking, - messages: [ - { - role: "user", - content: supportsPromptCache - ? [{ type: "text" as const, text: prompt, cache_control: { type: "ephemeral" } }] - : prompt, - }, - ], - stream: false, - } - - const response = await this.client.messages.create(params) - const content = response.content[0] - - if (content.type === "text") { - return content.text - } - - return "" + return text } catch (error) { - if (error instanceof Error) { - throw new Error(`Vertex completion error: ${error.message}`) - } - - throw error + TelemetryService.instance.captureException( + new ApiProviderError( + error instanceof Error ? error.message : String(error), + this.providerName, + id, + "completePrompt", + ), + ) + throw handleAiSdkError(error, this.providerName) } } + + /** + * Returns the thinking signature captured from the last Anthropic response. + * Claude models with extended thinking return a cryptographic signature + * which must be round-tripped back for multi-turn conversations with tool use. + */ + getThoughtSignature(): string | undefined { + return this.lastThoughtSignature + } + + /** + * Returns any redacted thinking blocks captured from the last Anthropic response. + * Anthropic returns these when safety filters trigger on reasoning content. + */ + getRedactedThinkingBlocks(): Array<{ type: "redacted_thinking"; data: string }> | undefined { + return this.lastRedactedThinkingBlocks.length > 0 ? this.lastRedactedThinkingBlocks : undefined + } + + override isAiSdkProvider(): boolean { + return true + } } diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 3139f5d25a..f6ee47e130 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -1,7 +1,6 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming" -import { CacheControlEphemeral } from "@anthropic-ai/sdk/resources" -import OpenAI from "openai" +import type { Anthropic } from "@anthropic-ai/sdk" +import { createAnthropic } from "@ai-sdk/anthropic" +import { streamText, generateText, ToolSet } from "ai" import { type ModelInfo, @@ -14,313 +13,277 @@ import { import { TelemetryService } from "@roo-code/telemetry" import type { ApiHandlerOptions } from "../../shared/api" +import { shouldUseReasoningBudget } from "../../shared/api" -import { ApiStream } from "../transform/stream" +import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" -import { filterNonAnthropicBlocks } from "../transform/anthropic-filter" -import { handleProviderError } from "./utils/error-handler" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" +import { calculateApiCostAnthropic } from "../../shared/cost" +import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { calculateApiCostAnthropic } from "../../shared/cost" -import { - convertOpenAIToolsToAnthropic, - convertOpenAIToolChoiceToAnthropic, -} from "../../core/prompts/tools/native-tools/converters" export class AnthropicHandler extends BaseProvider implements SingleCompletionHandler { private options: ApiHandlerOptions - private client: Anthropic + private provider: ReturnType private readonly providerName = "Anthropic" + private lastThoughtSignature: string | undefined + private lastRedactedThinkingBlocks: Array<{ type: "redacted_thinking"; data: string }> = [] constructor(options: ApiHandlerOptions) { super() this.options = options - const apiKeyFieldName = - this.options.anthropicBaseUrl && this.options.anthropicUseAuthToken ? "authToken" : "apiKey" + const useAuthToken = Boolean(options.anthropicBaseUrl && options.anthropicUseAuthToken) - this.client = new Anthropic({ - baseURL: this.options.anthropicBaseUrl || undefined, - [apiKeyFieldName]: this.options.apiKey, - }) - } + // Build beta headers for model-specific features + const betas: string[] = [] + const modelId = options.apiModelId - async *createMessage( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - let stream: AnthropicStream - const cacheControl: CacheControlEphemeral = { type: "ephemeral" } - let { - id: modelId, - betas = ["fine-grained-tool-streaming-2025-05-14"], - maxTokens, - temperature, - reasoning: thinking, - } = this.getModel() + if (modelId === "claude-3-7-sonnet-20250219:thinking") { + betas.push("output-128k-2025-02-19") + } - // Filter out non-Anthropic blocks (reasoning, thoughtSignature, etc.) before sending to the API - const sanitizedMessages = filterNonAnthropicBlocks(messages) - - // Add 1M context beta flag if enabled for Claude Sonnet 4 and 4.5 if ( - (modelId === "claude-sonnet-4-20250514" || modelId === "claude-sonnet-4-5") && - this.options.anthropicBeta1MContext + (modelId === "claude-sonnet-4-20250514" || + modelId === "claude-sonnet-4-5" || + modelId === "claude-opus-4-6") && + options.anthropicBeta1MContext ) { betas.push("context-1m-2025-08-07") } - const nativeToolParams = { - tools: convertOpenAIToolsToAnthropic(metadata?.tools ?? []), - tool_choice: convertOpenAIToolChoiceToAnthropic(metadata?.tool_choice, metadata?.parallelToolCalls), + this.provider = createAnthropic({ + baseURL: options.anthropicBaseUrl || undefined, + ...(useAuthToken ? { authToken: options.apiKey } : { apiKey: options.apiKey }), + headers: { + ...DEFAULT_HEADERS, + ...(betas.length > 0 ? { "anthropic-beta": betas.join(",") } : {}), + }, + }) + } + + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const modelConfig = this.getModel() + + // Reset thinking state for this request + this.lastThoughtSignature = undefined + this.lastRedactedThinkingBlocks = [] + + // Convert messages to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(messages) + + // Convert tools to AI SDK format + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + // Build Anthropic provider options + const anthropicProviderOptions: Record = {} + + // Configure thinking/reasoning if the model supports it + const isThinkingEnabled = + shouldUseReasoningBudget({ model: modelConfig.info, settings: this.options }) && + modelConfig.reasoning && + modelConfig.reasoningBudget + + if (isThinkingEnabled) { + anthropicProviderOptions.thinking = { + type: "enabled", + budgetTokens: modelConfig.reasoningBudget, + } } - switch (modelId) { - case "claude-sonnet-4-5": - case "claude-sonnet-4-20250514": - case "claude-opus-4-5-20251101": - case "claude-opus-4-1-20250805": - case "claude-opus-4-20250514": - case "claude-3-7-sonnet-20250219": - case "claude-3-5-sonnet-20241022": - case "claude-3-5-haiku-20241022": - case "claude-3-opus-20240229": - case "claude-haiku-4-5-20251001": - case "claude-3-haiku-20240307": { - /** - * The latest message will be the new user message, one before - * will be the assistant message from a previous request, and - * the user message before that will be a previously cached user - * message. So we need to mark the latest user message as - * ephemeral to cache it for the next request, and mark the - * second to last user message as ephemeral to let the server - * know the last message to retrieve from the cache for the - * current request. - */ - const userMsgIndices = sanitizedMessages.reduce( - (acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), - [] as number[], + // Forward parallelToolCalls setting + // When parallelToolCalls is explicitly false, disable parallel tool use + if (metadata?.parallelToolCalls === false) { + anthropicProviderOptions.disableParallelToolUse = true + } + + // Apply cache control to user messages + // Strategy: cache the last 2 user messages (write-to-cache + read-from-cache) + const cacheProviderOption = { anthropic: { cacheControl: { type: "ephemeral" as const } } } + + const userMsgIndices = messages.reduce( + (acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), + [] as number[], + ) + + const targetIndices = new Set() + const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 + const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 + + if (lastUserMsgIndex >= 0) targetIndices.add(lastUserMsgIndex) + if (secondLastUserMsgIndex >= 0) targetIndices.add(secondLastUserMsgIndex) + + if (targetIndices.size > 0) { + this.applyCacheControlToAiSdkMessages(messages, aiSdkMessages, targetIndices, cacheProviderOption) + } + + // Build streamText request + // Cast providerOptions to any to bypass strict JSONObject typing — the AI SDK accepts the correct runtime values + const requestOptions: Parameters[0] = { + model: this.provider(modelConfig.id), + system: systemPrompt, + ...({ + systemProviderOptions: { anthropic: { cacheControl: { type: "ephemeral" } } }, + } as Record), + messages: aiSdkMessages, + temperature: modelConfig.temperature, + maxOutputTokens: modelConfig.maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + ...(Object.keys(anthropicProviderOptions).length > 0 && { + providerOptions: { anthropic: anthropicProviderOptions } as any, + }), + } + + try { + const result = streamText(requestOptions) + + for await (const part of result.fullStream) { + // Capture thinking signature from stream events + // The AI SDK's @ai-sdk/anthropic emits the signature as a reasoning-delta + // event with providerMetadata.anthropic.signature + const partAny = part as any + if (partAny.providerMetadata?.anthropic?.signature) { + this.lastThoughtSignature = partAny.providerMetadata.anthropic.signature + } + + // Capture redacted thinking blocks from stream events + if (partAny.providerMetadata?.anthropic?.redactedData) { + this.lastRedactedThinkingBlocks.push({ + type: "redacted_thinking", + data: partAny.providerMetadata.anthropic.redactedData, + }) + } + + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } + + // Yield usage metrics at the end, including cache metrics from providerMetadata + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, modelConfig.info, providerMetadata) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + TelemetryService.instance.captureException( + new ApiProviderError(errorMessage, this.providerName, modelConfig.id, "createMessage"), + ) + throw handleAiSdkError(error, this.providerName) + } + } + + /** + * Process usage metrics from the AI SDK response, including Anthropic's cache metrics. + */ + private processUsageMetrics( + usage: { inputTokens?: number; outputTokens?: number }, + info: ModelInfo, + providerMetadata?: Record>, + ): ApiStreamUsageChunk { + const inputTokens = usage.inputTokens ?? 0 + const outputTokens = usage.outputTokens ?? 0 + + // Extract cache metrics from Anthropic's providerMetadata + const anthropicMeta = providerMetadata?.anthropic as + | { cacheCreationInputTokens?: number; cacheReadInputTokens?: number } + | undefined + const cacheWriteTokens = anthropicMeta?.cacheCreationInputTokens ?? 0 + const cacheReadTokens = anthropicMeta?.cacheReadInputTokens ?? 0 + + const { totalCost } = calculateApiCostAnthropic( + info, + inputTokens, + outputTokens, + cacheWriteTokens, + cacheReadTokens, + ) + + return { + type: "usage", + inputTokens, + outputTokens, + cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, + cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, + totalCost, + } + } + + /** + * Apply cacheControl providerOptions to the correct AI SDK messages by walking + * the original Anthropic messages and converted AI SDK messages in parallel. + * + * convertToAiSdkMessages() can split a single Anthropic user message (containing + * tool_results + text) into 2 AI SDK messages (tool role + user role). This method + * accounts for that split so cache control lands on the right message. + */ + private applyCacheControlToAiSdkMessages( + originalMessages: Anthropic.Messages.MessageParam[], + aiSdkMessages: { role: string; providerOptions?: Record> }[], + targetOriginalIndices: Set, + cacheProviderOption: Record>, + ): void { + let aiSdkIdx = 0 + for (let origIdx = 0; origIdx < originalMessages.length; origIdx++) { + const origMsg = originalMessages[origIdx] + + if (typeof origMsg.content === "string") { + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cacheProviderOption, + } + } + aiSdkIdx++ + } else if (origMsg.role === "user") { + const hasToolResults = origMsg.content.some((part) => (part as { type: string }).type === "tool_result") + const hasNonToolContent = origMsg.content.some( + (part) => (part as { type: string }).type === "text" || (part as { type: string }).type === "image", ) - const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 - const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 - - try { - stream = await this.client.messages.create( - { - model: modelId, - max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, - temperature, - thinking, - // Setting cache breakpoint for system prompt so new tasks can reuse it. - system: [{ text: systemPrompt, type: "text", cache_control: cacheControl }], - messages: sanitizedMessages.map((message, index) => { - if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) { - return { - ...message, - content: - typeof message.content === "string" - ? [{ type: "text", text: message.content, cache_control: cacheControl }] - : message.content.map((content, contentIndex) => - contentIndex === message.content.length - 1 - ? { ...content, cache_control: cacheControl } - : content, - ), - } - } - return message - }), - stream: true, - ...nativeToolParams, - }, - (() => { - // prompt caching: https://x.com/alexalbert__/status/1823751995901272068 - // https://github.com/anthropics/anthropic-sdk-typescript?tab=readme-ov-file#default-headers - // https://github.com/anthropics/anthropic-sdk-typescript/commit/c920b77fc67bd839bfeb6716ceab9d7c9bbe7393 - - // Then check for models that support prompt caching - switch (modelId) { - case "claude-sonnet-4-5": - case "claude-sonnet-4-20250514": - case "claude-opus-4-5-20251101": - case "claude-opus-4-1-20250805": - case "claude-opus-4-20250514": - case "claude-3-7-sonnet-20250219": - case "claude-3-5-sonnet-20241022": - case "claude-3-5-haiku-20241022": - case "claude-3-opus-20240229": - case "claude-haiku-4-5-20251001": - case "claude-3-haiku-20240307": - betas.push("prompt-caching-2024-07-31") - return { headers: { "anthropic-beta": betas.join(",") } } - default: - return undefined - } - })(), - ) - } catch (error) { - TelemetryService.instance.captureException( - new ApiProviderError( - error instanceof Error ? error.message : String(error), - this.providerName, - modelId, - "createMessage", - ), - ) - throw error - } - break - } - default: { - try { - stream = (await this.client.messages.create({ - model: modelId, - max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, - temperature, - system: [{ text: systemPrompt, type: "text" }], - messages: sanitizedMessages, - stream: true, - ...nativeToolParams, - })) as any - } catch (error) { - TelemetryService.instance.captureException( - new ApiProviderError( - error instanceof Error ? error.message : String(error), - this.providerName, - modelId, - "createMessage", - ), - ) - throw error - } - break - } - } - - let inputTokens = 0 - let outputTokens = 0 - let cacheWriteTokens = 0 - let cacheReadTokens = 0 - - for await (const chunk of stream) { - switch (chunk.type) { - case "message_start": { - // Tells us cache reads/writes/input/output. - const { - input_tokens = 0, - output_tokens = 0, - cache_creation_input_tokens, - cache_read_input_tokens, - } = chunk.message.usage - - yield { - type: "usage", - inputTokens: input_tokens, - outputTokens: output_tokens, - cacheWriteTokens: cache_creation_input_tokens || undefined, - cacheReadTokens: cache_read_input_tokens || undefined, - } - - inputTokens += input_tokens - outputTokens += output_tokens - cacheWriteTokens += cache_creation_input_tokens || 0 - cacheReadTokens += cache_read_input_tokens || 0 - - break - } - case "message_delta": - // Tells us stop_reason, stop_sequence, and output tokens - // along the way and at the end of the message. - yield { - type: "usage", - inputTokens: 0, - outputTokens: chunk.usage.output_tokens || 0, - } - - break - case "message_stop": - // No usage data, just an indicator that the message is done. - break - case "content_block_start": - switch (chunk.content_block.type) { - case "thinking": - // We may receive multiple text blocks, in which - // case just insert a line break between them. - if (chunk.index > 0) { - yield { type: "reasoning", text: "\n" } - } - - yield { type: "reasoning", text: chunk.content_block.thinking } - break - case "text": - // We may receive multiple text blocks, in which - // case just insert a line break between them. - if (chunk.index > 0) { - yield { type: "text", text: "\n" } - } - - yield { type: "text", text: chunk.content_block.text } - break - case "tool_use": { - // Emit initial tool call partial with id and name - yield { - type: "tool_call_partial", - index: chunk.index, - id: chunk.content_block.id, - name: chunk.content_block.name, - arguments: undefined, - } - break + if (hasToolResults && hasNonToolContent) { + const userMsgIdx = aiSdkIdx + 1 + if (targetOriginalIndices.has(origIdx) && userMsgIdx < aiSdkMessages.length) { + aiSdkMessages[userMsgIdx].providerOptions = { + ...aiSdkMessages[userMsgIdx].providerOptions, + ...cacheProviderOption, } } - break - case "content_block_delta": - switch (chunk.delta.type) { - case "thinking_delta": - yield { type: "reasoning", text: chunk.delta.thinking } - break - case "text_delta": - yield { type: "text", text: chunk.delta.text } - break - case "input_json_delta": { - // Emit tool call partial chunks as arguments stream in - yield { - type: "tool_call_partial", - index: chunk.index, - id: undefined, - name: undefined, - arguments: chunk.delta.partial_json, - } - break + aiSdkIdx += 2 + } else if (hasToolResults) { + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cacheProviderOption, } } - - break - case "content_block_stop": - // Block complete - no action needed for now. - // NativeToolCallParser handles tool call completion - // Note: Signature for multi-turn thinking would require using stream.finalMessage() - // after iteration completes, which requires restructuring the streaming approach. - break - } - } - - if (inputTokens > 0 || outputTokens > 0 || cacheWriteTokens > 0 || cacheReadTokens > 0) { - const { totalCost } = calculateApiCostAnthropic( - this.getModel().info, - inputTokens, - outputTokens, - cacheWriteTokens, - cacheReadTokens, - ) - - yield { - type: "usage", - inputTokens: 0, - outputTokens: 0, - totalCost, + aiSdkIdx++ + } else { + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cacheProviderOption, + } + } + aiSdkIdx++ + } + } else { + aiSdkIdx++ } } } @@ -330,9 +293,11 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa let id = modelId && modelId in anthropicModels ? (modelId as AnthropicModelId) : anthropicDefaultModelId let info: ModelInfo = anthropicModels[id] - // If 1M context beta is enabled for Claude Sonnet 4 or 4.5, update the model info - if ((id === "claude-sonnet-4-20250514" || id === "claude-sonnet-4-5") && this.options.anthropicBeta1MContext) { - // Use the tier pricing for 1M context + // If 1M context beta is enabled for supported models, update the model info + if ( + (id === "claude-sonnet-4-20250514" || id === "claude-sonnet-4-5" || id === "claude-opus-4-6") && + this.options.anthropicBeta1MContext + ) { const tier = info.tiers?.[0] if (tier) { info = { @@ -351,6 +316,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa modelId: id, model: info, settings: this.options, + defaultTemperature: 0, }) // The `:thinking` suffix indicates that the model is a "Hybrid" @@ -360,37 +326,53 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa return { id: id === "claude-3-7-sonnet-20250219:thinking" ? "claude-3-7-sonnet-20250219" : id, info, - betas: id === "claude-3-7-sonnet-20250219:thinking" ? ["output-128k-2025-02-19"] : undefined, ...params, } } - async completePrompt(prompt: string) { - let { id: model, temperature } = this.getModel() + async completePrompt(prompt: string): Promise { + const { id, temperature } = this.getModel() - let message try { - message = await this.client.messages.create({ - model, - max_tokens: ANTHROPIC_DEFAULT_MAX_TOKENS, - thinking: undefined, + const { text } = await generateText({ + model: this.provider(id), + prompt, + maxOutputTokens: ANTHROPIC_DEFAULT_MAX_TOKENS, temperature, - messages: [{ role: "user", content: prompt }], - stream: false, }) + + return text } catch (error) { TelemetryService.instance.captureException( new ApiProviderError( error instanceof Error ? error.message : String(error), this.providerName, - model, + id, "completePrompt", ), ) - throw error + throw handleAiSdkError(error, this.providerName) } + } - const content = message.content.find(({ type }) => type === "text") - return content?.type === "text" ? content.text : "" + /** + * Returns the thinking signature captured from the last Anthropic response. + * Claude models with extended thinking return a cryptographic signature + * which must be round-tripped back for multi-turn conversations with tool use. + */ + getThoughtSignature(): string | undefined { + return this.lastThoughtSignature + } + + /** + * Returns any redacted thinking blocks captured from the last Anthropic response. + * Anthropic returns these when safety filters trigger on reasoning content. + */ + getRedactedThinkingBlocks(): Array<{ type: "redacted_thinking"; data: string }> | undefined { + return this.lastRedactedThinkingBlocks.length > 0 ? this.lastRedactedThinkingBlocks : undefined + } + + override isAiSdkProvider(): boolean { + return true } } diff --git a/src/api/providers/groq.ts b/src/api/providers/azure.ts similarity index 55% rename from src/api/providers/groq.ts rename to src/api/providers/azure.ts index 648679f92c..5dcacb4895 100644 --- a/src/api/providers/groq.ts +++ b/src/api/providers/azure.ts @@ -1,8 +1,8 @@ import { Anthropic } from "@anthropic-ai/sdk" -import { createGroq } from "@ai-sdk/groq" +import { createAzure } from "@ai-sdk/azure" import { streamText, generateText, ToolSet } from "ai" -import { groqModels, groqDefaultModelId, type ModelInfo } from "@roo-code/types" +import { azureModels, azureDefaultModelInfo, azureOpenAiDefaultApiVersion, type ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" @@ -20,52 +20,68 @@ import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -const GROQ_DEFAULT_TEMPERATURE = 0.5 +const AZURE_DEFAULT_TEMPERATURE = 0 /** - * Groq provider using the dedicated @ai-sdk/groq package. - * Provides native support for reasoning models and prompt caching. + * Azure AI Foundry provider using the dedicated @ai-sdk/azure package. + * Provides native support for Azure OpenAI deployments with proper resource-based routing. */ -export class GroqHandler extends BaseProvider implements SingleCompletionHandler { +export class AzureHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - protected provider: ReturnType + protected provider: ReturnType constructor(options: ApiHandlerOptions) { super() this.options = options - // Create the Groq provider using AI SDK - this.provider = createGroq({ - baseURL: "https://api.groq.com/openai/v1", - apiKey: options.groqApiKey ?? "not-provided", + const rawApiVersion = (options.azureApiVersion ?? "").trim() + const queryLikeApiVersion = rawApiVersion.replace(/^\?/, "").trim() + const normalizedApiVersion = queryLikeApiVersion.toLowerCase().includes("api-version=") + ? (new URLSearchParams(queryLikeApiVersion).get("api-version") ?? "") + : queryLikeApiVersion + const apiVersion = normalizedApiVersion.replace(/^api-version=/i, "").trim() + + // Create the Azure provider using AI SDK + // The @ai-sdk/azure package uses resourceName-based routing + this.provider = createAzure({ + resourceName: options.azureResourceName ?? "", + apiKey: options.azureApiKey, // Optional — Azure supports managed identity / Entra ID auth + ...(apiVersion ? { apiVersion } : { apiVersion: azureOpenAiDefaultApiVersion }), headers: DEFAULT_HEADERS, }) } override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { - const id = this.options.apiModelId ?? groqDefaultModelId - const info = groqModels[id as keyof typeof groqModels] || groqModels[groqDefaultModelId] + // Azure uses deployment names for API calls, but apiModelId for model capabilities. + // deploymentId is sent to the Azure API; modelId is used for capability lookup. + const deploymentId = this.options.azureDeploymentName ?? this.options.apiModelId ?? "" + const modelId = this.options.apiModelId ?? deploymentId + const info: ModelInfo = + (azureModels as Record)[modelId] ?? + (azureModels as Record)[deploymentId] ?? + azureDefaultModelInfo const params = getModelParams({ format: "openai", - modelId: id, + modelId: deploymentId, // deployment name for the API model: info, settings: this.options, - defaultTemperature: GROQ_DEFAULT_TEMPERATURE, + defaultTemperature: AZURE_DEFAULT_TEMPERATURE, }) - return { id, info, ...params } + return { id: deploymentId, info, ...params } } /** - * Get the language model for the configured model ID. + * Get the language model for the configured deployment name. + * Azure provider is wired to use the Responses API endpoint. */ protected getLanguageModel() { const { id } = this.getModel() - return this.provider(id) + return this.provider.responses(id) } /** - * Process usage metrics from the AI SDK response, including Groq's cache metrics. - * Groq provides cache hit/miss info via providerMetadata for supported models. + * Process usage metrics from the AI SDK response. + * Azure AI Foundry provides standard OpenAI-compatible usage metrics. */ protected processUsageMetrics( usage: { @@ -77,15 +93,17 @@ export class GroqHandler extends BaseProvider implements SingleCompletionHandler } }, providerMetadata?: { - groq?: { + azure?: { promptCacheHitTokens?: number promptCacheMissTokens?: number } }, ): ApiStreamUsageChunk { - // Extract cache metrics from Groq's providerMetadata - const cacheReadTokens = providerMetadata?.groq?.promptCacheHitTokens ?? usage.details?.cachedInputTokens - const cacheWriteTokens = providerMetadata?.groq?.promptCacheMissTokens + // Extract cache metrics from Azure's providerMetadata if available + const cacheReadTokens = providerMetadata?.azure?.promptCacheHitTokens ?? usage.details?.cachedInputTokens + // Azure uses OpenAI-compatible caching which does not report cache write tokens separately; + // promptCacheMissTokens represents tokens NOT found in cache (processed from scratch), not tokens written to cache. + const cacheWriteTokens = undefined return { type: "usage", @@ -99,15 +117,17 @@ export class GroqHandler extends BaseProvider implements SingleCompletionHandler /** * Get the max tokens parameter to include in the request. + * Returns undefined if no valid maxTokens is configured to let the API use its default. */ protected getMaxOutputTokens(): number | undefined { const { info } = this.getModel() - return this.options.modelMaxTokens || info.maxTokens || undefined + const maxTokens = this.options.modelMaxTokens || info.maxTokens + // Azure AI Foundry API requires maxOutputTokens >= 1, so filter out invalid values + return maxTokens && maxTokens > 0 ? maxTokens : undefined } /** * Create a message stream using the AI SDK. - * Groq supports reasoning for models like qwen/qwen3-32b via reasoningFormat: 'parsed'. */ override async *createMessage( systemPrompt: string, @@ -129,7 +149,7 @@ export class GroqHandler extends BaseProvider implements SingleCompletionHandler model: languageModel, system: systemPrompt, messages: aiSdkMessages, - temperature: this.options.modelTemperature ?? temperature ?? GROQ_DEFAULT_TEMPERATURE, + temperature: this.options.modelTemperature ?? temperature ?? AZURE_DEFAULT_TEMPERATURE, maxOutputTokens: this.getMaxOutputTokens(), tools: aiSdkTools, toolChoice: mapToolChoice(metadata?.tool_choice), @@ -154,7 +174,7 @@ export class GroqHandler extends BaseProvider implements SingleCompletionHandler } } catch (error) { // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) - throw handleAiSdkError(error, "Groq") + throw handleAiSdkError(error, "Azure AI Foundry") } } @@ -169,9 +189,13 @@ export class GroqHandler extends BaseProvider implements SingleCompletionHandler model: languageModel, prompt, maxOutputTokens: this.getMaxOutputTokens(), - temperature: this.options.modelTemperature ?? temperature ?? GROQ_DEFAULT_TEMPERATURE, + temperature: this.options.modelTemperature ?? temperature ?? AZURE_DEFAULT_TEMPERATURE, }) return text } + + override isAiSdkProvider(): boolean { + return true + } } diff --git a/src/api/providers/base-provider.ts b/src/api/providers/base-provider.ts index a6adeeadbd..817af53a49 100644 --- a/src/api/providers/base-provider.ts +++ b/src/api/providers/base-provider.ts @@ -119,4 +119,12 @@ export abstract class BaseProvider implements ApiHandler { return countTokens(content, { useWorker: true }) } + + /** + * Default implementation returns false. + * AI SDK providers should override this to return true. + */ + isAiSdkProvider(): boolean { + return false + } } diff --git a/src/api/providers/baseten.ts b/src/api/providers/baseten.ts index ca0c286775..2e63f3d52c 100644 --- a/src/api/providers/baseten.ts +++ b/src/api/providers/baseten.ts @@ -1,18 +1,156 @@ -import { type BasetenModelId, basetenDefaultModelId, basetenModels } from "@roo-code/types" +import { Anthropic } from "@anthropic-ai/sdk" +import { createBaseten } from "@ai-sdk/baseten" +import { streamText, generateText, ToolSet } from "ai" + +import { basetenModels, basetenDefaultModelId, type ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" -import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" -export class BasetenHandler extends BaseOpenAiCompatibleProvider { +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { getModelParams } from "../transform/model-params" + +import { DEFAULT_HEADERS } from "./constants" +import { BaseProvider } from "./base-provider" +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" + +const BASETEN_DEFAULT_TEMPERATURE = 0.5 + +/** + * Baseten provider using the dedicated @ai-sdk/baseten package. + * Provides native support for Baseten's inference API. + */ +export class BasetenHandler extends BaseProvider implements SingleCompletionHandler { + protected options: ApiHandlerOptions + protected provider: ReturnType + constructor(options: ApiHandlerOptions) { - super({ - ...options, - providerName: "Baseten", + super() + this.options = options + + this.provider = createBaseten({ baseURL: "https://inference.baseten.co/v1", - apiKey: options.basetenApiKey, - defaultProviderModelId: basetenDefaultModelId, - providerModels: basetenModels, - defaultTemperature: 0.5, + apiKey: options.basetenApiKey ?? "not-provided", + headers: DEFAULT_HEADERS, }) } + + override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { + const id = this.options.apiModelId ?? basetenDefaultModelId + const info = basetenModels[id as keyof typeof basetenModels] || basetenModels[basetenDefaultModelId] + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: BASETEN_DEFAULT_TEMPERATURE, + }) + return { id, info, ...params } + } + + /** + * Get the language model for the configured model ID. + */ + protected getLanguageModel() { + const { id } = this.getModel() + return this.provider(id) + } + + /** + * Process usage metrics from the AI SDK response. + */ + protected processUsageMetrics(usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + }): ApiStreamUsageChunk { + return { + type: "usage", + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + reasoningTokens: usage.details?.reasoningTokens, + } + } + + /** + * Get the max tokens parameter to include in the request. + */ + protected getMaxOutputTokens(): number | undefined { + const { info } = this.getModel() + return this.options.modelMaxTokens || info.maxTokens || undefined + } + + /** + * Create a message stream using the AI SDK. + */ + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() + + const aiSdkMessages = convertToAiSdkMessages(messages) + + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + const requestOptions: Parameters[0] = { + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature: this.options.modelTemperature ?? temperature ?? BASETEN_DEFAULT_TEMPERATURE, + maxOutputTokens: this.getMaxOutputTokens(), + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + } + + const result = streamText(requestOptions) + + try { + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } + + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage) + } + } catch (error) { + throw handleAiSdkError(error, "Baseten") + } + } + + /** + * Complete a prompt using the AI SDK generateText. + */ + async completePrompt(prompt: string): Promise { + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() + + const { text } = await generateText({ + model: languageModel, + prompt, + maxOutputTokens: this.getMaxOutputTokens(), + temperature: this.options.modelTemperature ?? temperature ?? BASETEN_DEFAULT_TEMPERATURE, + }) + + return text + } + + override isAiSdkProvider(): boolean { + return true + } } diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index 2b96a277f3..375dd2c042 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -1,24 +1,13 @@ -import { - BedrockRuntimeClient, - ConverseStreamCommand, - ConverseCommand, - BedrockRuntimeClientConfig, - ContentBlock, - Message, - SystemContentBlock, - Tool, - ToolConfiguration, - ToolChoice, -} from "@aws-sdk/client-bedrock-runtime" -import OpenAI from "openai" +import type { Anthropic } from "@anthropic-ai/sdk" +import { createAmazonBedrock, type AmazonBedrockProvider } from "@ai-sdk/amazon-bedrock" +import { streamText, generateText, ToolSet } from "ai" import { fromIni } from "@aws-sdk/credential-providers" -import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" import { type ModelInfo, type ProviderSettings, type BedrockModelId, - type BedrockServiceTier, bedrockDefaultModelId, bedrockModels, bedrockDefaultPromptRouterModelId, @@ -34,162 +23,22 @@ import { } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { ApiStream } from "../transform/stream" -import { BaseProvider } from "./base-provider" -import { logger } from "../../utils/logging" -import { Package } from "../../shared/package" -import { MultiPointStrategy } from "../transform/cache-strategy/multi-point-strategy" -import { ModelInfo as CacheModelInfo } from "../transform/cache-strategy/types" -import { convertToBedrockConverseMessages as sharedConverter } from "../transform/bedrock-converse-format" +import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" import { getModelParams } from "../transform/model-params" import { shouldUseReasoningBudget } from "../../shared/api" -import { normalizeToolSchema } from "../../utils/json-schema" +import { BaseProvider } from "./base-provider" +import { DEFAULT_HEADERS } from "./constants" +import { logger } from "../../utils/logging" +import { Package } from "../../shared/package" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -/************************************************************************************ - * - * TYPES - * - *************************************************************************************/ - -// Define interface for Bedrock inference config -interface BedrockInferenceConfig { - maxTokens: number - temperature?: number -} - -// Define interface for Bedrock additional model request fields -// This includes thinking configuration, 1M context beta, and other model-specific parameters -interface BedrockAdditionalModelFields { - thinking?: { - type: "enabled" - budget_tokens: number - } - anthropic_beta?: string[] - [key: string]: any // Add index signature to be compatible with DocumentType -} - -// Define interface for Bedrock payload -interface BedrockPayload { - modelId: BedrockModelId | string - messages: Message[] - system?: SystemContentBlock[] - inferenceConfig: BedrockInferenceConfig - anthropic_version?: string - additionalModelRequestFields?: BedrockAdditionalModelFields - toolConfig?: ToolConfiguration -} - -// Extended payload type that includes service_tier as a top-level parameter -// AWS Bedrock service tiers (STANDARD, FLEX, PRIORITY) are specified at the top level -// https://docs.aws.amazon.com/bedrock/latest/userguide/service-tiers-inference.html -type BedrockPayloadWithServiceTier = BedrockPayload & { - service_tier?: BedrockServiceTier -} - -// Define specific types for content block events to avoid 'as any' usage -// These handle the multiple possible structures returned by AWS SDK -interface ContentBlockStartEvent { - start?: { - text?: string - thinking?: string - toolUse?: { - toolUseId?: string - name?: string - } - } - contentBlockIndex?: number - // Alternative structure used by some AWS SDK versions - content_block?: { - type?: string - thinking?: string - } - // Official AWS SDK structure for reasoning (as documented) - contentBlock?: { - type?: string - thinking?: string - reasoningContent?: { - text?: string - } - // Tool use block start - toolUse?: { - toolUseId?: string - name?: string - } - } -} - -interface ContentBlockDeltaEvent { - delta?: { - text?: string - thinking?: string - type?: string - // AWS SDK structure for reasoning content deltas - reasoningContent?: { - text?: string - } - // Tool use input delta - toolUse?: { - input?: string - } - } - contentBlockIndex?: number -} - -// Define types for stream events based on AWS SDK -export interface StreamEvent { - messageStart?: { - role?: string - } - messageStop?: { - stopReason?: "end_turn" | "tool_use" | "max_tokens" | "stop_sequence" - additionalModelResponseFields?: Record - } - contentBlockStart?: ContentBlockStartEvent - contentBlockDelta?: ContentBlockDeltaEvent - metadata?: { - usage?: { - inputTokens: number - outputTokens: number - totalTokens?: number // Made optional since we don't use it - // New cache-related fields - cacheReadInputTokens?: number - cacheWriteInputTokens?: number - cacheReadInputTokenCount?: number - cacheWriteInputTokenCount?: number - } - metrics?: { - latencyMs: number - } - } - // New trace field for prompt router - trace?: { - promptRouter?: { - invokedModelId?: string - usage?: { - inputTokens: number - outputTokens: number - totalTokens?: number // Made optional since we don't use it - // New cache-related fields - cacheReadTokens?: number - cacheWriteTokens?: number - cacheReadInputTokenCount?: number - cacheWriteInputTokenCount?: number - } - } - } -} - -// Type for usage information in stream events -export type UsageType = { - inputTokens?: number - outputTokens?: number - cacheReadInputTokens?: number - cacheWriteInputTokens?: number - cacheReadInputTokenCount?: number - cacheWriteInputTokenCount?: number -} - /************************************************************************************ * * PROVIDER @@ -198,19 +47,18 @@ export type UsageType = { export class AwsBedrockHandler extends BaseProvider implements SingleCompletionHandler { protected options: ProviderSettings - private client: BedrockRuntimeClient + protected provider: AmazonBedrockProvider private arnInfo: any private readonly providerName = "Bedrock" + private lastThoughtSignature: string | undefined + private lastRedactedThinkingBlocks: Array<{ type: "redacted_thinking"; data: string }> = [] constructor(options: ProviderSettings) { super() this.options = options let region = this.options.awsRegion - // process the various user input options, be opinionated about the intent of the options - // and determine the model to use during inference and for cost calculations - // There are variations on ARN strings that can be entered making the conditional logic - // more involved than the non-ARN branch of logic + // Process custom ARN if provided if (this.options.awsCustomArn) { this.arnInfo = this.parseArn(this.options.awsCustomArn, region) @@ -219,8 +67,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH ctx: "bedrock", errorMessage: this.arnInfo.errorMessage, }) - - // Throw a consistent error with a prefix that can be detected by callers const errorMessage = this.arnInfo.errorMessage || "Invalid ARN format. ARN should follow the pattern: arn:aws:bedrock:region:account-id:resource-type/resource-name" @@ -228,21 +74,16 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH } if (this.arnInfo.region && this.arnInfo.region !== this.options.awsRegion) { - // Log if there's a region mismatch between the ARN and the region selected by the user - // We will use the ARNs region, so execution can continue, but log an info statement. - // Log a warning if there's a region mismatch between the ARN and the region selected by the user - // We will use the ARNs region, so execution can continue, but log an info statement. logger.info(this.arnInfo.errorMessage, { ctx: "bedrock", selectedRegion: this.options.awsRegion, arnRegion: this.arnInfo.region, }) - this.options.awsRegion = this.arnInfo.region } this.options.apiModelId = this.arnInfo.modelId - if (this.arnInfo.awsUseCrossRegionInference) this.options.awsUseCrossRegionInference = true + if (this.arnInfo.crossRegionInference) this.options.awsUseCrossRegionInference = true } if (!this.options.modelTemperature) { @@ -251,44 +92,46 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH this.costModelConfig = this.getModel() - const clientConfig: BedrockRuntimeClientConfig = { - userAgentAppId: `RooCode#${Package.version}`, + // Build provider settings for AI SDK + const providerSettings: Parameters[0] = { region: this.options.awsRegion, - // Add the endpoint configuration when specified and enabled + headers: { + ...DEFAULT_HEADERS, + "User-Agent": `RooCode#${Package.version}`, + }, + // Add VPC endpoint if specified and enabled ...(this.options.awsBedrockEndpoint && - this.options.awsBedrockEndpointEnabled && { endpoint: this.options.awsBedrockEndpoint }), + this.options.awsBedrockEndpointEnabled && { baseURL: this.options.awsBedrockEndpoint }), } if (this.options.awsUseApiKey && this.options.awsApiKey) { - // Use API key/token-based authentication if enabled and API key is set - clientConfig.token = { token: this.options.awsApiKey } - clientConfig.authSchemePreference = ["httpBearerAuth"] // Otherwise there's no end of credential problems. - clientConfig.requestHandler = { - // This should be the default anyway, but without setting something - // this provider fails to work with LiteLLM passthrough. - requestTimeout: 0, - } + // Use API key/token-based authentication + providerSettings.apiKey = this.options.awsApiKey } else if (this.options.awsUseProfile && this.options.awsProfile) { - // Use profile-based credentials if enabled and profile is set - clientConfig.credentials = fromIni({ - profile: this.options.awsProfile, - ignoreCache: true, - }) + // Use profile-based credentials via credentialProvider + const profile = this.options.awsProfile + providerSettings.credentialProvider = async () => { + const creds = await fromIni({ profile, ignoreCache: true })() + return { + accessKeyId: creds.accessKeyId, + secretAccessKey: creds.secretAccessKey, + ...(creds.sessionToken ? { sessionToken: creds.sessionToken } : {}), + } + } } else if (this.options.awsAccessKey && this.options.awsSecretKey) { - // Use direct credentials if provided - clientConfig.credentials = { - accessKeyId: this.options.awsAccessKey, - secretAccessKey: this.options.awsSecretKey, - ...(this.options.awsSessionToken ? { sessionToken: this.options.awsSessionToken } : {}), + // Use direct credentials + providerSettings.accessKeyId = this.options.awsAccessKey + providerSettings.secretAccessKey = this.options.awsSecretKey + if (this.options.awsSessionToken) { + providerSettings.sessionToken = this.options.awsSessionToken } } - this.client = new BedrockRuntimeClient(clientConfig) + this.provider = createAmazonBedrock(providerSettings) } // Helper to guess model info from custom modelId string if not in bedrockModels private guessModelInfoFromId(modelId: string): Partial { - // Define a mapping for model ID patterns and their configurations const modelConfigMap: Record> = { "claude-4": { maxTokens: 8192, @@ -328,7 +171,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH }, } - // Match the model ID to a configuration const id = modelId.toLowerCase() for (const [pattern, config] of Object.entries(modelConfigMap)) { if (id.includes(pattern)) { @@ -336,7 +178,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH } } - // Default fallback return { maxTokens: BEDROCK_MAX_TOKENS, contextWindow: BEDROCK_DEFAULT_CONTEXT, @@ -348,594 +189,343 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH override async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata & { - thinking?: { - enabled: boolean - maxTokens?: number - maxThinkingTokens?: number - } - }, + metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const modelConfig = this.getModel() - const usePromptCache = Boolean(this.options.awsUsePromptCache && this.supportsAwsPromptCache(modelConfig)) - const conversationId = - messages.length > 0 - ? `conv_${messages[0].role}_${ - typeof messages[0].content === "string" - ? messages[0].content.substring(0, 20) - : "complex_content" - }` - : "default_conversation" + // Reset thinking state for this request + this.lastThoughtSignature = undefined + this.lastRedactedThinkingBlocks = [] - const formatted = this.convertToBedrockConverseMessages( - messages, - systemPrompt, - usePromptCache, - modelConfig.info, - conversationId, - ) + // Filter out provider-specific meta entries (e.g., { type: "reasoning" }) + // that are not valid Anthropic MessageParam values + type ReasoningMetaLike = { type?: string } + const filteredMessages = messages.filter((message): message is Anthropic.Messages.MessageParam => { + const meta = message as ReasoningMetaLike + if (meta.type === "reasoning") { + return false + } + return true + }) - let additionalModelRequestFields: BedrockAdditionalModelFields | undefined - let thinkingEnabled = false + // Convert messages to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(filteredMessages) - // Determine if thinking should be enabled - // metadata?.thinking?.enabled: Explicitly enabled through API metadata (direct request) - // shouldUseReasoningBudget(): Enabled through user settings (enableReasoningEffort = true) - const isThinkingExplicitlyEnabled = metadata?.thinking?.enabled + // Convert tools to AI SDK format + let openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + const toolChoice = mapToolChoice(metadata?.tool_choice) + + // Build provider options for reasoning, betas, etc. + const bedrockProviderOptions: Record = {} + + // Extended thinking / reasoning configuration const isThinkingEnabledBySettings = shouldUseReasoningBudget({ model: modelConfig.info, settings: this.options }) && modelConfig.reasoning && modelConfig.reasoningBudget - if ((isThinkingExplicitlyEnabled || isThinkingEnabledBySettings) && modelConfig.info.supportsReasoningBudget) { - thinkingEnabled = true - additionalModelRequestFields = { - thinking: { - type: "enabled", - budget_tokens: metadata?.thinking?.maxThinkingTokens || modelConfig.reasoningBudget || 4096, - }, + if (isThinkingEnabledBySettings && modelConfig.info.supportsReasoningBudget) { + bedrockProviderOptions.reasoningConfig = { + type: "enabled", + budgetTokens: modelConfig.reasoningBudget, } - logger.info("Extended thinking enabled for Bedrock request", { - ctx: "bedrock", - modelId: modelConfig.id, - thinking: additionalModelRequestFields.thinking, - }) } - const inferenceConfig: BedrockInferenceConfig = { - maxTokens: modelConfig.maxTokens || (modelConfig.info.maxTokens as number), - temperature: modelConfig.temperature ?? (this.options.modelTemperature as number), - } - - // Check if 1M context is enabled for Claude Sonnet 4 - // Use parseBaseModelId to handle cross-region inference prefixes - const baseModelId = this.parseBaseModelId(modelConfig.id) - const is1MContextEnabled = - BEDROCK_1M_CONTEXT_MODEL_IDS.includes(baseModelId as any) && this.options.awsBedrock1MContext - - // Determine if service tier should be applied (checked later when building payload) - const useServiceTier = - this.options.awsBedrockServiceTier && BEDROCK_SERVICE_TIER_MODEL_IDS.includes(baseModelId as any) - if (useServiceTier) { - logger.info("Service tier specified for Bedrock request", { - ctx: "bedrock", - modelId: modelConfig.id, - serviceTier: this.options.awsBedrockServiceTier, - }) - } - - // Add anthropic_beta headers for various features - // Start with an empty array and add betas as needed + // Anthropic beta headers for various features const anthropicBetas: string[] = [] + const baseModelId = this.parseBaseModelId(modelConfig.id) // Add 1M context beta if enabled - if (is1MContextEnabled) { + if (BEDROCK_1M_CONTEXT_MODEL_IDS.includes(baseModelId as any) && this.options.awsBedrock1MContext) { anthropicBetas.push("context-1m-2025-08-07") } - // Add fine-grained tool streaming beta for Claude models - // This enables proper tool use streaming for Anthropic models on Bedrock - if (baseModelId.includes("claude")) { - anthropicBetas.push("fine-grained-tool-streaming-2025-05-14") - } - - // Apply anthropic_beta to additionalModelRequestFields if any betas are needed if (anthropicBetas.length > 0) { - if (!additionalModelRequestFields) { - additionalModelRequestFields = {} as BedrockAdditionalModelFields + bedrockProviderOptions.anthropicBeta = anthropicBetas + } + + // Additional model request fields (service tier, etc.) + // Note: The AI SDK may not directly support service_tier as a top-level param, + // so we pass it through additionalModelRequestFields + if (this.options.awsBedrockServiceTier && BEDROCK_SERVICE_TIER_MODEL_IDS.includes(baseModelId as any)) { + bedrockProviderOptions.additionalModelRequestFields = { + ...(bedrockProviderOptions.additionalModelRequestFields as Record | undefined), + service_tier: this.options.awsBedrockServiceTier, } - additionalModelRequestFields.anthropic_beta = anthropicBetas } - const toolConfig: ToolConfiguration = { - tools: this.convertToolsForBedrock(metadata?.tools ?? []), - toolChoice: this.convertToolChoiceForBedrock(metadata?.tool_choice), - } + // Prompt caching: use AI SDK's cachePoint mechanism + // The AI SDK's @ai-sdk/amazon-bedrock supports cachePoint in providerOptions per message. + // + // Strategy: Bedrock allows up to 4 cache checkpoints. We use them as: + // 1. System prompt (via systemProviderOptions below) + // 2-4. Up to 3 user messages in the conversation history + // + // For the message cache points, we target the last 2 user messages (matching + // Anthropic's strategy: write-to-cache + read-from-cache) PLUS an earlier "anchor" + // user message near the middle of the conversation. This anchor ensures the 20-block + // lookback window has a stable cache entry to hit, covering all assistant/tool messages + // between the anchor and the recent messages. + // + // We identify targets in the ORIGINAL Anthropic messages (before AI SDK conversion) + // because convertToAiSdkMessages() splits user messages containing tool_results into + // separate "tool" + "user" role messages, which would skew naive counting. + const usePromptCache = Boolean(this.options.awsUsePromptCache && this.supportsAwsPromptCache(modelConfig)) - // Build payload with optional service_tier at top level - // Service tier is a top-level parameter per AWS documentation, NOT inside additionalModelRequestFields - // https://docs.aws.amazon.com/bedrock/latest/userguide/service-tiers-inference.html - const payload: BedrockPayloadWithServiceTier = { - modelId: modelConfig.id, - messages: formatted.messages, - system: formatted.system, - inferenceConfig, - ...(additionalModelRequestFields && { additionalModelRequestFields }), - // Add anthropic_version at top level when using thinking features - ...(thinkingEnabled && { anthropic_version: "bedrock-2023-05-31" }), - toolConfig, - // Add service_tier as a top-level parameter (not inside additionalModelRequestFields) - ...(useServiceTier && { service_tier: this.options.awsBedrockServiceTier }), - } + if (usePromptCache) { + const cachePointOption = { bedrock: { cachePoint: { type: "default" as const } } } - // Create AbortController with 10 minute timeout - const controller = new AbortController() - let timeoutId: NodeJS.Timeout | undefined - - try { - timeoutId = setTimeout( - () => { - controller.abort() - }, - 10 * 60 * 1000, + // Find all user message indices in the original (pre-conversion) message array. + const originalUserIndices = filteredMessages.reduce( + (acc, msg, idx) => (msg.role === "user" ? [...acc, idx] : acc), + [], ) - const command = new ConverseStreamCommand(payload) - const response = await this.client.send(command, { - abortSignal: controller.signal, - }) + // Select up to 3 user messages for cache points (system prompt uses the 4th): + // - Last user message: write to cache for next request + // - Second-to-last user message: read from cache for current request + // - An "anchor" message earlier in the conversation for 20-block window coverage + const targetOriginalIndices = new Set() + const numUserMsgs = originalUserIndices.length - if (!response.stream) { - clearTimeout(timeoutId) - throw new Error("No stream available in the response") + if (numUserMsgs >= 1) { + // Always cache the last user message + targetOriginalIndices.add(originalUserIndices[numUserMsgs - 1]) + } + if (numUserMsgs >= 2) { + // Cache the second-to-last user message + targetOriginalIndices.add(originalUserIndices[numUserMsgs - 2]) + } + if (numUserMsgs >= 5) { + // Add an anchor cache point roughly in the first third of user messages. + // This ensures that the 20-block lookback from the second-to-last breakpoint + // can find a stable cache entry, covering all the assistant and tool messages + // in the middle of the conversation. We pick the user message at ~1/3 position. + const anchorIdx = Math.floor(numUserMsgs / 3) + // Only add if it's not already one of the last-2 targets + if (!targetOriginalIndices.has(originalUserIndices[anchorIdx])) { + targetOriginalIndices.add(originalUserIndices[anchorIdx]) + } } - for await (const chunk of response.stream) { - // Parse the chunk as JSON if it's a string (for tests) - let streamEvent: StreamEvent - try { - streamEvent = typeof chunk === "string" ? JSON.parse(chunk) : (chunk as unknown as StreamEvent) - } catch (e) { - logger.error("Failed to parse stream event", { + // Apply cachePoint to the correct AI SDK messages by walking both arrays in parallel. + // A single original user message with tool_results becomes [tool-role msg, user-role msg] + // in the AI SDK array, while a plain user message becomes [user-role msg]. + if (targetOriginalIndices.size > 0) { + this.applyCachePointsToAiSdkMessages( + filteredMessages, + aiSdkMessages, + targetOriginalIndices, + cachePointOption, + ) + } + } + + // Build streamText request + // Cast providerOptions to any to bypass strict JSONObject typing — the AI SDK accepts the correct runtime values + const requestOptions: Parameters[0] = { + model: this.provider(modelConfig.id), + system: systemPrompt, + ...(usePromptCache && { + systemProviderOptions: { bedrock: { cachePoint: { type: "default" } } } as Record, + }), + messages: aiSdkMessages, + temperature: modelConfig.temperature ?? (this.options.modelTemperature as number), + maxOutputTokens: modelConfig.maxTokens || (modelConfig.info.maxTokens as number), + tools: aiSdkTools, + toolChoice, + ...(Object.keys(bedrockProviderOptions).length > 0 && { + providerOptions: { bedrock: bedrockProviderOptions } as any, + }), + } + + try { + const result = streamText(requestOptions) + + // Process the full stream + for await (const part of result.fullStream) { + // Capture thinking signature from stream events. + // The AI SDK's @ai-sdk/amazon-bedrock emits the signature as a reasoning-delta + // event with providerMetadata.bedrock.signature (empty delta text, signature in metadata). + // Also check tool-call events for thoughtSignature (Gemini pattern). + const partAny = part as any + if (partAny.providerMetadata?.bedrock?.signature) { + this.lastThoughtSignature = partAny.providerMetadata.bedrock.signature + logger.info("Captured thinking signature from stream", { ctx: "bedrock", - error: e instanceof Error ? e : String(e), - chunk: typeof chunk === "string" ? chunk : "binary data", + signatureLength: this.lastThoughtSignature?.length, }) - continue + } else if (partAny.providerMetadata?.bedrock?.thoughtSignature) { + this.lastThoughtSignature = partAny.providerMetadata.bedrock.thoughtSignature + } else if (partAny.providerMetadata?.anthropic?.thoughtSignature) { + this.lastThoughtSignature = partAny.providerMetadata.anthropic.thoughtSignature } - // Handle metadata events first - if (streamEvent.metadata?.usage) { - const usage = (streamEvent.metadata?.usage || {}) as UsageType - - // Check both field naming conventions for cache tokens - const cacheReadTokens = usage.cacheReadInputTokens || usage.cacheReadInputTokenCount || 0 - const cacheWriteTokens = usage.cacheWriteInputTokens || usage.cacheWriteInputTokenCount || 0 - - // Always include all available token information - yield { - type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, - cacheReadTokens: cacheReadTokens, - cacheWriteTokens: cacheWriteTokens, - } - continue + // Capture redacted reasoning data from stream events + if (partAny.providerMetadata?.bedrock?.redactedData) { + this.lastRedactedThinkingBlocks.push({ + type: "redacted_thinking", + data: partAny.providerMetadata.bedrock.redactedData, + }) } - if (streamEvent?.trace?.promptRouter?.invokedModelId) { - try { - //update the in-use model info to be based on the invoked Model Id for the router - //so that pricing, context window, caching etc have values that can be used - //However, we want to keep the id of the model to be the ID for the router for - //subsequent requests so they are sent back through the router - let invokedArnInfo = this.parseArn(streamEvent.trace.promptRouter.invokedModelId) - let invokedModel = this.getModelById(invokedArnInfo.modelId as string, invokedArnInfo.modelType) - if (invokedModel) { - invokedModel.id = modelConfig.id - this.costModelConfig = invokedModel - } - - // Handle metadata events for the promptRouter. - if (streamEvent?.trace?.promptRouter?.usage) { - const routerUsage = streamEvent.trace.promptRouter.usage - - // Check both field naming conventions for cache tokens - const cacheReadTokens = - routerUsage.cacheReadTokens || routerUsage.cacheReadInputTokenCount || 0 - const cacheWriteTokens = - routerUsage.cacheWriteTokens || routerUsage.cacheWriteInputTokenCount || 0 - - yield { - type: "usage", - inputTokens: routerUsage.inputTokens || 0, - outputTokens: routerUsage.outputTokens || 0, - cacheReadTokens: cacheReadTokens, - cacheWriteTokens: cacheWriteTokens, - } - } - } catch (error) { - logger.error("Error handling Bedrock invokedModelId", { - ctx: "bedrock", - error: error instanceof Error ? error : String(error), - }) - } finally { - // eslint-disable-next-line no-unsafe-finally - continue - } - } - - // Handle message start - if (streamEvent.messageStart) { - continue - } - - // Handle content blocks - if (streamEvent.contentBlockStart) { - const cbStart = streamEvent.contentBlockStart - - // Check if this is a reasoning block (AWS SDK structure) - if (cbStart.contentBlock?.reasoningContent) { - if (cbStart.contentBlockIndex && cbStart.contentBlockIndex > 0) { - yield { type: "reasoning", text: "\n" } - } - yield { - type: "reasoning", - text: cbStart.contentBlock.reasoningContent.text || "", - } - } - // Check for thinking block - handle both possible AWS SDK structures - // cbStart.contentBlock: newer structure - // cbStart.content_block: alternative structure seen in some AWS SDK versions - else if (cbStart.contentBlock?.type === "thinking" || cbStart.content_block?.type === "thinking") { - const contentBlock = cbStart.contentBlock || cbStart.content_block - if (cbStart.contentBlockIndex && cbStart.contentBlockIndex > 0) { - yield { type: "reasoning", text: "\n" } - } - if (contentBlock?.thinking) { - yield { - type: "reasoning", - text: contentBlock.thinking, - } - } - } - // Handle tool use block start - else if (cbStart.start?.toolUse || cbStart.contentBlock?.toolUse) { - const toolUse = cbStart.start?.toolUse || cbStart.contentBlock?.toolUse - if (toolUse) { - yield { - type: "tool_call_partial", - index: cbStart.contentBlockIndex ?? 0, - id: toolUse.toolUseId, - name: toolUse.name, - arguments: undefined, - } - } - } else if (cbStart.start?.text) { - yield { - type: "text", - text: cbStart.start.text, - } - } - continue - } - - // Handle content deltas - if (streamEvent.contentBlockDelta) { - const cbDelta = streamEvent.contentBlockDelta - const delta = cbDelta.delta - - // Process reasoning and text content deltas - // Multiple structures are supported for AWS SDK compatibility: - // - delta.reasoningContent.text: AWS docs structure for reasoning - // - delta.thinking: alternative structure for thinking content - // - delta.text: standard text content - // - delta.toolUse.input: tool input arguments - if (delta) { - // Check for reasoningContent property (AWS SDK structure) - if (delta.reasoningContent?.text) { - yield { - type: "reasoning", - text: delta.reasoningContent.text, - } - continue - } - - // Handle tool use input delta - if (delta.toolUse?.input) { - yield { - type: "tool_call_partial", - index: cbDelta.contentBlockIndex ?? 0, - id: undefined, - name: undefined, - arguments: delta.toolUse.input, - } - continue - } - - // Handle alternative thinking structure (fallback for older SDK versions) - if (delta.type === "thinking_delta" && delta.thinking) { - yield { - type: "reasoning", - text: delta.thinking, - } - } else if (delta.text) { - yield { - type: "text", - text: delta.text, - } - } - } - continue - } - // Handle message stop - if (streamEvent.messageStop) { - continue + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk } } - // Clear timeout after stream completes - clearTimeout(timeoutId) - } catch (error: unknown) { - // Clear timeout on error - clearTimeout(timeoutId) - // Capture error in telemetry before processing + // Yield usage metrics at the end + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, modelConfig.info, providerMetadata) + } + } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) const apiError = new ApiProviderError(errorMessage, this.providerName, modelConfig.id, "createMessage") TelemetryService.instance.captureException(apiError) - // Check if this is a throttling error that should trigger retry logic - const errorType = this.getErrorType(error) - - // For throttling errors, throw immediately without yielding chunks - // This allows the retry mechanism in attemptApiRequest() to catch and handle it - // The retry logic in Task.ts (around line 1817) expects errors to be thrown - // on the first chunk for proper exponential backoff behavior - if (errorType === "THROTTLING") { + // Check for throttling errors that should trigger retry (re-throw original to preserve status) + if (this.isThrottlingError(error)) { if (error instanceof Error) { throw error - } else { - throw new Error("Throttling error occurred") } + throw new Error("Throttling error occurred") } - // For non-throttling errors, use the standard error handling with chunks - const errorChunks = this.handleBedrockError(error, true) // true for streaming context - // Yield each chunk individually to ensure type compatibility - for (const chunk of errorChunks) { - yield chunk as any // Cast to any to bypass type checking since we know the structure is correct - } - - // Re-throw with enhanced error message for retry system - const enhancedErrorMessage = this.formatErrorMessage(error, this.getErrorType(error), true) - if (error instanceof Error) { - const enhancedError = new Error(enhancedErrorMessage) - // Preserve important properties from the original error - enhancedError.name = error.name - // Validate and preserve status property - if ("status" in error && typeof (error as any).status === "number") { - ;(enhancedError as any).status = (error as any).status - } - // Validate and preserve $metadata property - if ( - "$metadata" in error && - typeof (error as any).$metadata === "object" && - (error as any).$metadata !== null - ) { - ;(enhancedError as any).$metadata = (error as any).$metadata - } - throw enhancedError - } else { - throw new Error("An unknown error occurred") - } - } - } - - async completePrompt(prompt: string): Promise { - try { - const modelConfig = this.getModel() - - // For completePrompt, thinking is typically not used, but we should still check - // if thinking was somehow enabled in the model config - const thinkingEnabled = - shouldUseReasoningBudget({ model: modelConfig.info, settings: this.options }) && - modelConfig.reasoning && - modelConfig.reasoningBudget - - const inferenceConfig: BedrockInferenceConfig = { - maxTokens: modelConfig.maxTokens || (modelConfig.info.maxTokens as number), - temperature: modelConfig.temperature ?? (this.options.modelTemperature as number), - } - - // For completePrompt, use a unique conversation ID based on the prompt - const conversationId = `prompt_${prompt.substring(0, 20)}` - - const payload = { - modelId: modelConfig.id, - messages: this.convertToBedrockConverseMessages( - [ - { - role: "user", - content: prompt, - }, - ], - undefined, - false, - modelConfig.info, - conversationId, - ).messages, - inferenceConfig, - } - - const command = new ConverseCommand(payload) - const response = await this.client.send(command) - - if ( - response?.output?.message?.content && - response.output.message.content.length > 0 && - response.output.message.content[0].text && - response.output.message.content[0].text.trim().length > 0 - ) { - try { - return response.output.message.content[0].text - } catch (parseError) { - logger.error("Failed to parse Bedrock response", { - ctx: "bedrock", - error: parseError instanceof Error ? parseError : String(parseError), - }) - } - } - return "" - } catch (error) { - // Capture error in telemetry - const model = this.getModel() - const telemetryErrorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(telemetryErrorMessage, this.providerName, model.id, "completePrompt") - TelemetryService.instance.captureException(apiError) - - // Use the extracted error handling method for all errors - const errorResult = this.handleBedrockError(error, false) // false for non-streaming context - // Since we're in a non-streaming context, we know the result is a string - const errorMessage = errorResult as string - - // Create enhanced error for retry system - const enhancedError = new Error(errorMessage) - if (error instanceof Error) { - // Preserve important properties from the original error - enhancedError.name = error.name - // Validate and preserve status property - if ("status" in error && typeof (error as any).status === "number") { - ;(enhancedError as any).status = (error as any).status - } - // Validate and preserve $metadata property - if ( - "$metadata" in error && - typeof (error as any).$metadata === "object" && - (error as any).$metadata !== null - ) { - ;(enhancedError as any).$metadata = (error as any).$metadata - } - } - throw enhancedError + // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) + throw handleAiSdkError(error, this.providerName) } } /** - * Convert Anthropic messages to Bedrock Converse format + * Process usage metrics from the AI SDK response. */ - private convertToBedrockConverseMessages( - anthropicMessages: Anthropic.Messages.MessageParam[] | { role: string; content: string }[], - systemMessage?: string, - usePromptCache: boolean = false, - modelInfo?: any, - conversationId?: string, // Optional conversation ID to track cache points across messages - ): { system: SystemContentBlock[]; messages: Message[] } { - // First convert messages using shared converter for proper image handling - const convertedMessages = sharedConverter(anthropicMessages as Anthropic.Messages.MessageParam[]) + private processUsageMetrics( + usage: { inputTokens?: number; outputTokens?: number }, + info: ModelInfo, + providerMetadata?: Record>, + ): ApiStreamUsageChunk { + const inputTokens = usage.inputTokens ?? 0 + const outputTokens = usage.outputTokens ?? 0 - // If prompt caching is disabled, return the converted messages directly - if (!usePromptCache) { - return { - system: systemMessage ? [{ text: systemMessage } as SystemContentBlock] : [], - messages: convertedMessages, - } - } + // The AI SDK exposes reasoningTokens as a top-level field on usage, and also + // under outputTokenDetails.reasoningTokens — there is no .details property. + const reasoningTokens = + (usage as any).reasoningTokens ?? (usage as any).outputTokenDetails?.reasoningTokens ?? 0 - // Convert model info to expected format for cache strategy - const cacheModelInfo: CacheModelInfo = { - maxTokens: modelInfo?.maxTokens || 8192, - contextWindow: modelInfo?.contextWindow || 200_000, - supportsPromptCache: modelInfo?.supportsPromptCache || false, - maxCachePoints: modelInfo?.maxCachePoints || 0, - minTokensPerCachePoint: modelInfo?.minTokensPerCachePoint || 50, - cachableFields: modelInfo?.cachableFields || [], - } + // Extract cache metrics primarily from usage (AI SDK standard locations), + // falling back to providerMetadata.bedrock.usage for provider-specific fields. + const bedrockUsage = providerMetadata?.bedrock?.usage as + | { cacheReadInputTokens?: number; cacheWriteInputTokens?: number } + | undefined + const cacheReadTokens = + (usage as any).inputTokenDetails?.cacheReadTokens ?? + (usage as any).cachedInputTokens ?? + bedrockUsage?.cacheReadInputTokens ?? + 0 + const cacheWriteTokens = + (usage as any).inputTokenDetails?.cacheWriteTokens ?? bedrockUsage?.cacheWriteInputTokens ?? 0 - // Get previous cache point placements for this conversation if available - const previousPlacements = - conversationId && this.previousCachePointPlacements[conversationId] - ? this.previousCachePointPlacements[conversationId] - : undefined - - // Create config for cache strategy - const config = { - modelInfo: cacheModelInfo, - systemPrompt: systemMessage, - messages: anthropicMessages as Anthropic.Messages.MessageParam[], - usePromptCache, - previousCachePointPlacements: previousPlacements, - } - - // Get cache point placements - let strategy = new MultiPointStrategy(config) - const cacheResult = strategy.determineOptimalCachePoints() - - // Store cache point placements for future use if conversation ID is provided - if (conversationId && cacheResult.messageCachePointPlacements) { - this.previousCachePointPlacements[conversationId] = cacheResult.messageCachePointPlacements - } - - // Apply cache points to the properly converted messages - const messagesWithCache = convertedMessages.map((msg, index) => { - const placement = cacheResult.messageCachePointPlacements?.find((p) => p.index === index) - if (placement) { - return { - ...msg, - content: [...(msg.content || []), { cachePoint: { type: "default" } } as ContentBlock], + // For prompt routers, the AI SDK surfaces the invoked model ID in + // providerMetadata.bedrock.trace.promptRouter.invokedModelId. + // When present, look up that model's pricing info for accurate cost calculation. + const invokedModelId = (providerMetadata?.bedrock as any)?.trace?.promptRouter?.invokedModelId as + | string + | undefined + let costInfo = info + if (invokedModelId) { + try { + const invokedArnInfo = this.parseArn(invokedModelId) + const invokedModel = this.getModelById(invokedArnInfo.modelId as string, invokedArnInfo.modelType) + if (invokedModel) { + // Update costModelConfig so subsequent requests use the invoked model's pricing, + // but keep the router's ID so requests continue through the router. + invokedModel.id = this.costModelConfig.id || invokedModel.id + this.costModelConfig = invokedModel + costInfo = invokedModel.info } + } catch (error) { + logger.error("Error handling Bedrock invokedModelId", { + ctx: "bedrock", + error: error instanceof Error ? error : String(error), + }) } - return msg - }) + } return { - system: cacheResult.system, - messages: messagesWithCache, + type: "usage", + inputTokens, + outputTokens, + cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, + cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, + reasoningTokens: reasoningTokens > 0 ? reasoningTokens : undefined, + totalCost: this.calculateCost({ + inputTokens, + outputTokens, + cacheWriteTokens, + cacheReadTokens, + reasoningTokens, + info: costInfo, + }), + } + } + + /** + * Check if an error is a throttling/rate limit error + */ + private isThrottlingError(error: unknown): boolean { + if (!(error instanceof Error)) return false + if ((error as any).status === 429 || (error as any).$metadata?.httpStatusCode === 429) return true + if ((error as any).name === "ThrottlingException") return true + const msg = error.message.toLowerCase() + return ( + msg.includes("throttl") || + msg.includes("rate limit") || + msg.includes("too many requests") || + msg.includes("bedrock is unable to process your request") + ) + } + + async completePrompt(prompt: string): Promise { + const modelConfig = this.getModel() + + try { + const result = await generateText({ + model: this.provider(modelConfig.id), + prompt, + temperature: modelConfig.temperature ?? (this.options.modelTemperature as number), + maxOutputTokens: modelConfig.maxTokens || (modelConfig.info.maxTokens as number), + }) + + return result.text + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, modelConfig.id, "completePrompt") + TelemetryService.instance.captureException(apiError) + + // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) + throw handleAiSdkError(error, this.providerName) } } /************************************************************************************ * - * MODEL IDENTIFICATION + * MODEL CONFIGURATION * *************************************************************************************/ private costModelConfig: { id: BedrockModelId | string; info: ModelInfo } = { id: "", - info: { maxTokens: 0, contextWindow: 0, supportsPromptCache: false, supportsImages: false }, + info: { maxTokens: 0, contextWindow: 0, supportsPromptCache: false }, } private parseArn(arn: string, region?: string) { - /* - * VIA Roo analysis: platform-independent Regex. It's designed to parse Amazon Bedrock ARNs and doesn't rely on any platform-specific features - * like file path separators, line endings, or case sensitivity behaviors. The forward slashes in the regex are properly escaped and - * represent literal characters in the AWS ARN format, not filesystem paths. This regex will function consistently across Windows, - * macOS, Linux, and any other operating system where JavaScript runs. - * - * Supports any AWS partition (aws, aws-us-gov, aws-cn, or future partitions). - * The partition is not captured since we don't need to use it. - * - * This matches ARNs like: - * - Foundation Model: arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-v2 - * - GovCloud Inference Profile: arn:aws-us-gov:bedrock:us-gov-west-1:123456789012:inference-profile/us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0 - * - Prompt Router: arn:aws:bedrock:us-west-2:123456789012:prompt-router/anthropic-claude - * - Inference Profile: arn:aws:bedrock:us-west-2:123456789012:inference-profile/anthropic.claude-v2 - * - Cross Region Inference Profile: arn:aws:bedrock:us-west-2:123456789012:inference-profile/us.anthropic.claude-3-5-sonnet-20241022-v2:0 - * - Custom Model (Provisioned Throughput): arn:aws:bedrock:us-west-2:123456789012:provisioned-model/my-custom-model - * - Imported Model: arn:aws:bedrock:us-west-2:123456789012:imported-model/my-imported-model - * - * match[0] - The entire matched string - * match[1] - The region (e.g., "us-east-1", "us-gov-west-1") - * match[2] - The account ID (can be empty string for AWS-managed resources) - * match[3] - The resource type (e.g., "foundation-model") - * match[4] - The resource ID (e.g., "anthropic.claude-3-sonnet-20240229-v1:0") - */ - const arnRegex = /^arn:[^:]+:(?:bedrock|sagemaker):([^:]+):([^:]*):(?:([^\/]+)\/([\w\.\-:]+)|([^\/]+))$/ let match = arn.match(arnRegex) if (match && match[1] && match[3] && match[4]) { - // Create the result object const result: { isValid: boolean region?: string @@ -945,25 +535,21 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH crossRegionInference: boolean } = { isValid: true, - crossRegionInference: false, // Default to false + crossRegionInference: false, } result.modelType = match[3] const originalModelId = match[4] result.modelId = this.parseBaseModelId(originalModelId) - // Extract the region from the first capture group const arnRegion = match[1] result.region = arnRegion - // Check if the original model ID had a region prefix if (originalModelId && result.modelId !== originalModelId) { - // If the model ID changed after parsing, it had a region prefix let prefix = originalModelId.replace(result.modelId, "") result.crossRegionInference = AwsBedrockHandler.isSystemInferenceProfile(prefix) } - // Check if region in ARN matches provided region (if specified) if (region && arnRegion !== region) { result.errorMessage = `Region mismatch: The region in your ARN (${arnRegion}) does not match your selected region (${region}). This may cause access issues. The provider will use the region from the ARN.` result.region = arnRegion @@ -972,7 +558,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH return result } - // If we get here, the regex didn't match return { isValid: false, region: undefined, @@ -983,40 +568,27 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH } } - //This strips any region prefix that used on cross-region model inference ARNs private parseBaseModelId(modelId: string): string { - if (!modelId) { - return modelId - } + if (!modelId) return modelId - // Remove AWS cross-region inference profile prefixes - // as defined in AWS_INFERENCE_PROFILE_MAPPING for (const [_, inferenceProfile] of AWS_INFERENCE_PROFILE_MAPPING) { if (modelId.startsWith(inferenceProfile)) { - // Remove the inference profile prefix from the model ID return modelId.substring(inferenceProfile.length) } } - // Also strip Global Inference profile prefix if present if (modelId.startsWith("global.")) { return modelId.substring("global.".length) } - // Return the model ID as-is for all other cases return modelId } - //Prompt Router responses come back in a different sequence and the model used is in the response and must be fetched by name getModelById(modelId: string, modelType?: string): { id: BedrockModelId | string; info: ModelInfo } { - // Try to find the model in bedrockModels const baseModelId = this.parseBaseModelId(modelId) as BedrockModelId let model if (baseModelId in bedrockModels) { - //Do a deep copy of the model info so that later in the code the model id and maxTokens can be set. - // The bedrockModels array is a constant and updating the model ID from the returned invokedModelID value - // in a prompt router response isn't possible on the constant. model = { id: baseModelId, info: JSON.parse(JSON.stringify(bedrockModels[baseModelId])) } } else if (modelType && modelType.includes("router")) { model = { @@ -1024,7 +596,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH info: JSON.parse(JSON.stringify(bedrockModels[bedrockDefaultPromptRouterModelId])), } } else { - // Use heuristics for model info, then allow overrides from ProviderSettings const guessed = this.guessModelInfoFromId(modelId) model = { id: bedrockDefaultModelId, @@ -1035,7 +606,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH } } - // Always allow user to override detected/guessed maxTokens and contextWindow if (this.options.modelMaxTokens && this.options.modelMaxTokens > 0) { model.info.maxTokens = this.options.modelMaxTokens } @@ -1055,7 +625,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH reasoningBudget?: number } { if (this.costModelConfig?.id?.trim().length > 0) { - // Get model params for cost model config const params = getModelParams({ format: "anthropic", modelId: this.costModelConfig.id, @@ -1068,28 +637,19 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH let modelConfig = undefined - // If custom ARN is provided, use it if (this.options.awsCustomArn) { modelConfig = this.getModelById(this.arnInfo.modelId, this.arnInfo.modelType) - - //If the user entered an ARN for a foundation-model they've done the same thing as picking from our list of options. - //We leave the model data matching the same as if a drop-down input method was used by not overwriting the model ID with the user input ARN - //Otherwise the ARN is not a foundation-model resource type that ARN should be used as the identifier in Bedrock interactions if (this.arnInfo.modelType !== "foundation-model") modelConfig.id = this.options.awsCustomArn } else { - //a model was selected from the drop down modelConfig = this.getModelById(this.options.apiModelId as string) - // Apply Global Inference prefix if enabled and supported (takes precedence over cross-region) const baseIdForGlobal = this.parseBaseModelId(modelConfig.id) if ( this.options.awsUseGlobalInference && BEDROCK_GLOBAL_INFERENCE_MODEL_IDS.includes(baseIdForGlobal as any) ) { modelConfig.id = `global.${baseIdForGlobal}` - } - // Otherwise, add cross-region inference prefix if enabled - else if (this.options.awsUseCrossRegionInference && this.options.awsRegion) { + } else if (this.options.awsUseCrossRegionInference && this.options.awsRegion) { const prefix = AwsBedrockHandler.getPrefixForRegion(this.options.awsRegion) if (prefix) { modelConfig.id = `${prefix}${modelConfig.id}` @@ -1097,18 +657,20 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH } } - // Check if 1M context is enabled for Claude Sonnet 4 / 4.5 - // Use parseBaseModelId to handle cross-region inference prefixes + // Check if 1M context is enabled const baseModelId = this.parseBaseModelId(modelConfig.id) if (BEDROCK_1M_CONTEXT_MODEL_IDS.includes(baseModelId as any) && this.options.awsBedrock1MContext) { - // Update context window to 1M tokens when 1M context beta is enabled + const tier = modelConfig.info.tiers?.[0] modelConfig.info = { ...modelConfig.info, - contextWindow: 1_000_000, + contextWindow: tier?.contextWindow ?? 1_000_000, + inputPrice: tier?.inputPrice ?? modelConfig.info.inputPrice, + outputPrice: tier?.outputPrice ?? modelConfig.info.outputPrice, + cacheWritesPrice: tier?.cacheWritesPrice ?? modelConfig.info.cacheWritesPrice, + cacheReadsPrice: tier?.cacheReadsPrice ?? modelConfig.info.cacheReadsPrice, } } - // Get model params including reasoning configuration const params = getModelParams({ format: "anthropic", modelId: modelConfig.id, @@ -1117,12 +679,11 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH defaultTemperature: BEDROCK_DEFAULT_TEMPERATURE, }) - // Apply service tier pricing if specified and model supports it + // Apply service tier pricing const baseModelIdForTier = this.parseBaseModelId(modelConfig.id) if (this.options.awsBedrockServiceTier && BEDROCK_SERVICE_TIER_MODEL_IDS.includes(baseModelIdForTier as any)) { const pricingMultiplier = BEDROCK_SERVICE_TIER_PRICING[this.options.awsBedrockServiceTier] if (pricingMultiplier && pricingMultiplier !== 1.0) { - // Apply pricing multiplier to all price fields modelConfig.info = { ...modelConfig.info, inputPrice: modelConfig.info.inputPrice @@ -1141,7 +702,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH } } - // Don't override maxTokens/contextWindow here; handled in getModelById (and includes user overrides) return { ...modelConfig, ...params } as { id: BedrockModelId | string info: ModelInfo @@ -1158,103 +718,82 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH * *************************************************************************************/ - // Store previous cache point placements for maintaining consistency across consecutive messages - private previousCachePointPlacements: { [conversationId: string]: any[] } = {} - private supportsAwsPromptCache(modelConfig: { id: BedrockModelId | string; info: ModelInfo }): boolean | undefined { - // Check if the model supports prompt cache - // The cachableFields property is not part of the ModelInfo type in schemas - // but it's used in the bedrockModels object in shared/api.ts return ( modelConfig?.info?.supportsPromptCache && - // Use optional chaining and type assertion to access cachableFields (modelConfig?.info as any)?.cachableFields && (modelConfig?.info as any)?.cachableFields?.length > 0 ) } /** - * Removes any existing cachePoint nodes from content blocks - */ - private removeCachePoints(content: any): any { - if (Array.isArray(content)) { - return content.map((block) => { - // Use destructuring to remove cachePoint property - const { cachePoint: _, ...rest } = block - return rest - }) - } - - return content - } - - /************************************************************************************ + * Apply cachePoint providerOptions to the correct AI SDK messages by walking + * the original Anthropic messages and converted AI SDK messages in parallel. * - * NATIVE TOOLS - * - *************************************************************************************/ - - /** - * Convert OpenAI tool definitions to Bedrock Converse format - * Transforms JSON Schema to draft 2020-12 compliant format required by Claude models. - * @param tools Array of OpenAI ChatCompletionTool definitions - * @returns Array of Bedrock Tool definitions + * convertToAiSdkMessages() can split a single Anthropic user message (containing + * tool_results + text) into 2 AI SDK messages (tool role + user role). This method + * accounts for that split so cache points land on the right message. */ - private convertToolsForBedrock(tools: OpenAI.Chat.ChatCompletionTool[]): Tool[] { - return tools - .filter((tool) => tool.type === "function") - .map( - (tool) => - ({ - toolSpec: { - name: tool.function.name, - description: tool.function.description, - inputSchema: { - // Normalize schema to JSON Schema draft 2020-12 compliant format - // This converts type: ["T", "null"] to anyOf: [{type: "T"}, {type: "null"}] - json: normalizeToolSchema(tool.function.parameters as Record), - }, - }, - }) as Tool, - ) - } + private applyCachePointsToAiSdkMessages( + originalMessages: Anthropic.Messages.MessageParam[], + aiSdkMessages: { role: string; providerOptions?: Record> }[], + targetOriginalIndices: Set, + cachePointOption: Record>, + ): void { + let aiSdkIdx = 0 + for (let origIdx = 0; origIdx < originalMessages.length; origIdx++) { + const origMsg = originalMessages[origIdx] - /** - * Convert OpenAI tool_choice to Bedrock ToolChoice format - * @param toolChoice OpenAI tool_choice parameter - * @returns Bedrock ToolChoice configuration - */ - private convertToolChoiceForBedrock( - toolChoice: OpenAI.Chat.ChatCompletionCreateParams["tool_choice"], - ): ToolChoice | undefined { - if (!toolChoice) { - // Default to auto - model decides whether to use tools - return { auto: {} } as ToolChoice - } + if (typeof origMsg.content === "string") { + // Simple string content → 1 AI SDK message + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cachePointOption, + } + } + aiSdkIdx++ + } else if (origMsg.role === "user") { + // User message with array content may split into tool + user messages. + const hasToolResults = origMsg.content.some((part) => (part as { type: string }).type === "tool_result") + const hasNonToolContent = origMsg.content.some( + (part) => (part as { type: string }).type === "text" || (part as { type: string }).type === "image", + ) - if (typeof toolChoice === "string") { - switch (toolChoice) { - case "none": - return undefined // Bedrock doesn't have "none", just omit tools - case "auto": - return { auto: {} } as ToolChoice - case "required": - return { any: {} } as ToolChoice // Model must use at least one tool - default: - return { auto: {} } as ToolChoice + if (hasToolResults && hasNonToolContent) { + // Split into tool msg + user msg — cache the user msg (the second one) + const userMsgIdx = aiSdkIdx + 1 + if (targetOriginalIndices.has(origIdx) && userMsgIdx < aiSdkMessages.length) { + aiSdkMessages[userMsgIdx].providerOptions = { + ...aiSdkMessages[userMsgIdx].providerOptions, + ...cachePointOption, + } + } + aiSdkIdx += 2 + } else if (hasToolResults) { + // Only tool results → 1 tool msg + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cachePointOption, + } + } + aiSdkIdx++ + } else { + // Only text/image content → 1 user msg + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cachePointOption, + } + } + aiSdkIdx++ + } + } else { + // Assistant message → 1 AI SDK message + aiSdkIdx++ } } - - // Handle object form { type: "function", function: { name: string } } - if (typeof toolChoice === "object" && "function" in toolChoice) { - return { - tool: { - name: toolChoice.function.name, - }, - } as ToolChoice - } - - return { auto: {} } as ToolChoice } /************************************************************************************ @@ -1264,19 +803,15 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH *************************************************************************************/ private static getPrefixForRegion(region: string): string | undefined { - // Use AWS recommended inference profile prefixes - // Array is pre-sorted by pattern length (descending) to ensure more specific patterns match first for (const [regionPattern, inferenceProfile] of AWS_INFERENCE_PROFILE_MAPPING) { if (region.startsWith(regionPattern)) { return inferenceProfile } } - return undefined } private static isSystemInferenceProfile(prefix: string): boolean { - // Check if the prefix is defined in AWS_INFERENCE_PROFILE_MAPPING for (const [_, inferenceProfile] of AWS_INFERENCE_PROFILE_MAPPING) { if (prefix === inferenceProfile) { return true @@ -1287,291 +822,65 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH /************************************************************************************ * - * ERROR HANDLING + * COST CALCULATION + * + *************************************************************************************/ + + private calculateCost({ + inputTokens, + outputTokens, + cacheWriteTokens = 0, + cacheReadTokens = 0, + reasoningTokens = 0, + info, + }: { + inputTokens: number + outputTokens: number + cacheWriteTokens?: number + cacheReadTokens?: number + reasoningTokens?: number + info: ModelInfo + }): number { + const inputPrice = info.inputPrice ?? 0 + const outputPrice = info.outputPrice ?? 0 + const cacheWritesPrice = info.cacheWritesPrice ?? 0 + const cacheReadsPrice = info.cacheReadsPrice ?? 0 + + const uncachedInputTokens = Math.max(0, inputTokens - cacheWriteTokens - cacheReadTokens) + const billedOutputTokens = outputTokens + reasoningTokens + + const cacheWriteCost = cacheWriteTokens > 0 ? cacheWritesPrice * (cacheWriteTokens / 1_000_000) : 0 + const cacheReadCost = cacheReadTokens > 0 ? cacheReadsPrice * (cacheReadTokens / 1_000_000) : 0 + const inputTokensCost = inputPrice * (uncachedInputTokens / 1_000_000) + const outputTokensCost = outputPrice * (billedOutputTokens / 1_000_000) + + return inputTokensCost + outputTokensCost + cacheWriteCost + cacheReadCost + } + + /************************************************************************************ + * + * THINKING SIGNATURE ROUND-TRIP * *************************************************************************************/ /** - * Error type definitions for Bedrock API errors + * Returns the thinking signature captured from the last Bedrock response. + * Claude models with extended thinking return a cryptographic signature + * which must be round-tripped back for multi-turn conversations with tool use. */ - private static readonly ERROR_TYPES: Record< - string, - { - patterns: string[] // Strings to match in lowercase error message or name - messageTemplate: string // Template with placeholders like {region}, {modelId}, etc. - logLevel: "error" | "warn" | "info" // Log level for this error type - } - > = { - ACCESS_DENIED: { - patterns: ["access", "denied", "permission"], - messageTemplate: `You don't have access to the model specified. - -Please verify: -1. Try cross-region inference if you're using a foundation model -2. If using an ARN, verify the ARN is correct and points to a valid model -3. Your AWS credentials have permission to access this model (check IAM policies) -4. The region in the ARN matches the region where the model is deployed -5. If using a provisioned model, ensure it's active and not in a failed state`, - logLevel: "error", - }, - NOT_FOUND: { - patterns: ["not found", "does not exist"], - messageTemplate: `The specified ARN does not exist or is invalid. Please check: - -1. The ARN format is correct (arn:aws:bedrock:region:account-id:resource-type/resource-name) -2. The model exists in the specified region -3. The account ID in the ARN is correct`, - logLevel: "error", - }, - THROTTLING: { - patterns: [ - "throttl", - "rate", - "limit", - "bedrock is unable to process your request", // Amazon Bedrock specific throttling message - "please wait", - "quota exceeded", - "service unavailable", - "busy", - "overloaded", - "too many requests", - "request limit", - "concurrent requests", - ], - messageTemplate: `Request was throttled or rate limited. Please try: -1. Reducing the frequency of requests -2. If using a provisioned model, check its throughput settings -3. Contact AWS support to request a quota increase if needed - -`, - logLevel: "error", - }, - TOO_MANY_TOKENS: { - patterns: ["too many tokens", "token limit exceeded", "context length", "maximum context length"], - messageTemplate: `"Too many tokens" error detected. -Possible Causes: -1. Input exceeds model's context window limit -2. Rate limiting (too many tokens per minute) -3. Quota exceeded for token usage -4. Other token-related service limitations - -Suggestions: -1. Reduce the size of your input -2. Split your request into smaller chunks -3. Use a model with a larger context window -4. If rate limited, reduce request frequency -5. Check your Amazon Bedrock quotas and limits - -`, - logLevel: "error", - }, - SERVICE_QUOTA_EXCEEDED: { - patterns: ["service quota exceeded", "service quota", "quota exceeded for model"], - messageTemplate: `Service quota exceeded. This error indicates you've reached AWS service limits. - -Please try: -1. Contact AWS support to request a quota increase -2. Reduce request frequency temporarily -3. Check your Amazon Bedrock quotas in the AWS console -4. Consider using a different model or region with available capacity - -`, - logLevel: "error", - }, - MODEL_NOT_READY: { - patterns: ["model not ready", "model is not ready", "provisioned throughput not ready", "model loading"], - messageTemplate: `Model is not ready or still loading. This can happen with: -1. Provisioned throughput models that are still initializing -2. Custom models that are being loaded -3. Models that are temporarily unavailable - -Please try: -1. Wait a few minutes and retry -2. Check the model status in Amazon Bedrock console -3. Verify the model is properly provisioned - -`, - logLevel: "error", - }, - INTERNAL_SERVER_ERROR: { - patterns: ["internal server error", "internal error", "server error", "service error"], - messageTemplate: `Amazon Bedrock internal server error. This is a temporary service issue. - -Please try: -1. Retry the request after a brief delay -2. If the error persists, check AWS service health -3. Contact AWS support if the issue continues - -`, - logLevel: "error", - }, - ON_DEMAND_NOT_SUPPORTED: { - patterns: ["with on-demand throughput isn’t supported."], - messageTemplate: ` -1. Try enabling cross-region inference in settings. -2. Or, create an inference profile and then leverage the "Use custom ARN..." option of the model selector in settings.`, - logLevel: "error", - }, - ABORT: { - patterns: ["aborterror"], // This will match error.name.toLowerCase() for AbortError - messageTemplate: `Request was aborted: The operation timed out or was manually cancelled. Please try again or check your network connection.`, - logLevel: "info", - }, - INVALID_ARN_FORMAT: { - patterns: ["invalid_arn_format:", "invalid arn format"], - messageTemplate: `Invalid ARN format. ARN should follow the pattern: arn:aws:bedrock:region:account-id:resource-type/resource-name`, - logLevel: "error", - }, - VALIDATION_ERROR: { - patterns: [ - "input tag", - "does not match any of the expected tags", - "field required", - "validation", - "invalid parameter", - ], - messageTemplate: `Parameter validation error: {errorMessage} - -This error indicates that the request parameters don't match Amazon Bedrock's expected format. - -Common causes: -1. Extended thinking parameter format is incorrect -2. Model-specific parameters are not supported by this model -3. API parameter structure has changed - -Please check: -- Model supports the requested features (extended thinking, etc.) -- Parameter format matches Amazon Bedrock specification -- Model ID is correct for the requested features`, - logLevel: "error", - }, - // Default/generic error - GENERIC: { - patterns: [], // Empty patterns array means this is the default - messageTemplate: `Unknown Error: {errorMessage}`, - logLevel: "error", - }, + getThoughtSignature(): string | undefined { + return this.lastThoughtSignature } /** - * Determines the error type based on the error message or name + * Returns any redacted thinking blocks captured from the last Bedrock response. + * Anthropic returns these when safety filters trigger on reasoning content. */ - private getErrorType(error: unknown): string { - if (!(error instanceof Error)) { - return "GENERIC" - } - - // Check for HTTP 429 status code (Too Many Requests) - if ((error as any).status === 429 || (error as any).$metadata?.httpStatusCode === 429) { - return "THROTTLING" - } - - // Check for Amazon Bedrock specific throttling exception names - if ((error as any).name === "ThrottlingException" || (error as any).__type === "ThrottlingException") { - return "THROTTLING" - } - - const errorMessage = error.message.toLowerCase() - const errorName = error.name.toLowerCase() - - // Check each error type's patterns in order of specificity (most specific first) - const errorTypeOrder = [ - "SERVICE_QUOTA_EXCEEDED", // Most specific - check before THROTTLING - "MODEL_NOT_READY", - "TOO_MANY_TOKENS", - "INTERNAL_SERVER_ERROR", - "ON_DEMAND_NOT_SUPPORTED", - "NOT_FOUND", - "ACCESS_DENIED", - "THROTTLING", // Less specific - check after more specific patterns - ] - - for (const errorType of errorTypeOrder) { - const definition = AwsBedrockHandler.ERROR_TYPES[errorType] - if (!definition) continue - - // If any pattern matches in either message or name, return this error type - if (definition.patterns.some((pattern) => errorMessage.includes(pattern) || errorName.includes(pattern))) { - return errorType - } - } - - // Default to generic error - return "GENERIC" + getRedactedThinkingBlocks(): Array<{ type: "redacted_thinking"; data: string }> | undefined { + return this.lastRedactedThinkingBlocks.length > 0 ? this.lastRedactedThinkingBlocks : undefined } - /** - * Formats an error message based on the error type and context - */ - private formatErrorMessage(error: unknown, errorType: string, _isStreamContext: boolean): string { - const definition = AwsBedrockHandler.ERROR_TYPES[errorType] || AwsBedrockHandler.ERROR_TYPES.GENERIC - let template = definition.messageTemplate - - // Prepare template variables - const templateVars: Record = {} - - if (error instanceof Error) { - templateVars.errorMessage = error.message - templateVars.errorName = error.name - - const modelConfig = this.getModel() - templateVars.modelId = modelConfig.id - templateVars.contextWindow = String(modelConfig.info.contextWindow || "unknown") - } - - // Add context-specific template variables - const region = - typeof this?.client?.config?.region === "function" - ? this?.client?.config?.region() - : this?.client?.config?.region - templateVars.regionInfo = `(${region})` - - // Replace template variables - for (const [key, value] of Object.entries(templateVars)) { - template = template.replace(new RegExp(`{${key}}`, "g"), value || "") - } - - return template - } - - /** - * Handles Bedrock API errors and generates appropriate error messages - * @param error The error that occurred - * @param isStreamContext Whether the error occurred in a streaming context (true) or not (false) - * @returns Error message string for non-streaming context or array of stream chunks for streaming context - */ - private handleBedrockError( - error: unknown, - isStreamContext: boolean, - ): string | Array<{ type: string; text?: string; inputTokens?: number; outputTokens?: number }> { - // Determine error type - const errorType = this.getErrorType(error) - - // Format error message - const errorMessage = this.formatErrorMessage(error, errorType, isStreamContext) - - // Log the error - const definition = AwsBedrockHandler.ERROR_TYPES[errorType] - const logMethod = definition.logLevel - const contextName = isStreamContext ? "createMessage" : "completePrompt" - logger[logMethod](`${errorType} error in ${contextName}`, { - ctx: "bedrock", - customArn: this.options.awsCustomArn, - errorType, - errorMessage: error instanceof Error ? error.message : String(error), - ...(error instanceof Error && error.stack ? { errorStack: error.stack } : {}), - ...(this.client?.config?.region ? { clientRegion: this.client.config.region } : {}), - }) - - // Return appropriate response based on isStreamContext - if (isStreamContext) { - return [ - { type: "text", text: `Error: ${errorMessage}` }, - { type: "usage", inputTokens: 0, outputTokens: 0 }, - ] - } else { - // For non-streaming context, add the expected prefix - return `Bedrock completion error: ${errorMessage}` - } + override isAiSdkProvider(): boolean { + return true } } diff --git a/src/api/providers/cerebras.ts b/src/api/providers/cerebras.ts deleted file mode 100644 index de1a4b2dbb..0000000000 --- a/src/api/providers/cerebras.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import { createCerebras } from "@ai-sdk/cerebras" -import { streamText, generateText, ToolSet } from "ai" - -import { cerebrasModels, cerebrasDefaultModelId, type CerebrasModelId, type ModelInfo } from "@roo-code/types" - -import type { ApiHandlerOptions } from "../../shared/api" - -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - processAiSdkStreamPart, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" -import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" -import { getModelParams } from "../transform/model-params" - -import { DEFAULT_HEADERS } from "./constants" -import { BaseProvider } from "./base-provider" -import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" - -const CEREBRAS_INTEGRATION_HEADER = "X-Cerebras-3rd-Party-Integration" -const CEREBRAS_INTEGRATION_NAME = "roocode" -const CEREBRAS_DEFAULT_TEMPERATURE = 0 - -/** - * Cerebras provider using the dedicated @ai-sdk/cerebras package. - * Provides high-speed inference powered by Wafer-Scale Engines. - */ -export class CerebrasHandler extends BaseProvider implements SingleCompletionHandler { - protected options: ApiHandlerOptions - protected provider: ReturnType - - constructor(options: ApiHandlerOptions) { - super() - this.options = options - - // Create the Cerebras provider using AI SDK - this.provider = createCerebras({ - apiKey: options.cerebrasApiKey ?? "not-provided", - headers: { - ...DEFAULT_HEADERS, - [CEREBRAS_INTEGRATION_HEADER]: CEREBRAS_INTEGRATION_NAME, - }, - }) - } - - override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { - const id = (this.options.apiModelId ?? cerebrasDefaultModelId) as CerebrasModelId - const info = cerebrasModels[id as keyof typeof cerebrasModels] || cerebrasModels[cerebrasDefaultModelId] - const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options }) - return { id, info, ...params } - } - - /** - * Get the language model for the configured model ID. - */ - protected getLanguageModel() { - const { id } = this.getModel() - return this.provider(id) - } - - /** - * Process usage metrics from the AI SDK response. - */ - protected processUsageMetrics(usage: { - inputTokens?: number - outputTokens?: number - details?: { - cachedInputTokens?: number - reasoningTokens?: number - } - }): ApiStreamUsageChunk { - return { - type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, - cacheReadTokens: usage.details?.cachedInputTokens, - reasoningTokens: usage.details?.reasoningTokens, - } - } - - /** - * Get the max tokens parameter to include in the request. - */ - protected getMaxOutputTokens(): number | undefined { - const { info } = this.getModel() - return this.options.modelMaxTokens || info.maxTokens || undefined - } - - /** - * Create a message stream using the AI SDK. - */ - override async *createMessage( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - const { temperature } = this.getModel() - const languageModel = this.getLanguageModel() - - // Convert messages to AI SDK format - const aiSdkMessages = convertToAiSdkMessages(messages) - - // Convert tools to OpenAI format first, then to AI SDK format - const openAiTools = this.convertToolsForOpenAI(metadata?.tools) - const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined - - // Build the request options - const requestOptions: Parameters[0] = { - model: languageModel, - system: systemPrompt, - messages: aiSdkMessages, - temperature: this.options.modelTemperature ?? temperature ?? CEREBRAS_DEFAULT_TEMPERATURE, - maxOutputTokens: this.getMaxOutputTokens(), - tools: aiSdkTools, - toolChoice: mapToolChoice(metadata?.tool_choice), - } - - // Use streamText for streaming responses - const result = streamText(requestOptions) - - try { - // Process the full stream to get all events including reasoning - for await (const part of result.fullStream) { - for (const chunk of processAiSdkStreamPart(part)) { - yield chunk - } - } - - // Yield usage metrics at the end - const usage = await result.usage - if (usage) { - yield this.processUsageMetrics(usage) - } - } catch (error) { - // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) - throw handleAiSdkError(error, "Cerebras") - } - } - - /** - * Complete a prompt using the AI SDK generateText. - */ - async completePrompt(prompt: string): Promise { - const { temperature } = this.getModel() - const languageModel = this.getLanguageModel() - - const { text } = await generateText({ - model: languageModel, - prompt, - maxOutputTokens: this.getMaxOutputTokens(), - temperature: this.options.modelTemperature ?? temperature ?? CEREBRAS_DEFAULT_TEMPERATURE, - }) - - return text - } -} diff --git a/src/api/providers/chutes.ts b/src/api/providers/chutes.ts deleted file mode 100644 index 6b040834cd..0000000000 --- a/src/api/providers/chutes.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { DEEP_SEEK_DEFAULT_TEMPERATURE, chutesDefaultModelId, chutesDefaultModelInfo } from "@roo-code/types" -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" - -import type { ApiHandlerOptions } from "../../shared/api" -import { getModelMaxOutputTokens } from "../../shared/api" -import { TagMatcher } from "../../utils/tag-matcher" -import { convertToR1Format } from "../transform/r1-format" -import { convertToOpenAiMessages } from "../transform/openai-format" -import { ApiStream } from "../transform/stream" -import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" - -import { RouterProvider } from "./router-provider" - -export class ChutesHandler extends RouterProvider implements SingleCompletionHandler { - constructor(options: ApiHandlerOptions) { - super({ - options, - name: "chutes", - baseURL: "https://llm.chutes.ai/v1", - apiKey: options.chutesApiKey, - modelId: options.apiModelId, - defaultModelId: chutesDefaultModelId, - defaultModelInfo: chutesDefaultModelInfo, - }) - } - - private getCompletionParams( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming { - const { id: model, info } = this.getModel() - - // Centralized cap: clamp to 20% of the context window (unless provider-specific exceptions apply) - const max_tokens = - getModelMaxOutputTokens({ - modelId: model, - model: info, - settings: this.options, - format: "openai", - }) ?? undefined - - const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { - model, - max_tokens, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], - stream: true, - stream_options: { include_usage: true }, - tools: metadata?.tools, - tool_choice: metadata?.tool_choice, - } - - // Only add temperature if model supports it - if (this.supportsTemperature(model)) { - params.temperature = this.options.modelTemperature ?? info.temperature - } - - return params - } - - override async *createMessage( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - const model = await this.fetchModel() - - if (model.id.includes("DeepSeek-R1")) { - const stream = await this.client.chat.completions.create({ - ...this.getCompletionParams(systemPrompt, messages, metadata), - messages: convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]), - }) - - const matcher = new TagMatcher( - "think", - (chunk) => - ({ - type: chunk.matched ? "reasoning" : "text", - text: chunk.data, - }) as const, - ) - - for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta - - if (delta?.content) { - for (const processedChunk of matcher.update(delta.content)) { - yield processedChunk - } - } - - // Emit raw tool call chunks - NativeToolCallParser handles state management - if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - - if (chunk.usage) { - yield { - type: "usage", - inputTokens: chunk.usage.prompt_tokens || 0, - outputTokens: chunk.usage.completion_tokens || 0, - } - } - } - - // Process any remaining content - for (const processedChunk of matcher.final()) { - yield processedChunk - } - } else { - // For non-DeepSeek-R1 models, use standard OpenAI streaming - const stream = await this.client.chat.completions.create( - this.getCompletionParams(systemPrompt, messages, metadata), - ) - - for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta - - if (delta?.content) { - yield { type: "text", text: delta.content } - } - - if (delta && "reasoning_content" in delta && delta.reasoning_content) { - yield { type: "reasoning", text: (delta.reasoning_content as string | undefined) || "" } - } - - // Emit raw tool call chunks - NativeToolCallParser handles state management - if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - - if (chunk.usage) { - yield { - type: "usage", - inputTokens: chunk.usage.prompt_tokens || 0, - outputTokens: chunk.usage.completion_tokens || 0, - } - } - } - } - } - - async completePrompt(prompt: string): Promise { - const model = await this.fetchModel() - const { id: modelId, info } = model - - try { - // Centralized cap: clamp to 20% of the context window (unless provider-specific exceptions apply) - const max_tokens = - getModelMaxOutputTokens({ - modelId, - model: info, - settings: this.options, - format: "openai", - }) ?? undefined - - const requestParams: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { - model: modelId, - messages: [{ role: "user", content: prompt }], - max_tokens, - } - - // Only add temperature if model supports it - if (this.supportsTemperature(modelId)) { - const isDeepSeekR1 = modelId.includes("DeepSeek-R1") - const defaultTemperature = isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0.5 - requestParams.temperature = this.options.modelTemperature ?? defaultTemperature - } - - const response = await this.client.chat.completions.create(requestParams) - return response.choices[0]?.message.content || "" - } catch (error) { - if (error instanceof Error) { - throw new Error(`Chutes completion error: ${error.message}`) - } - throw error - } - } - - override getModel() { - const model = super.getModel() - const isDeepSeekR1 = model.id.includes("DeepSeek-R1") - - return { - ...model, - info: { - ...model.info, - temperature: isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0.5, - }, - } - } -} diff --git a/src/api/providers/deepinfra.ts b/src/api/providers/deepinfra.ts deleted file mode 100644 index e5b10e4e44..0000000000 --- a/src/api/providers/deepinfra.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" - -import { deepInfraDefaultModelId, deepInfraDefaultModelInfo } from "@roo-code/types" - -import type { ApiHandlerOptions } from "../../shared/api" -import { calculateApiCostOpenAI } from "../../shared/cost" - -import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" -import { convertToOpenAiMessages } from "../transform/openai-format" - -import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { RouterProvider } from "./router-provider" -import { getModelParams } from "../transform/model-params" -import { getModels } from "./fetchers/modelCache" - -export class DeepInfraHandler extends RouterProvider implements SingleCompletionHandler { - constructor(options: ApiHandlerOptions) { - super({ - options: { - ...options, - openAiHeaders: { - "X-Deepinfra-Source": "roo-code", - "X-Deepinfra-Version": `2025-08-25`, - }, - }, - name: "deepinfra", - baseURL: `${options.deepInfraBaseUrl || "https://api.deepinfra.com/v1/openai"}`, - apiKey: options.deepInfraApiKey || "not-provided", - modelId: options.deepInfraModelId, - defaultModelId: deepInfraDefaultModelId, - defaultModelInfo: deepInfraDefaultModelInfo, - }) - } - - public override async fetchModel() { - this.models = await getModels({ provider: this.name, apiKey: this.client.apiKey, baseUrl: this.client.baseURL }) - return this.getModel() - } - - override getModel() { - const id = this.options.deepInfraModelId ?? deepInfraDefaultModelId - const info = this.models[id] ?? deepInfraDefaultModelInfo - - const params = getModelParams({ - format: "openai", - modelId: id, - model: info, - settings: this.options, - }) - - return { id, info, ...params } - } - - override async *createMessage( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - _metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - // Ensure we have up-to-date model metadata - await this.fetchModel() - const { id: modelId, info, reasoningEffort: reasoning_effort } = await this.fetchModel() - let prompt_cache_key = undefined - if (info.supportsPromptCache && _metadata?.taskId) { - prompt_cache_key = _metadata.taskId - } - - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { - model: modelId, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], - stream: true, - stream_options: { include_usage: true }, - reasoning_effort, - prompt_cache_key, - tools: this.convertToolsForOpenAI(_metadata?.tools), - tool_choice: _metadata?.tool_choice, - parallel_tool_calls: _metadata?.parallelToolCalls ?? true, - } as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming - - if (this.supportsTemperature(modelId)) { - requestOptions.temperature = this.options.modelTemperature ?? 0 - } - - if (this.options.includeMaxTokens === true && info.maxTokens) { - ;(requestOptions as any).max_completion_tokens = this.options.modelMaxTokens || info.maxTokens - } - - const { data: stream } = await this.client.chat.completions.create(requestOptions).withResponse() - - let lastUsage: OpenAI.CompletionUsage | undefined - for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta - - if (delta?.content) { - yield { type: "text", text: delta.content } - } - - if (delta && "reasoning_content" in delta && delta.reasoning_content) { - yield { type: "reasoning", text: (delta.reasoning_content as string | undefined) || "" } - } - - // Handle tool calls in stream - emit partial chunks for NativeToolCallParser - if (delta?.tool_calls) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - - if (chunk.usage) { - lastUsage = chunk.usage - } - } - - if (lastUsage) { - yield this.processUsageMetrics(lastUsage, info) - } - } - - async completePrompt(prompt: string): Promise { - await this.fetchModel() - const { id: modelId, info } = this.getModel() - - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { - model: modelId, - messages: [{ role: "user", content: prompt }], - } - if (this.supportsTemperature(modelId)) { - requestOptions.temperature = this.options.modelTemperature ?? 0 - } - if (this.options.includeMaxTokens === true && info.maxTokens) { - ;(requestOptions as any).max_completion_tokens = this.options.modelMaxTokens || info.maxTokens - } - - const resp = await this.client.chat.completions.create(requestOptions) - return resp.choices[0]?.message?.content || "" - } - - protected processUsageMetrics(usage: any, modelInfo?: any): ApiStreamUsageChunk { - const inputTokens = usage?.prompt_tokens || 0 - const outputTokens = usage?.completion_tokens || 0 - const cacheWriteTokens = usage?.prompt_tokens_details?.cache_write_tokens || 0 - const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens || 0 - - const { totalCost } = modelInfo - ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) - : { totalCost: 0 } - - return { - type: "usage", - inputTokens, - outputTokens, - cacheWriteTokens: cacheWriteTokens || undefined, - cacheReadTokens: cacheReadTokens || undefined, - totalCost, - } - } -} diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index ba9c9d47e3..aa1af804ea 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -34,7 +34,7 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan // Create the DeepSeek provider using AI SDK this.provider = createDeepSeek({ - baseURL: options.deepSeekBaseUrl ?? "https://api.deepseek.com/v1", + baseURL: options.deepSeekBaseUrl || "https://api.deepseek.com/v1", apiKey: options.deepSeekApiKey ?? "not-provided", headers: DEFAULT_HEADERS, }) @@ -43,7 +43,13 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { const id = this.options.apiModelId ?? deepSeekDefaultModelId const info = deepSeekModels[id as keyof typeof deepSeekModels] || deepSeekModels[deepSeekDefaultModelId] - const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options }) + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: DEEP_SEEK_DEFAULT_TEMPERATURE, + }) return { id, info, ...params } } @@ -166,4 +172,8 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan return text } + + override isAiSdkProvider(): boolean { + return true + } } diff --git a/src/api/providers/doubao.ts b/src/api/providers/doubao.ts deleted file mode 100644 index a1337ed558..0000000000 --- a/src/api/providers/doubao.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { OpenAiHandler } from "./openai" -import type { ApiHandlerOptions } from "../../shared/api" -import { DOUBAO_API_BASE_URL, doubaoDefaultModelId, doubaoModels } from "@roo-code/types" -import { getModelParams } from "../transform/model-params" -import { ApiStreamUsageChunk } from "../transform/stream" - -// Core types for Doubao API -interface ChatCompletionMessageParam { - role: "system" | "user" | "assistant" | "developer" - content: - | string - | Array<{ - type: "text" | "image_url" - text?: string - image_url?: { url: string } - }> -} - -interface ChatCompletionParams { - model: string - messages: ChatCompletionMessageParam[] - temperature?: number - stream?: boolean - stream_options?: { include_usage: boolean } - max_completion_tokens?: number -} - -interface ChatCompletion { - choices: Array<{ - message: { - content: string - } - }> - usage?: { - prompt_tokens: number - completion_tokens: number - } -} - -interface ChatCompletionChunk { - choices: Array<{ - delta: { - content?: string - } - }> - usage?: { - prompt_tokens: number - completion_tokens: number - } -} - -export class DoubaoHandler extends OpenAiHandler { - constructor(options: ApiHandlerOptions) { - super({ - ...options, - openAiApiKey: options.doubaoApiKey ?? "not-provided", - openAiModelId: options.apiModelId ?? doubaoDefaultModelId, - openAiBaseUrl: options.doubaoBaseUrl ?? DOUBAO_API_BASE_URL, - openAiStreamingEnabled: true, - includeMaxTokens: true, - }) - } - - override getModel() { - const id = this.options.apiModelId ?? doubaoDefaultModelId - const info = doubaoModels[id as keyof typeof doubaoModels] || doubaoModels[doubaoDefaultModelId] - const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options }) - return { id, info, ...params } - } - - // Override to handle Doubao's usage metrics, including caching. - protected override processUsageMetrics(usage: any): ApiStreamUsageChunk { - return { - type: "usage", - inputTokens: usage?.prompt_tokens || 0, - outputTokens: usage?.completion_tokens || 0, - cacheWriteTokens: usage?.prompt_tokens_details?.cache_miss_tokens, - cacheReadTokens: usage?.prompt_tokens_details?.cached_tokens, - } - } -} diff --git a/src/api/providers/fake-ai.ts b/src/api/providers/fake-ai.ts index c73752fc66..b6bb9fd2c3 100644 --- a/src/api/providers/fake-ai.ts +++ b/src/api/providers/fake-ai.ts @@ -78,4 +78,8 @@ export class FakeAIHandler implements ApiHandler, SingleCompletionHandler { completePrompt(prompt: string): Promise { return this.ai.completePrompt(prompt) } + + isAiSdkProvider(): boolean { + return false + } } diff --git a/src/api/providers/featherless.ts b/src/api/providers/featherless.ts deleted file mode 100644 index 6a94fce983..0000000000 --- a/src/api/providers/featherless.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { - DEEP_SEEK_DEFAULT_TEMPERATURE, - type FeatherlessModelId, - featherlessDefaultModelId, - featherlessModels, -} from "@roo-code/types" -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" - -import type { ApiHandlerOptions } from "../../shared/api" -import { TagMatcher } from "../../utils/tag-matcher" -import { convertToR1Format } from "../transform/r1-format" -import { convertToOpenAiMessages } from "../transform/openai-format" -import { ApiStream } from "../transform/stream" - -import type { ApiHandlerCreateMessageMetadata } from "../index" -import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" - -export class FeatherlessHandler extends BaseOpenAiCompatibleProvider { - constructor(options: ApiHandlerOptions) { - super({ - ...options, - providerName: "Featherless", - baseURL: "https://api.featherless.ai/v1", - apiKey: options.featherlessApiKey, - defaultProviderModelId: featherlessDefaultModelId, - providerModels: featherlessModels, - defaultTemperature: 0.5, - }) - } - - private getCompletionParams( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - ): OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming { - const { - id: model, - info: { maxTokens: max_tokens }, - } = this.getModel() - - const temperature = this.options.modelTemperature ?? this.getModel().info.temperature - - return { - model, - max_tokens, - temperature, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], - stream: true, - stream_options: { include_usage: true }, - } - } - - override async *createMessage( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - const model = this.getModel() - - if (model.id.includes("DeepSeek-R1")) { - const stream = await this.client.chat.completions.create({ - ...this.getCompletionParams(systemPrompt, messages), - messages: convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]), - }) - - const matcher = new TagMatcher( - "think", - (chunk) => - ({ - type: chunk.matched ? "reasoning" : "text", - text: chunk.data, - }) as const, - ) - - for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta - - if (delta?.content) { - for (const processedChunk of matcher.update(delta.content)) { - yield processedChunk - } - } - - if (chunk.usage) { - yield { - type: "usage", - inputTokens: chunk.usage.prompt_tokens || 0, - outputTokens: chunk.usage.completion_tokens || 0, - } - } - } - - // Process any remaining content - for (const processedChunk of matcher.final()) { - yield processedChunk - } - } else { - yield* super.createMessage(systemPrompt, messages, metadata) - } - } - - override getModel() { - const model = super.getModel() - const isDeepSeekR1 = model.id.includes("DeepSeek-R1") - return { - ...model, - info: { - ...model.info, - temperature: isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : this.defaultTemperature, - }, - } - } -} diff --git a/src/api/providers/fetchers/__tests__/chutes.spec.ts b/src/api/providers/fetchers/__tests__/chutes.spec.ts deleted file mode 100644 index 009cf0493f..0000000000 --- a/src/api/providers/fetchers/__tests__/chutes.spec.ts +++ /dev/null @@ -1,342 +0,0 @@ -// Mocks must come first, before imports -vi.mock("axios") - -import type { Mock } from "vitest" -import type { ModelInfo } from "@roo-code/types" -import axios from "axios" -import { getChutesModels } from "../chutes" -import { chutesModels } from "@roo-code/types" - -const mockedAxios = axios as typeof axios & { - get: Mock -} - -describe("getChutesModels", () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it("should fetch and parse models successfully", async () => { - const mockResponse = { - data: { - data: [ - { - id: "test/new-model", - object: "model", - owned_by: "test", - created: 1234567890, - context_length: 128000, - max_model_len: 8192, - input_modalities: ["text"], - }, - ], - }, - } - - mockedAxios.get.mockResolvedValue(mockResponse) - - const models = await getChutesModels("test-api-key") - - expect(mockedAxios.get).toHaveBeenCalledWith( - "https://llm.chutes.ai/v1/models", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer test-api-key", - }), - }), - ) - - expect(models["test/new-model"]).toEqual({ - maxTokens: 8192, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: "Chutes AI model: test/new-model", - }) - }) - - it("should override hardcoded models with dynamic API data", async () => { - // Find any hardcoded model - const [modelId] = Object.entries(chutesModels)[0] - - const mockResponse = { - data: { - data: [ - { - id: modelId, - object: "model", - owned_by: "test", - created: 1234567890, - context_length: 200000, // Different from hardcoded - max_model_len: 10000, // Different from hardcoded - input_modalities: ["text", "image"], - }, - ], - }, - } - - mockedAxios.get.mockResolvedValue(mockResponse) - - const models = await getChutesModels("test-api-key") - - // Dynamic values should override hardcoded - expect(models[modelId]).toBeDefined() - expect(models[modelId].contextWindow).toBe(200000) - expect(models[modelId].maxTokens).toBe(10000) - expect(models[modelId].supportsImages).toBe(true) - }) - - it("should return hardcoded models when API returns empty", async () => { - const mockResponse = { - data: { - data: [], - }, - } - - mockedAxios.get.mockResolvedValue(mockResponse) - - const models = await getChutesModels("test-api-key") - - // Should still have hardcoded models - expect(Object.keys(models).length).toBeGreaterThan(0) - expect(models).toEqual(expect.objectContaining(chutesModels)) - }) - - it("should return hardcoded models on API error", async () => { - mockedAxios.get.mockRejectedValue(new Error("Network error")) - - const models = await getChutesModels("test-api-key") - - // Should still have hardcoded models - expect(Object.keys(models).length).toBeGreaterThan(0) - expect(models).toEqual(chutesModels) - }) - - it("should work without API key", async () => { - const mockResponse = { - data: { - data: [], - }, - } - - mockedAxios.get.mockResolvedValue(mockResponse) - - const models = await getChutesModels() - - expect(mockedAxios.get).toHaveBeenCalledWith( - "https://llm.chutes.ai/v1/models", - expect.objectContaining({ - headers: expect.not.objectContaining({ - Authorization: expect.anything(), - }), - }), - ) - - expect(Object.keys(models).length).toBeGreaterThan(0) - }) - - it("should detect image support from input_modalities", async () => { - const mockResponse = { - data: { - data: [ - { - id: "test/image-model", - object: "model", - owned_by: "test", - created: 1234567890, - context_length: 128000, - max_model_len: 8192, - input_modalities: ["text", "image"], - }, - ], - }, - } - - mockedAxios.get.mockResolvedValue(mockResponse) - - const models = await getChutesModels("test-api-key") - - expect(models["test/image-model"].supportsImages).toBe(true) - }) - - it("should accept supported_features containing tools", async () => { - const mockResponse = { - data: { - data: [ - { - id: "test/tools-model", - object: "model", - owned_by: "test", - created: 1234567890, - context_length: 128000, - max_model_len: 8192, - input_modalities: ["text"], - supported_features: ["json_mode", "tools", "reasoning"], - }, - ], - }, - } - - mockedAxios.get.mockResolvedValue(mockResponse) - - const models = await getChutesModels("test-api-key") - - expect(models["test/tools-model"]).toBeDefined() - expect(models["test/tools-model"].contextWindow).toBe(128000) - }) - - it("should accept supported_features without tools", async () => { - const mockResponse = { - data: { - data: [ - { - id: "test/no-tools-model", - object: "model", - owned_by: "test", - created: 1234567890, - context_length: 128000, - max_model_len: 8192, - input_modalities: ["text"], - supported_features: ["json_mode", "reasoning"], - }, - ], - }, - } - - mockedAxios.get.mockResolvedValue(mockResponse) - - const models = await getChutesModels("test-api-key") - - expect(models["test/no-tools-model"]).toBeDefined() - expect(models["test/no-tools-model"].contextWindow).toBe(128000) - }) - - it("should skip empty objects in API response and still process valid models", async () => { - const mockResponse = { - data: { - data: [ - { - id: "test/valid-model", - object: "model", - owned_by: "test", - created: 1234567890, - context_length: 128000, - max_model_len: 8192, - input_modalities: ["text"], - }, - {}, // Empty object - should be skipped - { - id: "test/another-valid-model", - object: "model", - context_length: 64000, - max_model_len: 4096, - }, - ], - }, - } - - mockedAxios.get.mockResolvedValue(mockResponse) - - const models = await getChutesModels("test-api-key") - - // Valid models should be processed - expect(models["test/valid-model"]).toBeDefined() - expect(models["test/valid-model"].contextWindow).toBe(128000) - expect(models["test/another-valid-model"]).toBeDefined() - expect(models["test/another-valid-model"].contextWindow).toBe(64000) - }) - - it("should skip models without id field", async () => { - const mockResponse = { - data: { - data: [ - { - // Missing id field - object: "model", - context_length: 128000, - max_model_len: 8192, - }, - { - id: "test/valid-model", - context_length: 64000, - max_model_len: 4096, - }, - ], - }, - } - - mockedAxios.get.mockResolvedValue(mockResponse) - - const models = await getChutesModels("test-api-key") - - // Only the valid model should be added - expect(models["test/valid-model"]).toBeDefined() - // Hardcoded models should still exist - expect(Object.keys(models).length).toBeGreaterThan(1) - }) - - it("should calculate maxTokens fallback when max_model_len is missing", async () => { - const mockResponse = { - data: { - data: [ - { - id: "test/no-max-len-model", - object: "model", - context_length: 100000, - // max_model_len is missing - input_modalities: ["text"], - }, - ], - }, - } - - mockedAxios.get.mockResolvedValue(mockResponse) - - const models = await getChutesModels("test-api-key") - - // Should calculate maxTokens as 20% of contextWindow - expect(models["test/no-max-len-model"]).toBeDefined() - expect(models["test/no-max-len-model"].maxTokens).toBe(20000) // 100000 * 0.2 - expect(models["test/no-max-len-model"].contextWindow).toBe(100000) - }) - - it("should gracefully handle response with mixed valid and invalid items", async () => { - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) - - const mockResponse = { - data: { - data: [ - { - id: "test/valid-1", - context_length: 128000, - max_model_len: 8192, - }, - {}, // Empty - will be skipped - null, // Null - will be skipped - { - id: "", // Empty string id - will be skipped - context_length: 64000, - }, - { - id: "test/valid-2", - context_length: 256000, - max_model_len: 16384, - supported_features: ["tools"], - }, - ], - }, - } - - mockedAxios.get.mockResolvedValue(mockResponse) - - const models = await getChutesModels("test-api-key") - - // Both valid models should be processed - expect(models["test/valid-1"]).toBeDefined() - expect(models["test/valid-2"]).toBeDefined() - - consoleErrorSpy.mockRestore() - }) -}) diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 3c73b2a272..60a39fa15f 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -41,8 +41,6 @@ vi.mock("fs", () => ({ vi.mock("../litellm") vi.mock("../openrouter") vi.mock("../requesty") -vi.mock("../unbound") -vi.mock("../io-intelligence") // Mock ContextProxy with a simple static instance vi.mock("../../../core/config/ContextProxy", () => ({ @@ -63,18 +61,12 @@ import { getModels, getModelsFromCache } from "../modelCache" import { getLiteLLMModels } from "../litellm" import { getOpenRouterModels } from "../openrouter" import { getRequestyModels } from "../requesty" -import { getUnboundModels } from "../unbound" -import { getIOIntelligenceModels } from "../io-intelligence" const mockGetLiteLLMModels = getLiteLLMModels as Mock const mockGetOpenRouterModels = getOpenRouterModels as Mock const mockGetRequestyModels = getRequestyModels as Mock -const mockGetUnboundModels = getUnboundModels as Mock -const mockGetIOIntelligenceModels = getIOIntelligenceModels as Mock const DUMMY_REQUESTY_KEY = "requesty-key-for-testing" -const DUMMY_UNBOUND_KEY = "unbound-key-for-testing" -const DUMMY_IOINTELLIGENCE_KEY = "io-intelligence-key-for-testing" describe("getModels with new GetModelsOptions", () => { beforeEach(() => { @@ -136,40 +128,6 @@ describe("getModels with new GetModelsOptions", () => { expect(result).toEqual(mockModels) }) - it("calls getUnboundModels with optional API key", async () => { - const mockModels = { - "unbound/model": { - maxTokens: 4096, - contextWindow: 8192, - supportsPromptCache: false, - description: "Unbound model", - }, - } - mockGetUnboundModels.mockResolvedValue(mockModels) - - const result = await getModels({ provider: "unbound", apiKey: DUMMY_UNBOUND_KEY }) - - expect(mockGetUnboundModels).toHaveBeenCalledWith(DUMMY_UNBOUND_KEY) - expect(result).toEqual(mockModels) - }) - - it("calls IOIntelligenceModels for IO-Intelligence provider", async () => { - const mockModels = { - "io-intelligence/model": { - maxTokens: 4096, - contextWindow: 8192, - supportsPromptCache: false, - description: "IO Intelligence Model", - }, - } - mockGetIOIntelligenceModels.mockResolvedValue(mockModels) - - const result = await getModels({ provider: "io-intelligence", apiKey: DUMMY_IOINTELLIGENCE_KEY }) - - expect(mockGetIOIntelligenceModels).toHaveBeenCalled() - expect(result).toEqual(mockModels) - }) - it("handles errors and re-throws them", async () => { const expectedError = new Error("LiteLLM connection failed") mockGetLiteLLMModels.mockRejectedValue(expectedError) diff --git a/src/api/providers/fetchers/chutes.ts b/src/api/providers/fetchers/chutes.ts deleted file mode 100644 index d79a2c80b0..0000000000 --- a/src/api/providers/fetchers/chutes.ts +++ /dev/null @@ -1,89 +0,0 @@ -import axios from "axios" -import { z } from "zod" - -import { type ModelInfo, chutesModels } from "@roo-code/types" - -import { DEFAULT_HEADERS } from "../constants" - -// Chutes models endpoint follows OpenAI /models shape with additional fields. -// All fields are optional to allow graceful handling of incomplete API responses. -const ChutesModelSchema = z.object({ - id: z.string().optional(), - object: z.literal("model").optional(), - owned_by: z.string().optional(), - created: z.number().optional(), - context_length: z.number().optional(), - max_model_len: z.number().optional(), - input_modalities: z.array(z.string()).optional(), - supported_features: z.array(z.string()).optional(), -}) - -const ChutesModelsResponseSchema = z.object({ data: z.array(ChutesModelSchema) }) - -type ChutesModelsResponse = z.infer - -export async function getChutesModels(apiKey?: string): Promise> { - const headers: Record = { ...DEFAULT_HEADERS } - - if (apiKey) { - headers["Authorization"] = `Bearer ${apiKey}` - } - - const url = "https://llm.chutes.ai/v1/models" - - // Start with hardcoded models as the base. - const models: Record = { ...chutesModels } - - try { - const response = await axios.get(url, { headers }) - const result = ChutesModelsResponseSchema.safeParse(response.data) - - // Graceful fallback: use parsed data if valid, otherwise fall back to raw response data. - // This mirrors the OpenRouter pattern for handling API responses with some invalid items. - const data = result.success ? result.data.data : response.data?.data - - if (!result.success) { - console.error(`Error parsing Chutes models response: ${JSON.stringify(result.error.format(), null, 2)}`) - } - - if (!data || !Array.isArray(data)) { - console.error("Chutes models response missing data array") - return models - } - - for (const m of data) { - // Skip items missing required fields (e.g., empty objects from API) - if (!m || typeof m.id !== "string" || !m.id) { - continue - } - - const contextWindow = - typeof m.context_length === "number" && Number.isFinite(m.context_length) ? m.context_length : undefined - const maxModelLen = - typeof m.max_model_len === "number" && Number.isFinite(m.max_model_len) ? m.max_model_len : undefined - - // Skip models without valid context window information - if (!contextWindow) { - continue - } - - const info: ModelInfo = { - maxTokens: maxModelLen ?? Math.ceil(contextWindow * 0.2), - contextWindow, - supportsImages: (m.input_modalities || []).includes("image"), - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - description: `Chutes AI model: ${m.id}`, - } - - // Union: dynamic models override hardcoded ones if they have the same ID. - models[m.id] = info - } - } catch (error) { - console.error(`Error fetching Chutes models: ${error instanceof Error ? error.message : String(error)}`) - // On error, still return hardcoded models. - } - - return models -} diff --git a/src/api/providers/fetchers/deepinfra.ts b/src/api/providers/fetchers/deepinfra.ts deleted file mode 100644 index f38daff822..0000000000 --- a/src/api/providers/fetchers/deepinfra.ts +++ /dev/null @@ -1,71 +0,0 @@ -import axios from "axios" -import { z } from "zod" - -import { type ModelInfo } from "@roo-code/types" - -import { DEFAULT_HEADERS } from "../constants" - -// DeepInfra models endpoint follows OpenAI /models shape with an added metadata object. - -const DeepInfraModelSchema = z.object({ - id: z.string(), - object: z.literal("model").optional(), - owned_by: z.string().optional(), - created: z.number().optional(), - root: z.string().optional(), - metadata: z - .object({ - description: z.string().optional(), - context_length: z.number().optional(), - max_tokens: z.number().optional(), - tags: z.array(z.string()).optional(), // e.g., ["vision", "prompt_cache"] - pricing: z - .object({ - input_tokens: z.number().optional(), - output_tokens: z.number().optional(), - cache_read_tokens: z.number().optional(), - }) - .optional(), - }) - .optional(), -}) - -const DeepInfraModelsResponseSchema = z.object({ data: z.array(DeepInfraModelSchema) }) - -export async function getDeepInfraModels( - apiKey?: string, - baseUrl: string = "https://api.deepinfra.com/v1/openai", -): Promise> { - const headers: Record = { ...DEFAULT_HEADERS } - if (apiKey) headers["Authorization"] = `Bearer ${apiKey}` - - const url = `${baseUrl.replace(/\/$/, "")}/models` - const models: Record = {} - - const response = await axios.get(url, { headers }) - const parsed = DeepInfraModelsResponseSchema.safeParse(response.data) - const data = parsed.success ? parsed.data.data : response.data?.data || [] - - for (const m of data as Array>) { - const meta = m.metadata || {} - const tags = meta.tags || [] - - const contextWindow = typeof meta.context_length === "number" ? meta.context_length : 8192 - const maxTokens = typeof meta.max_tokens === "number" ? meta.max_tokens : Math.ceil(contextWindow * 0.2) - - const info: ModelInfo = { - maxTokens, - contextWindow, - supportsImages: tags.includes("vision"), - supportsPromptCache: tags.includes("prompt_cache"), - inputPrice: meta.pricing?.input_tokens, - outputPrice: meta.pricing?.output_tokens, - cacheReadsPrice: meta.pricing?.cache_read_tokens, - description: meta.description, - } - - models[m.id] = info - } - - return models -} diff --git a/src/api/providers/fetchers/huggingface.ts b/src/api/providers/fetchers/huggingface.ts deleted file mode 100644 index 16963edc75..0000000000 --- a/src/api/providers/fetchers/huggingface.ts +++ /dev/null @@ -1,252 +0,0 @@ -import axios from "axios" -import { z } from "zod" - -import { - type ModelInfo, - type ModelRecord, - HUGGINGFACE_API_URL, - HUGGINGFACE_CACHE_DURATION, - HUGGINGFACE_DEFAULT_MAX_TOKENS, - HUGGINGFACE_DEFAULT_CONTEXT_WINDOW, -} from "@roo-code/types" - -const huggingFaceProviderSchema = z.object({ - provider: z.string(), - status: z.enum(["live", "staging", "error"]), - supports_tools: z.boolean().optional(), - supports_structured_output: z.boolean().optional(), - context_length: z.number().optional(), - pricing: z - .object({ - input: z.number(), - output: z.number(), - }) - .optional(), -}) - -/** - * Represents a provider that can serve a HuggingFace model. - * - * @property provider - The provider identifier (e.g., "sambanova", "together") - * @property status - The current status of the provider - * @property supports_tools - Whether the provider supports tool/function calling - * @property supports_structured_output - Whether the provider supports structured output - * @property context_length - The maximum context length supported by this provider - * @property pricing - The pricing information for input/output tokens - */ -export type HuggingFaceProvider = z.infer - -const huggingFaceModelSchema = z.object({ - id: z.string(), - object: z.literal("model"), - created: z.number(), - owned_by: z.string(), - providers: z.array(huggingFaceProviderSchema), -}) - -/** - * Represents a HuggingFace model available through the router API - * - * @property id - The unique identifier of the model - * @property object - The object type (always "model") - * @property created - Unix timestamp of when the model was created - * @property owned_by - The organization that owns the model - * @property providers - List of providers that can serve this model - */ -export type HuggingFaceModel = z.infer - -const huggingFaceApiResponseSchema = z.object({ - object: z.string(), - data: z.array(huggingFaceModelSchema), -}) - -type HuggingFaceApiResponse = z.infer - -interface CacheEntry { - data: ModelRecord - rawModels?: HuggingFaceModel[] - timestamp: number -} - -let cache: CacheEntry | null = null - -/** - * Parse a HuggingFace model into ModelInfo format. - * - * @param model - The HuggingFace model to parse - * @param provider - Optional specific provider to use for capabilities - * @returns ModelInfo object compatible with the application's model system - */ -function parseHuggingFaceModel(model: HuggingFaceModel, provider?: HuggingFaceProvider): ModelInfo { - // Use provider-specific values if available, otherwise find first provider with values. - const contextLength = - provider?.context_length || - model.providers.find((p) => p.context_length)?.context_length || - HUGGINGFACE_DEFAULT_CONTEXT_WINDOW - - const pricing = provider?.pricing || model.providers.find((p) => p.pricing)?.pricing - - // Include provider name in description if specific provider is given. - const description = provider ? `${model.id} via ${provider.provider}` : `${model.id} via HuggingFace` - - return { - maxTokens: Math.min(contextLength, HUGGINGFACE_DEFAULT_MAX_TOKENS), - contextWindow: contextLength, - supportsImages: false, // HuggingFace API doesn't provide this info yet. - supportsPromptCache: false, - inputPrice: pricing?.input, - outputPrice: pricing?.output, - description, - } -} - -/** - * Fetches available models from HuggingFace - * - * @returns A promise that resolves to a record of model IDs to model info - * @throws Will throw an error if the request fails - */ -export async function getHuggingFaceModels(): Promise { - const now = Date.now() - - if (cache && now - cache.timestamp < HUGGINGFACE_CACHE_DURATION) { - return cache.data - } - - const models: ModelRecord = {} - - try { - const response = await axios.get(HUGGINGFACE_API_URL, { - headers: { - "Upgrade-Insecure-Requests": "1", - "Sec-Fetch-Dest": "document", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "none", - "Sec-Fetch-User": "?1", - Priority: "u=0, i", - Pragma: "no-cache", - "Cache-Control": "no-cache", - }, - timeout: 10000, - }) - - const result = huggingFaceApiResponseSchema.safeParse(response.data) - - if (!result.success) { - console.error("HuggingFace models response validation failed:", result.error.format()) - throw new Error("Invalid response format from HuggingFace API") - } - - const validModels = result.data.data.filter((model) => model.providers.length > 0) - - for (const model of validModels) { - // Add the base model. - models[model.id] = parseHuggingFaceModel(model) - - // Add provider-specific variants for all live providers. - for (const provider of model.providers) { - if (provider.status === "live") { - const providerKey = `${model.id}:${provider.provider}` - const providerModel = parseHuggingFaceModel(model, provider) - - // Always add provider variants to show all available providers. - models[providerKey] = providerModel - } - } - } - - cache = { data: models, rawModels: validModels, timestamp: now } - - return models - } catch (error) { - console.error("Error fetching HuggingFace models:", error) - - if (cache) { - return cache.data - } - - if (axios.isAxiosError(error)) { - if (error.response) { - throw new Error( - `Failed to fetch HuggingFace models: ${error.response.status} ${error.response.statusText}`, - ) - } else if (error.request) { - throw new Error( - "Failed to fetch HuggingFace models: No response from server. Check your internet connection.", - ) - } - } - - throw new Error( - `Failed to fetch HuggingFace models: ${error instanceof Error ? error.message : "Unknown error"}`, - ) - } -} - -/** - * Get cached models without making an API request. - */ -export function getCachedHuggingFaceModels(): ModelRecord | null { - return cache?.data || null -} - -/** - * Get cached raw models for UI display. - */ -export function getCachedRawHuggingFaceModels(): HuggingFaceModel[] | null { - return cache?.rawModels || null -} - -export function clearHuggingFaceCache(): void { - cache = null -} - -export interface HuggingFaceModelsResponse { - models: HuggingFaceModel[] - cached: boolean - timestamp: number -} - -export async function getHuggingFaceModelsWithMetadata(): Promise { - try { - // First, trigger the fetch to populate cache. - await getHuggingFaceModels() - - // Get the raw models from cache. - const cachedRawModels = getCachedRawHuggingFaceModels() - - if (cachedRawModels) { - return { - models: cachedRawModels, - cached: true, - timestamp: Date.now(), - } - } - - // If no cached raw models, fetch directly from API. - const response = await axios.get(HUGGINGFACE_API_URL, { - headers: { - "Upgrade-Insecure-Requests": "1", - "Sec-Fetch-Dest": "document", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "none", - "Sec-Fetch-User": "?1", - Priority: "u=0, i", - Pragma: "no-cache", - "Cache-Control": "no-cache", - }, - timeout: 10000, - }) - - const models = response.data?.data || [] - - return { - models, - cached: false, - timestamp: Date.now(), - } - } catch (error) { - console.error("Failed to get HuggingFace models:", error) - return { models: [], cached: false, timestamp: Date.now() } - } -} diff --git a/src/api/providers/fetchers/io-intelligence.ts b/src/api/providers/fetchers/io-intelligence.ts deleted file mode 100644 index a0ea5dedae..0000000000 --- a/src/api/providers/fetchers/io-intelligence.ts +++ /dev/null @@ -1,158 +0,0 @@ -import axios from "axios" -import { z } from "zod" - -import { type ModelInfo, type ModelRecord, IO_INTELLIGENCE_CACHE_DURATION } from "@roo-code/types" - -const ioIntelligenceModelSchema = z.object({ - id: z.string(), - object: z.literal("model"), - created: z.number(), - owned_by: z.string(), - root: z.string().nullable().optional(), - parent: z.string().nullable().optional(), - max_model_len: z.number().nullable().optional(), - permission: z.array( - z.object({ - id: z.string(), - object: z.literal("model_permission"), - created: z.number(), - allow_create_engine: z.boolean(), - allow_sampling: z.boolean(), - allow_logprobs: z.boolean(), - allow_search_indices: z.boolean(), - allow_view: z.boolean(), - allow_fine_tuning: z.boolean(), - organization: z.string(), - group: z.string().nullable(), - is_blocking: z.boolean(), - }), - ), -}) - -export type IOIntelligenceModel = z.infer - -const ioIntelligenceApiResponseSchema = z.object({ - object: z.literal("list"), - data: z.array(ioIntelligenceModelSchema), -}) - -type IOIntelligenceApiResponse = z.infer - -interface CacheEntry { - data: ModelRecord - timestamp: number -} - -let cache: CacheEntry | null = null - -/** - * Model context length mapping based on the documentation - * 1 - */ -const MODEL_CONTEXT_LENGTHS: Record = { - "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": 430000, - "deepseek-ai/DeepSeek-R1-0528": 128000, - "Intel/Qwen3-Coder-480B-A35B-Instruct-int4-mixed-ar": 106000, - "openai/gpt-oss-120b": 131072, -} - -const VISION_MODELS = new Set([ - "Qwen/Qwen2.5-VL-32B-Instruct", - "meta-llama/Llama-3.2-90B-Vision-Instruct", - "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", -]) - -function parseIOIntelligenceModel(model: IOIntelligenceModel): ModelInfo { - const contextLength = MODEL_CONTEXT_LENGTHS[model.id] || 8192 - // Cap maxTokens at 32k for very large context windows, or 20% of context length, whichever is smaller. - const maxTokens = Math.min(contextLength, Math.ceil(contextLength * 0.2), 32768) - const supportsImages = VISION_MODELS.has(model.id) - - return { - maxTokens, - contextWindow: contextLength, - supportsImages, - supportsPromptCache: false, - description: `${model.id} via IO Intelligence`, - } -} - -/** - * Fetches available models from IO Intelligence - * 1 - */ -export async function getIOIntelligenceModels(apiKey?: string): Promise { - const now = Date.now() - - if (cache && now - cache.timestamp < IO_INTELLIGENCE_CACHE_DURATION) { - return cache.data - } - - const models: ModelRecord = {} - - try { - const headers: Record = { - "Content-Type": "application/json", - } - - if (apiKey) { - headers.Authorization = `Bearer ${apiKey}` - } else { - console.error("IO Intelligence API key is required") - throw new Error("IO Intelligence API key is required") - } - - const response = await axios.get( - "https://api.intelligence.io.solutions/api/v1/models", - { - headers, - timeout: 10_000, - }, - ) - - const result = ioIntelligenceApiResponseSchema.safeParse(response.data) - - if (!result.success) { - console.error("IO Intelligence models response validation failed:", result.error.format()) - throw new Error("Invalid response format from IO Intelligence API") - } - - for (const model of result.data.data) { - models[model.id] = parseIOIntelligenceModel(model) - } - - cache = { data: models, timestamp: now } - - return models - } catch (error) { - console.error("Error fetching IO Intelligence models:", error) - - if (cache) { - return cache.data - } - - if (axios.isAxiosError(error)) { - if (error.response) { - throw new Error( - `Failed to fetch IO Intelligence models: ${error.response.status} ${error.response.statusText}`, - ) - } else if (error.request) { - throw new Error( - "Failed to fetch IO Intelligence models: No response from server. Check your internet connection.", - ) - } - } - - throw new Error( - `Failed to fetch IO Intelligence models: ${error instanceof Error ? error.message : "Unknown error"}`, - ) - } -} - -export function getCachedIOIntelligenceModels(): ModelRecord | null { - return cache?.data || null -} - -export function clearIOIntelligenceCache(): void { - cache = null -} diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 51ca19e2bc..fd213dc93a 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -19,16 +19,11 @@ import { fileExistsAtPath } from "../../../utils/fs" import { getOpenRouterModels } from "./openrouter" import { getVercelAiGatewayModels } from "./vercel-ai-gateway" import { getRequestyModels } from "./requesty" -import { getUnboundModels } from "./unbound" import { getLiteLLMModels } from "./litellm" import { GetModelsOptions } from "../../../shared/api" import { getOllamaModels } from "./ollama" import { getLMStudioModels } from "./lmstudio" -import { getIOIntelligenceModels } from "./io-intelligence" -import { getDeepInfraModels } from "./deepinfra" -import { getHuggingFaceModels } from "./huggingface" import { getRooModels } from "./roo" -import { getChutesModels } from "./chutes" const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 }) @@ -67,16 +62,12 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise { const publicProviders: Array<{ provider: RouterName; options: GetModelsOptions }> = [ { provider: "openrouter", options: { provider: "openrouter" } }, { provider: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } }, - { provider: "chutes", options: { provider: "chutes" } }, ] // Refresh each provider in background (fire and forget) diff --git a/src/api/providers/fetchers/openrouter.ts b/src/api/providers/fetchers/openrouter.ts index 4d20447312..9fcf3d49cb 100644 --- a/src/api/providers/fetchers/openrouter.ts +++ b/src/api/providers/fetchers/openrouter.ts @@ -248,6 +248,16 @@ export const parseOpenRouterModel = ({ modelInfo.maxTokens = anthropicModels["claude-opus-4-1-20250805"].maxTokens } + // Set claude-opus-4.5 model to use the correct configuration + if (id === "anthropic/claude-opus-4.5") { + modelInfo.maxTokens = anthropicModels["claude-opus-4-5-20251101"].maxTokens + } + + // Set claude-opus-4.6 model to use the correct configuration + if (id === "anthropic/claude-opus-4.6") { + modelInfo.maxTokens = anthropicModels["claude-opus-4-6"].maxTokens + } + // Ensure correct reasoning handling for Claude Haiku 4.5 on OpenRouter // Use budget control and disable effort-based reasoning fallback if (id === "anthropic/claude-haiku-4.5") { diff --git a/src/api/providers/fetchers/unbound.ts b/src/api/providers/fetchers/unbound.ts deleted file mode 100644 index 354c0fde58..0000000000 --- a/src/api/providers/fetchers/unbound.ts +++ /dev/null @@ -1,52 +0,0 @@ -import axios from "axios" - -import type { ModelInfo } from "@roo-code/types" - -export async function getUnboundModels(apiKey?: string | null): Promise> { - const models: Record = {} - - try { - const headers: Record = {} - - if (apiKey) { - headers["Authorization"] = `Bearer ${apiKey}` - } - - const response = await axios.get("https://api.getunbound.ai/models", { headers }) - - if (response.data) { - const rawModels: Record = response.data - - for (const [modelId, model] of Object.entries(rawModels)) { - const modelInfo: ModelInfo = { - maxTokens: model?.maxTokens ? parseInt(model.maxTokens) : undefined, - contextWindow: model?.contextWindow ? parseInt(model.contextWindow) : 0, - supportsImages: model?.supportsImages ?? false, - supportsPromptCache: model?.supportsPromptCaching ?? false, - inputPrice: model?.inputTokenPrice ? parseFloat(model.inputTokenPrice) : undefined, - outputPrice: model?.outputTokenPrice ? parseFloat(model.outputTokenPrice) : undefined, - cacheWritesPrice: model?.cacheWritePrice ? parseFloat(model.cacheWritePrice) : undefined, - cacheReadsPrice: model?.cacheReadPrice ? parseFloat(model.cacheReadPrice) : undefined, - } - - switch (true) { - case modelId.startsWith("anthropic/"): - // Set max tokens to 8192 for supported Anthropic models - if (modelInfo.maxTokens !== 4096) { - modelInfo.maxTokens = 8192 - } - break - default: - break - } - - models[modelId] = modelInfo - } - } - } catch (error) { - console.error(`Error fetching Unbound models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) - throw new Error(`Failed to fetch Unbound models: ${error instanceof Error ? error.message : "Unknown error"}`) - } - - return models -} diff --git a/src/api/providers/fireworks.ts b/src/api/providers/fireworks.ts index 52bf431bb6..bc5560bfbb 100644 --- a/src/api/providers/fireworks.ts +++ b/src/api/providers/fireworks.ts @@ -172,4 +172,8 @@ export class FireworksHandler extends BaseProvider implements SingleCompletionHa return text } + + override isAiSdkProvider(): boolean { + return true + } } diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 823ed0ac8b..f7ebfdeeb9 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -1,13 +1,6 @@ import type { Anthropic } from "@anthropic-ai/sdk" -import { - GoogleGenAI, - type GenerateContentResponseUsageMetadata, - type GenerateContentParameters, - type GenerateContentConfig, - type GroundingMetadata, - FunctionCallingConfigMode, -} from "@google/genai" -import type { JWTInput } from "google-auth-library" +import { createGoogleGenerativeAI, type GoogleGenerativeAIProvider } from "@ai-sdk/google" +import { streamText, generateText, NoOutputGeneratedError, ToolSet } from "ai" import { type ModelInfo, @@ -16,59 +9,43 @@ import { geminiModels, ApiProviderError, } from "@roo-code/types" -import { safeJsonParse } from "@roo-code/core" import { TelemetryService } from "@roo-code/telemetry" import type { ApiHandlerOptions } from "../../shared/api" -import { convertAnthropicMessageToGemini } from "../transform/gemini-format" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, +} from "../transform/ai-sdk" import { t } from "i18next" -import type { ApiStream, GroundingSource } from "../transform/stream" +import type { ApiStream, ApiStreamUsageChunk, GroundingSource } from "../transform/stream" import { getModelParams } from "../transform/model-params" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { BaseProvider } from "./base-provider" - -type GeminiHandlerOptions = ApiHandlerOptions & { - isVertex?: boolean -} +import { DEFAULT_HEADERS } from "./constants" export class GeminiHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - - private client: GoogleGenAI - private lastThoughtSignature?: string - private lastResponseId?: string + protected provider: GoogleGenerativeAIProvider private readonly providerName = "Gemini" + private lastThoughtSignature: string | undefined - constructor({ isVertex, ...options }: GeminiHandlerOptions) { + constructor(options: ApiHandlerOptions) { super() this.options = options - const project = this.options.vertexProjectId ?? "not-provided" - const location = this.options.vertexRegion ?? "not-provided" - const apiKey = this.options.geminiApiKey ?? "not-provided" - - this.client = this.options.vertexJsonCredentials - ? new GoogleGenAI({ - vertexai: true, - project, - location, - googleAuthOptions: { - credentials: safeJsonParse(this.options.vertexJsonCredentials, undefined), - }, - }) - : this.options.vertexKeyFile - ? new GoogleGenAI({ - vertexai: true, - project, - location, - googleAuthOptions: { keyFile: this.options.vertexKeyFile }, - }) - : isVertex - ? new GoogleGenAI({ vertexai: true, project, location }) - : new GoogleGenAI({ apiKey }) + // Create the Google Generative AI provider using AI SDK + // For Vertex AI, we still use this provider but with different authentication + // (Vertex authentication happens separately) + this.provider = createGoogleGenerativeAI({ + apiKey: this.options.geminiApiKey ?? "not-provided", + baseURL: this.options.googleGeminiBaseUrl || undefined, + headers: DEFAULT_HEADERS, + }) } async *createMessage( @@ -76,10 +53,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const { id: model, info, reasoning: thinkingConfig, maxTokens } = this.getModel() - // Reset per-request metadata that we persist into apiConversationHistory. - this.lastThoughtSignature = undefined - this.lastResponseId = undefined + const { id: modelId, info, reasoning: thinkingConfig, maxTokens } = this.getModel() // For hybrid/budget reasoning models (e.g. Gemini 2.5 Pro), respect user-configured // modelMaxTokens so the ThinkingBudget slider can control the cap. For effort-only or @@ -90,58 +64,6 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl ? (this.options.modelMaxTokens ?? maxTokens ?? undefined) : (maxTokens ?? undefined) - // Gemini 3 validates thought signatures for tool/function calling steps. - // We must round-trip the signature when tools are in use, even if the user chose - // a minimal thinking level (or thinkingConfig is otherwise absent). - const includeThoughtSignatures = Boolean(thinkingConfig) || Boolean(metadata?.tools?.length) - - // The message list can include provider-specific meta entries such as - // `{ type: "reasoning", ... }` that are intended only for providers like - // openai-native. Gemini should never see those; they are not valid - // Anthropic.MessageParam values and will cause failures (e.g. missing - // `content` for the converter). Filter them out here. - type ReasoningMetaLike = { type?: string } - - const geminiMessages = messages.filter((message): message is Anthropic.Messages.MessageParam => { - const meta = message as ReasoningMetaLike - if (meta.type === "reasoning") { - return false - } - return true - }) - - // Build a map of tool IDs to names from previous messages - // This is needed because Anthropic's tool_result blocks only contain the ID, - // but Gemini requires the name in functionResponse - const toolIdToName = new Map() - for (const message of messages) { - if (Array.isArray(message.content)) { - for (const block of message.content) { - if (block.type === "tool_use") { - toolIdToName.set(block.id, block.name) - } - } - } - } - - const contents = geminiMessages - .map((message) => convertAnthropicMessageToGemini(message, { includeThoughtSignatures, toolIdToName })) - .flat() - - // Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS). - // Google built-in tools (Grounding, URL Context) are mutually exclusive - // with function declarations in the Gemini API, so we always use - // function declarations when tools are provided. - const tools: GenerateContentConfig["tools"] = [ - { - functionDeclarations: (metadata?.tools ?? []).map((tool) => ({ - name: (tool as any).function.name, - description: (tool as any).function.description, - parametersJsonSchema: (tool as any).function.parameters, - })), - }, - ] - // Determine temperature respecting model capabilities and defaults: // - If supportsTemperature is explicitly false, ignore user overrides // and pin to the model's defaultTemperature (or omit if undefined). @@ -152,190 +74,135 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl ? (this.options.modelTemperature ?? info.defaultTemperature ?? 1) : info.defaultTemperature - const config: GenerateContentConfig = { - systemInstruction, - httpOptions: this.options.googleGeminiBaseUrl ? { baseUrl: this.options.googleGeminiBaseUrl } : undefined, - thinkingConfig, - maxOutputTokens, + // The message list can include provider-specific meta entries such as + // `{ type: "reasoning", ... }` that are intended only for providers like + // openai-native. Gemini should never see those; they are not valid + // Anthropic.MessageParam values and will cause failures. + type ReasoningMetaLike = { type?: string } + + const filteredMessages = messages.filter((message): message is Anthropic.Messages.MessageParam => { + const meta = message as ReasoningMetaLike + if (meta.type === "reasoning") { + return false + } + return true + }) + + // Convert messages to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(filteredMessages) + + // Convert tools to OpenAI format first, then to AI SDK format + let openAiTools = this.convertToolsForOpenAI(metadata?.tools) + + // Filter tools based on allowedFunctionNames for mode-restricted tool access + if (metadata?.allowedFunctionNames && metadata.allowedFunctionNames.length > 0 && openAiTools) { + const allowedSet = new Set(metadata.allowedFunctionNames) + openAiTools = openAiTools.filter((tool) => tool.type === "function" && allowedSet.has(tool.function.name)) + } + + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + // Build tool choice - use 'required' when allowedFunctionNames restricts available tools + const toolChoice = + metadata?.allowedFunctionNames && metadata.allowedFunctionNames.length > 0 + ? "required" + : mapToolChoice(metadata?.tool_choice) + + // Build the request options + const requestOptions: Parameters[0] = { + model: this.provider(modelId), + system: systemInstruction, + messages: aiSdkMessages, temperature: temperatureConfig, - ...(tools.length > 0 ? { tools } : {}), + maxOutputTokens, + tools: aiSdkTools, + toolChoice, + // Add thinking/reasoning configuration if present + // Cast to any to bypass strict JSONObject typing - the AI SDK accepts the correct runtime values + ...(thinkingConfig && { + providerOptions: { google: { thinkingConfig } } as any, + }), } - // Handle allowedFunctionNames for mode-restricted tool access. - // When provided, all tool definitions are passed to the model (so it can reference - // historical tool calls in conversation), but only the specified tools can be invoked. - // This takes precedence over tool_choice to ensure mode restrictions are honored. - if (metadata?.allowedFunctionNames && metadata.allowedFunctionNames.length > 0) { - config.toolConfig = { - functionCallingConfig: { - // Use ANY mode to allow calling any of the allowed functions - mode: FunctionCallingConfigMode.ANY, - allowedFunctionNames: metadata.allowedFunctionNames, - }, - } - } else if (metadata?.tool_choice) { - const choice = metadata.tool_choice - let mode: FunctionCallingConfigMode - let allowedFunctionNames: string[] | undefined - - if (choice === "auto") { - mode = FunctionCallingConfigMode.AUTO - } else if (choice === "none") { - mode = FunctionCallingConfigMode.NONE - } else if (choice === "required") { - // "required" means the model must call at least one tool; Gemini uses ANY for this. - mode = FunctionCallingConfigMode.ANY - } else if (typeof choice === "object" && "function" in choice && choice.type === "function") { - mode = FunctionCallingConfigMode.ANY - allowedFunctionNames = [choice.function.name] - } else { - // Fall back to AUTO for unknown values to avoid unintentionally broadening tool access. - mode = FunctionCallingConfigMode.AUTO - } - - config.toolConfig = { - functionCallingConfig: { - mode, - ...(allowedFunctionNames ? { allowedFunctionNames } : {}), - }, - } - } - - const params: GenerateContentParameters = { model, contents, config } - try { - const result = await this.client.models.generateContentStream(params) + // Reset thought signature for this request + this.lastThoughtSignature = undefined - let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined - let pendingGroundingMetadata: GroundingMetadata | undefined - let finalResponse: { responseId?: string } | undefined - let finishReason: string | undefined + // Use streamText for streaming responses + const result = streamText(requestOptions) - let toolCallCounter = 0 + // Track whether any text content was yielded (not just reasoning/thinking) let hasContent = false - let hasReasoning = false - for await (const chunk of result) { - // Track the final structured response (per SDK pattern: candidate.finishReason) - if (chunk.candidates && chunk.candidates[0]?.finishReason) { - finalResponse = chunk as { responseId?: string } - finishReason = chunk.candidates[0].finishReason - } - // Process candidates and their parts to separate thoughts from content - if (chunk.candidates && chunk.candidates.length > 0) { - const candidate = chunk.candidates[0] - - if (candidate.groundingMetadata) { - pendingGroundingMetadata = candidate.groundingMetadata + // Process the full stream to get all events including reasoning + for await (const part of result.fullStream) { + // Capture thoughtSignature from tool-call events (Gemini 3 thought signatures) + // The AI SDK's tool-call event includes providerMetadata with the signature + if (part.type === "tool-call") { + const googleMeta = (part as any).providerMetadata?.google + if (googleMeta?.thoughtSignature) { + this.lastThoughtSignature = googleMeta.thoughtSignature } + } - if (candidate.content && candidate.content.parts) { - for (const part of candidate.content.parts as Array<{ - thought?: boolean - text?: string - thoughtSignature?: string - functionCall?: { name: string; args: Record } - }>) { - // Capture thought signatures so they can be persisted into API history. - const thoughtSignature = part.thoughtSignature - // Persist thought signatures so they can be round-tripped in the next step. - // Gemini 3 requires this during tool calling; other Gemini thinking models - // benefit from it for continuity. - if (includeThoughtSignatures && thoughtSignature) { - this.lastThoughtSignature = thoughtSignature - } + for (const chunk of processAiSdkStreamPart(part)) { + if (chunk.type === "text" || chunk.type === "tool_call_start") { + hasContent = true + } + yield chunk + } + } - if (part.thought) { - // This is a thinking/reasoning part - if (part.text) { - hasReasoning = true - yield { type: "reasoning", text: part.text } - } - } else if (part.functionCall) { - hasContent = true - // Gemini sends complete function calls in a single chunk - // Emit as partial chunks for consistent handling with NativeToolCallParser - const callId = `${part.functionCall.name}-${toolCallCounter}` - const args = JSON.stringify(part.functionCall.args) + // If the stream completed without yielding any text content, inform the user + // TODO: Move to i18n key common:errors.gemini.empty_response once translation pipeline is updated + if (!hasContent) { + yield { + type: "text" as const, + text: "Model returned an empty response. This may be caused by an unsupported thinking configuration or content filtering.", + } + } - // Emit name first - yield { - type: "tool_call_partial", - index: toolCallCounter, - id: callId, - name: part.functionCall.name, - arguments: undefined, - } - - // Then emit arguments - yield { - type: "tool_call_partial", - index: toolCallCounter, - id: callId, - name: undefined, - arguments: args, - } - - toolCallCounter++ - } else { - // This is regular content - if (part.text) { - hasContent = true - yield { type: "text", text: part.text } - } - } + // Extract grounding sources from providerMetadata if available + const providerMetadata = await result.providerMetadata + const groundingMetadata = providerMetadata?.google as + | { + groundingMetadata?: { + groundingChunks?: Array<{ + web?: { uri?: string; title?: string } + }> } - } - } + } + | undefined - // Fallback to the original text property if no candidates structure - else if (chunk.text) { - hasContent = true - yield { type: "text", text: chunk.text } - } - - if (chunk.usageMetadata) { - lastUsageMetadata = chunk.usageMetadata - } - } - - if (finalResponse?.responseId) { - // Capture responseId so Task.addToApiConversationHistory can store it - // alongside the assistant message in api_history.json. - this.lastResponseId = finalResponse.responseId - } - - if (pendingGroundingMetadata) { - const sources = this.extractGroundingSources(pendingGroundingMetadata) + if (groundingMetadata?.groundingMetadata) { + const sources = this.extractGroundingSources(groundingMetadata.groundingMetadata) if (sources.length > 0) { yield { type: "grounding", sources } } } - if (lastUsageMetadata) { - const inputTokens = lastUsageMetadata.promptTokenCount ?? 0 - const outputTokens = lastUsageMetadata.candidatesTokenCount ?? 0 - const cacheReadTokens = lastUsageMetadata.cachedContentTokenCount - const reasoningTokens = lastUsageMetadata.thoughtsTokenCount - - yield { - type: "usage", - inputTokens, - outputTokens, - cacheReadTokens, - reasoningTokens, - totalCost: this.calculateCost({ - info, - inputTokens, - outputTokens, - cacheReadTokens, - reasoningTokens, - }), + // Yield usage metrics at the end + // Wrap in try-catch to handle NoOutputGeneratedError thrown by the AI SDK + // when the stream produces no output (e.g., thinking-only, safety block) + try { + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage, info, providerMetadata) + } + } catch (usageError) { + if (usageError instanceof NoOutputGeneratedError) { + // If we already yielded the empty-stream message, suppress this error + if (hasContent) { + throw usageError + } + // Otherwise the informative message was already yielded above — no-op + } else { + throw usageError } } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, model, "createMessage") + const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage") TelemetryService.instance.captureException(apiError) if (error instanceof Error) { @@ -366,7 +233,47 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl return { id: id.endsWith(":thinking") ? id.replace(":thinking", "") : id, info, ...params } } - private extractGroundingSources(groundingMetadata?: GroundingMetadata): GroundingSource[] { + /** + * Process usage metrics from the AI SDK response. + */ + protected processUsageMetrics( + usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + }, + info: ModelInfo, + providerMetadata?: Record, + ): ApiStreamUsageChunk { + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 + const cacheReadTokens = usage.details?.cachedInputTokens + const reasoningTokens = usage.details?.reasoningTokens + + return { + type: "usage", + inputTokens, + outputTokens, + cacheReadTokens, + reasoningTokens, + totalCost: this.calculateCost({ + info, + inputTokens, + outputTokens, + cacheReadTokens, + reasoningTokens, + }), + } + } + + private extractGroundingSources(groundingMetadata?: { + groundingChunks?: Array<{ + web?: { uri?: string; title?: string } + }> + }): GroundingSource[] { const chunks = groundingMetadata?.groundingChunks if (!chunks) { @@ -389,7 +296,11 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl .filter((source): source is GroundingSource => source !== null) } - private extractCitationsOnly(groundingMetadata?: GroundingMetadata): string | null { + private extractCitationsOnly(groundingMetadata?: { + groundingChunks?: Array<{ + web?: { uri?: string; title?: string } + }> + }): string | null { const sources = this.extractGroundingSources(groundingMetadata) if (sources.length === 0) { @@ -401,43 +312,36 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl } async completePrompt(prompt: string): Promise { - const { id: model, info } = this.getModel() + const { id: modelId, info } = this.getModel() try { - const tools: GenerateContentConfig["tools"] = [] - if (this.options.enableUrlContext) { - tools.push({ urlContext: {} }) - } - if (this.options.enableGrounding) { - tools.push({ googleSearch: {} }) - } - const supportsTemperature = info.supportsTemperature !== false const temperatureConfig: number | undefined = supportsTemperature ? (this.options.modelTemperature ?? info.defaultTemperature ?? 1) : info.defaultTemperature - const promptConfig: GenerateContentConfig = { - httpOptions: this.options.googleGeminiBaseUrl - ? { baseUrl: this.options.googleGeminiBaseUrl } - : undefined, + const result = await generateText({ + model: this.provider(modelId), + prompt, temperature: temperatureConfig, - ...(tools.length > 0 ? { tools } : {}), - } - - const request = { - model, - contents: [{ role: "user", parts: [{ text: prompt }] }], - config: promptConfig, - } - - const result = await this.client.models.generateContent(request) + }) let text = result.text ?? "" - const candidate = result.candidates?.[0] - if (candidate?.groundingMetadata) { - const citations = this.extractCitationsOnly(candidate.groundingMetadata) + // Extract grounding citations from providerMetadata if available + const providerMetadata = result.providerMetadata + const groundingMetadata = providerMetadata?.google as + | { + groundingMetadata?: { + groundingChunks?: Array<{ + web?: { uri?: string; title?: string } + }> + } + } + | undefined + + if (groundingMetadata?.groundingMetadata) { + const citations = this.extractCitationsOnly(groundingMetadata.groundingMetadata) if (citations) { text += `\n\n${t("common:errors.gemini.sources")} ${citations}` } @@ -446,7 +350,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl return text } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, model, "completePrompt") + const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt") TelemetryService.instance.captureException(apiError) if (error instanceof Error) { @@ -457,14 +361,6 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl } } - public getThoughtSignature(): string | undefined { - return this.lastThoughtSignature - } - - public getResponseId(): string | undefined { - return this.lastResponseId - } - public calculateCost({ info, inputTokens, @@ -528,4 +424,17 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl return totalCost } + + override isAiSdkProvider(): boolean { + return true + } + + /** + * Returns the thought signature captured from the last Gemini response. + * Gemini 3 models return thoughtSignature on function call parts, + * which must be round-tripped back for tool use continuations. + */ + getThoughtSignature(): string | undefined { + return this.lastThoughtSignature + } } diff --git a/src/api/providers/huggingface.ts b/src/api/providers/huggingface.ts deleted file mode 100644 index 21e429aaab..0000000000 --- a/src/api/providers/huggingface.ts +++ /dev/null @@ -1,137 +0,0 @@ -import OpenAI from "openai" -import { Anthropic } from "@anthropic-ai/sdk" - -import type { ModelRecord } from "@roo-code/types" - -import type { ApiHandlerOptions } from "../../shared/api" -import { ApiStream } from "../transform/stream" -import { convertToOpenAiMessages } from "../transform/openai-format" -import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { DEFAULT_HEADERS } from "./constants" -import { BaseProvider } from "./base-provider" -import { getHuggingFaceModels, getCachedHuggingFaceModels } from "./fetchers/huggingface" -import { handleOpenAIError } from "./utils/openai-error-handler" - -export class HuggingFaceHandler extends BaseProvider implements SingleCompletionHandler { - private client: OpenAI - private options: ApiHandlerOptions - private modelCache: ModelRecord | null = null - private readonly providerName = "HuggingFace" - - constructor(options: ApiHandlerOptions) { - super() - this.options = options - - if (!this.options.huggingFaceApiKey) { - throw new Error("Hugging Face API key is required") - } - - this.client = new OpenAI({ - baseURL: "https://router.huggingface.co/v1", - apiKey: this.options.huggingFaceApiKey, - defaultHeaders: DEFAULT_HEADERS, - }) - - // Try to get cached models first - this.modelCache = getCachedHuggingFaceModels() - - // Fetch models asynchronously - this.fetchModels() - } - - private async fetchModels() { - try { - this.modelCache = await getHuggingFaceModels() - } catch (error) { - console.error("Failed to fetch HuggingFace models:", error) - } - } - - override async *createMessage( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - const modelId = this.options.huggingFaceModelId || "meta-llama/Llama-3.3-70B-Instruct" - const temperature = this.options.modelTemperature ?? 0.7 - - const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { - model: modelId, - temperature, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], - stream: true, - stream_options: { include_usage: true }, - } - - // Add max_tokens if specified - if (this.options.includeMaxTokens && this.options.modelMaxTokens) { - params.max_tokens = this.options.modelMaxTokens - } - - let stream - try { - stream = await this.client.chat.completions.create(params) - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } - - for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta - - if (delta?.content) { - yield { - type: "text", - text: delta.content, - } - } - - if (chunk.usage) { - yield { - type: "usage", - inputTokens: chunk.usage.prompt_tokens || 0, - outputTokens: chunk.usage.completion_tokens || 0, - } - } - } - } - - async completePrompt(prompt: string): Promise { - const modelId = this.options.huggingFaceModelId || "meta-llama/Llama-3.3-70B-Instruct" - - try { - const response = await this.client.chat.completions.create({ - model: modelId, - messages: [{ role: "user", content: prompt }], - }) - - return response.choices[0]?.message.content || "" - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } - } - - override getModel() { - const modelId = this.options.huggingFaceModelId || "meta-llama/Llama-3.3-70B-Instruct" - - // Try to get model info from cache - const modelInfo = this.modelCache?.[modelId] - - if (modelInfo) { - return { - id: modelId, - info: modelInfo, - } - } - - // Fallback to default values if model not found in cache - return { - id: modelId, - info: { - maxTokens: 8192, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - }, - } - } -} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index cf49f75f18..9d72a9f194 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -1,16 +1,11 @@ export { AnthropicVertexHandler } from "./anthropic-vertex" export { AnthropicHandler } from "./anthropic" +export { AzureHandler } from "./azure" export { AwsBedrockHandler } from "./bedrock" -export { CerebrasHandler } from "./cerebras" -export { ChutesHandler } from "./chutes" export { DeepSeekHandler } from "./deepseek" -export { DoubaoHandler } from "./doubao" export { MoonshotHandler } from "./moonshot" export { FakeAIHandler } from "./fake-ai" export { GeminiHandler } from "./gemini" -export { GroqHandler } from "./groq" -export { HuggingFaceHandler } from "./huggingface" -export { IOIntelligenceHandler } from "./io-intelligence" export { LiteLLMHandler } from "./lite-llm" export { LmStudioHandler } from "./lm-studio" export { MistralHandler } from "./mistral" @@ -23,15 +18,12 @@ export { OpenRouterHandler } from "./openrouter" export { QwenCodeHandler } from "./qwen-code" export { RequestyHandler } from "./requesty" export { SambaNovaHandler } from "./sambanova" -export { UnboundHandler } from "./unbound" export { VertexHandler } from "./vertex" export { VsCodeLmHandler } from "./vscode-lm" export { XAIHandler } from "./xai" export { ZAiHandler } from "./zai" export { FireworksHandler } from "./fireworks" export { RooHandler } from "./roo" -export { FeatherlessHandler } from "./featherless" export { VercelAiGatewayHandler } from "./vercel-ai-gateway" -export { DeepInfraHandler } from "./deepinfra" export { MiniMaxHandler } from "./minimax" export { BasetenHandler } from "./baseten" diff --git a/src/api/providers/io-intelligence.ts b/src/api/providers/io-intelligence.ts deleted file mode 100644 index ef1c60a6a2..0000000000 --- a/src/api/providers/io-intelligence.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ioIntelligenceDefaultModelId, ioIntelligenceModels, type IOIntelligenceModelId } from "@roo-code/types" - -import type { ApiHandlerOptions } from "../../shared/api" -import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" - -export class IOIntelligenceHandler extends BaseOpenAiCompatibleProvider { - constructor(options: ApiHandlerOptions) { - if (!options.ioIntelligenceApiKey) { - throw new Error("IO Intelligence API key is required") - } - - super({ - ...options, - providerName: "IO Intelligence", - baseURL: "https://api.intelligence.io.solutions/api/v1", - defaultProviderModelId: ioIntelligenceDefaultModelId, - providerModels: ioIntelligenceModels, - defaultTemperature: 0.7, - apiKey: options.ioIntelligenceApiKey, - }) - } - - override getModel() { - const modelId = this.options.ioIntelligenceModelId || (ioIntelligenceDefaultModelId as IOIntelligenceModelId) - - const modelInfo = - this.providerModels[modelId as IOIntelligenceModelId] ?? this.providerModels[ioIntelligenceDefaultModelId] - - if (modelInfo) { - return { id: modelId as IOIntelligenceModelId, info: modelInfo } - } - - // Return the requested model ID even if not found, with fallback info. - return { - id: modelId as IOIntelligenceModelId, - info: { - maxTokens: 8192, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - }, - } - } -} diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index a771394c53..fdc95afb41 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -1,39 +1,49 @@ import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" -import axios from "axios" +import { streamText, generateText, ToolSet, wrapLanguageModel, extractReasoningMiddleware, LanguageModel } from "ai" import { type ModelInfo, openAiModelInfoSaneDefaults, LMSTUDIO_DEFAULT_TEMPERATURE } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" -import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser" -import { TagMatcher } from "../../utils/tag-matcher" - -import { convertToOpenAiMessages } from "../transform/openai-format" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" import { ApiStream } from "../transform/stream" -import { BaseProvider } from "./base-provider" +import { OpenAICompatibleHandler, OpenAICompatibleConfig } from "./openai-compatible" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { getModelsFromCache } from "./fetchers/modelCache" -import { getApiRequestTimeout } from "./utils/timeout-config" -import { handleOpenAIError } from "./utils/openai-error-handler" - -export class LmStudioHandler extends BaseProvider implements SingleCompletionHandler { - protected options: ApiHandlerOptions - private client: OpenAI - private readonly providerName = "LM Studio" +export class LmStudioHandler extends OpenAICompatibleHandler implements SingleCompletionHandler { constructor(options: ApiHandlerOptions) { - super() - this.options = options + const modelId = options.lmStudioModelId || "" + const baseURL = (options.lmStudioBaseUrl || "http://localhost:1234") + "/v1" - // LM Studio uses "noop" as a placeholder API key - const apiKey = "noop" + const models = getModelsFromCache("lmstudio") + const modelInfo = (models && modelId && models[modelId]) || openAiModelInfoSaneDefaults - this.client = new OpenAI({ - baseURL: (this.options.lmStudioBaseUrl || "http://localhost:1234") + "/v1", - apiKey: apiKey, - timeout: getApiRequestTimeout(), + const config: OpenAICompatibleConfig = { + providerName: "lmstudio", + baseURL, + apiKey: "noop", + modelId, + modelInfo, + temperature: options.modelTemperature ?? LMSTUDIO_DEFAULT_TEMPERATURE, + modelMaxTokens: options.modelMaxTokens ?? undefined, + } + + super(options, config) + } + + protected override getLanguageModel(): LanguageModel { + const baseModel = this.provider(this.config.modelId) + return wrapLanguageModel({ + model: baseModel, + middleware: extractReasoningMiddleware({ tagName: "think" }), }) } @@ -42,189 +52,83 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages(messages), - ] + const model = this.getModel() + const languageModel = this.getLanguageModel() - // ------------------------- - // Track token usage - // ------------------------- - const toContentBlocks = ( - blocks: Anthropic.Messages.MessageParam[] | string, - ): Anthropic.Messages.ContentBlockParam[] => { - if (typeof blocks === "string") { - return [{ type: "text", text: blocks }] - } + const aiSdkMessages = convertToAiSdkMessages(messages) - const result: Anthropic.Messages.ContentBlockParam[] = [] - for (const msg of blocks) { - if (typeof msg.content === "string") { - result.push({ type: "text", text: msg.content }) - } else if (Array.isArray(msg.content)) { - for (const part of msg.content) { - if (part.type === "text") { - result.push({ type: "text", text: part.text }) - } - } - } - } - return result + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + const requestOptions: Parameters[0] = { + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature: model.temperature ?? this.config.temperature ?? LMSTUDIO_DEFAULT_TEMPERATURE, + maxOutputTokens: this.getMaxOutputTokens(), + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), } - let inputTokens = 0 - try { - inputTokens = await this.countTokens([{ type: "text", text: systemPrompt }, ...toContentBlocks(messages)]) - } catch (err) { - console.error("[LmStudio] Failed to count input tokens:", err) - inputTokens = 0 + if (this.options.lmStudioSpeculativeDecodingEnabled && this.options.lmStudioDraftModelId) { + requestOptions.providerOptions = { + lmstudio: { draft_model: this.options.lmStudioDraftModelId }, + } } - let assistantText = "" + const result = streamText(requestOptions) try { - const params: OpenAI.Chat.ChatCompletionCreateParamsStreaming & { draft_model?: string } = { - model: this.getModel().id, - messages: openAiMessages, - temperature: this.options.modelTemperature ?? LMSTUDIO_DEFAULT_TEMPERATURE, - stream: true, - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, - } - - if (this.options.lmStudioSpeculativeDecodingEnabled && this.options.lmStudioDraftModelId) { - params.draft_model = this.options.lmStudioDraftModelId - } - - let results - try { - results = await this.client.chat.completions.create(params) - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } - - const matcher = new TagMatcher( - "think", - (chunk) => - ({ - type: chunk.matched ? "reasoning" : "text", - text: chunk.data, - }) as const, - ) - - for await (const chunk of results) { - const delta = chunk.choices[0]?.delta - const finishReason = chunk.choices[0]?.finish_reason - - if (delta?.content) { - assistantText += delta.content - for (const processedChunk of matcher.update(delta.content)) { - yield processedChunk - } - } - - // Handle tool calls in stream - emit partial chunks for NativeToolCallParser - if (delta?.tool_calls) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - - // Process finish_reason to emit tool_call_end events - if (finishReason) { - const endEvents = NativeToolCallParser.processFinishReason(finishReason) - for (const event of endEvents) { - yield event - } + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk } } - for (const processedChunk of matcher.final()) { - yield processedChunk + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage) } - - let outputTokens = 0 - try { - outputTokens = await this.countTokens([{ type: "text", text: assistantText }]) - } catch (err) { - console.error("[LmStudio] Failed to count output tokens:", err) - outputTokens = 0 - } - - yield { - type: "usage", - inputTokens, - outputTokens, - } as const } catch (error) { - throw new Error( - "Please check the LM Studio developer logs to debug what went wrong. You may need to load the model with a larger context length to work with Roo Code's prompts.", - ) + throw handleAiSdkError(error, "LM Studio") } } - override getModel(): { id: string; info: ModelInfo } { + override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { const models = getModelsFromCache("lmstudio") - if (models && this.options.lmStudioModelId && models[this.options.lmStudioModelId]) { - return { - id: this.options.lmStudioModelId, - info: models[this.options.lmStudioModelId], - } - } else { - return { - id: this.options.lmStudioModelId || "", - info: openAiModelInfoSaneDefaults, - } + const modelId = this.options.lmStudioModelId || "" + + const info = (models && modelId && models[modelId]) || openAiModelInfoSaneDefaults + + return { + id: modelId, + info, + temperature: this.options.modelTemperature ?? LMSTUDIO_DEFAULT_TEMPERATURE, + maxTokens: this.options.modelMaxTokens ?? undefined, } } - async completePrompt(prompt: string): Promise { + override async completePrompt(prompt: string): Promise { + const languageModel = this.getLanguageModel() + + const options: Parameters[0] = { + model: languageModel, + prompt, + maxOutputTokens: this.getMaxOutputTokens(), + temperature: this.options.modelTemperature ?? LMSTUDIO_DEFAULT_TEMPERATURE, + } + + if (this.options.lmStudioSpeculativeDecodingEnabled && this.options.lmStudioDraftModelId) { + options.providerOptions = { + lmstudio: { draft_model: this.options.lmStudioDraftModelId }, + } + } + try { - // Create params object with optional draft model - const params: any = { - model: this.getModel().id, - messages: [{ role: "user", content: prompt }], - temperature: this.options.modelTemperature ?? LMSTUDIO_DEFAULT_TEMPERATURE, - stream: false, - } - - // Add draft model if speculative decoding is enabled and a draft model is specified - if (this.options.lmStudioSpeculativeDecodingEnabled && this.options.lmStudioDraftModelId) { - params.draft_model = this.options.lmStudioDraftModelId - } - - let response - try { - response = await this.client.chat.completions.create(params) - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } - return response.choices[0]?.message.content || "" + const { text } = await generateText(options) + return text } catch (error) { - throw new Error( - "Please check the LM Studio developer logs to debug what went wrong. You may need to load the model with a larger context length to work with Roo Code's prompts.", - ) + throw handleAiSdkError(error, "LM Studio") } } } - -export async function getLmStudioModels(baseUrl = "http://localhost:1234") { - try { - if (!URL.canParse(baseUrl)) { - return [] - } - - const response = await axios.get(`${baseUrl}/v1/models`) - const modelsArray = response.data?.data?.map((model: any) => model.id) || [] - return [...new Set(modelsArray)] - } catch (error) { - return [] - } -} diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index e0e19298f4..be6665e324 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -1,224 +1,211 @@ import { Anthropic } from "@anthropic-ai/sdk" -import { Mistral } from "@mistralai/mistralai" -import OpenAI from "openai" +import { createMistral } from "@ai-sdk/mistral" +import { streamText, generateText, ToolSet, LanguageModel } from "ai" import { - type MistralModelId, - mistralDefaultModelId, mistralModels, + mistralDefaultModelId, + type MistralModelId, + type ModelInfo, MISTRAL_DEFAULT_TEMPERATURE, - ApiProviderError, } from "@roo-code/types" -import { TelemetryService } from "@roo-code/telemetry" -import { ApiHandlerOptions } from "../../shared/api" +import type { ApiHandlerOptions } from "../../shared/api" -import { convertToMistralMessages } from "../transform/mistral-format" -import { ApiStream } from "../transform/stream" -import { handleProviderError } from "./utils/error-handler" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + handleAiSdkError, +} from "../transform/ai-sdk" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { getModelParams } from "../transform/model-params" +import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -// Type helper to handle thinking chunks from Mistral API -// The SDK includes ThinkChunk but TypeScript has trouble with the discriminated union -type ContentChunkWithThinking = { - type: string - text?: string - thinking?: Array<{ type: string; text?: string }> -} - -// Type for Mistral tool calls in stream delta -type MistralToolCall = { - id?: string - type?: string - function?: { - name?: string - arguments?: string - } -} - -// Type for Mistral tool definition - matches Mistral SDK Tool type -type MistralTool = { - type: "function" - function: { - name: string - description?: string - parameters: Record - } -} - +/** + * Mistral provider using the dedicated @ai-sdk/mistral package. + * Provides access to Mistral AI models including Codestral, Mistral Large, and more. + */ export class MistralHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - private client: Mistral - private readonly providerName = "Mistral" + protected provider: ReturnType constructor(options: ApiHandlerOptions) { super() + this.options = options - if (!options.mistralApiKey) { - throw new Error("Mistral API key is required") - } + const modelId = options.apiModelId ?? mistralDefaultModelId - // Set default model ID if not provided. - const apiModelId = options.apiModelId || mistralDefaultModelId - this.options = { ...options, apiModelId } + // Determine the base URL based on the model (Codestral uses a different endpoint) + const baseURL = modelId.startsWith("codestral-") + ? options.mistralCodestralUrl || "https://codestral.mistral.ai/v1" + : "https://api.mistral.ai/v1" - this.client = new Mistral({ - serverURL: apiModelId.startsWith("codestral-") - ? this.options.mistralCodestralUrl || "https://codestral.mistral.ai" - : "https://api.mistral.ai", - apiKey: this.options.mistralApiKey, + // Create the Mistral provider using AI SDK + this.provider = createMistral({ + apiKey: options.mistralApiKey ?? "not-provided", + baseURL, + headers: DEFAULT_HEADERS, }) } + override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { + const id = (this.options.apiModelId ?? mistralDefaultModelId) as MistralModelId + const info = mistralModels[id as keyof typeof mistralModels] || mistralModels[mistralDefaultModelId] + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) + return { id, info, ...params } + } + + /** + * Get the language model for the configured model ID. + */ + protected getLanguageModel(): LanguageModel { + const { id } = this.getModel() + // Type assertion needed due to version mismatch between @ai-sdk/mistral and ai packages + return this.provider(id) as unknown as LanguageModel + } + + /** + * Process usage metrics from the AI SDK response. + */ + protected processUsageMetrics(usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + }): ApiStreamUsageChunk { + return { + type: "usage", + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheReadTokens: usage.details?.cachedInputTokens, + reasoningTokens: usage.details?.reasoningTokens, + } + } + + /** + * Map OpenAI tool_choice to AI SDK toolChoice format. + */ + protected mapToolChoice( + toolChoice: any, + ): "auto" | "none" | "required" | { type: "tool"; toolName: string } | undefined { + if (!toolChoice) { + return undefined + } + + // Handle string values + if (typeof toolChoice === "string") { + switch (toolChoice) { + case "auto": + return "auto" + case "none": + return "none" + case "required": + case "any": + return "required" + default: + return "auto" + } + } + + // Handle object values (OpenAI ChatCompletionNamedToolChoice format) + if (typeof toolChoice === "object" && "type" in toolChoice) { + if (toolChoice.type === "function" && "function" in toolChoice && toolChoice.function?.name) { + return { type: "tool", toolName: toolChoice.function.name } + } + } + + return undefined + } + + /** + * Get the max tokens parameter to include in the request. + */ + protected getMaxOutputTokens(): number | undefined { + const { info } = this.getModel() + return this.options.modelMaxTokens || info.maxTokens || undefined + } + + /** + * Create a message stream using the AI SDK. + */ override async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const { id: model, info, maxTokens, temperature } = this.getModel() + const languageModel = this.getLanguageModel() - // Build request options - const requestOptions: { - model: string - messages: ReturnType - maxTokens: number - temperature: number - tools?: MistralTool[] - toolChoice?: "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } } - } = { - model, - messages: [{ role: "system", content: systemPrompt }, ...convertToMistralMessages(messages)], - maxTokens: maxTokens ?? info.maxTokens, - temperature, + // Convert messages to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(messages) + + // Convert tools to OpenAI format first, then to AI SDK format + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + // Build the request options + // Use MISTRAL_DEFAULT_TEMPERATURE (1) as fallback to match original behavior + const requestOptions: Parameters[0] = { + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature: this.options.modelTemperature ?? MISTRAL_DEFAULT_TEMPERATURE, + maxOutputTokens: this.getMaxOutputTokens(), + tools: aiSdkTools, + toolChoice: this.mapToolChoice(metadata?.tool_choice), } - requestOptions.tools = this.convertToolsForMistral(metadata?.tools ?? []) - // Always use "any" to require tool use - requestOptions.toolChoice = "any" + // Use streamText for streaming responses + const result = streamText(requestOptions) - // Temporary debug log for QA - // console.log("[MISTRAL DEBUG] Raw API request body:", requestOptions) - - let response try { - response = await this.client.chat.stream(requestOptions) + // Process the full stream to get all events including reasoning + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } + + // Yield usage metrics at the end + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage) + } } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, model, "createMessage") - TelemetryService.instance.captureException(apiError) - throw new Error(`Mistral completion error: ${errorMessage}`) - } - - for await (const event of response) { - const delta = event.data.choices[0]?.delta - - if (delta?.content) { - if (typeof delta.content === "string") { - // Handle string content as text - yield { type: "text", text: delta.content } - } else if (Array.isArray(delta.content)) { - // Handle array of content chunks - // The SDK v1.9.18 supports ThinkChunk with type "thinking" - for (const chunk of delta.content as ContentChunkWithThinking[]) { - if (chunk.type === "thinking" && chunk.thinking) { - // Handle thinking content as reasoning chunks - // ThinkChunk has a 'thinking' property that contains an array of text/reference chunks - for (const thinkingPart of chunk.thinking) { - if (thinkingPart.type === "text" && thinkingPart.text) { - yield { type: "reasoning", text: thinkingPart.text } - } - } - } else if (chunk.type === "text" && chunk.text) { - // Handle text content normally - yield { type: "text", text: chunk.text } - } - } - } - } - - // Handle tool calls in stream - // Mistral SDK provides tool_calls in delta similar to OpenAI format - const toolCalls = (delta as { toolCalls?: MistralToolCall[] })?.toolCalls - if (toolCalls) { - for (let i = 0; i < toolCalls.length; i++) { - const toolCall = toolCalls[i] - yield { - type: "tool_call_partial", - index: i, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - - if (event.data.usage) { - yield { - type: "usage", - inputTokens: event.data.usage.promptTokens || 0, - outputTokens: event.data.usage.completionTokens || 0, - } - } + // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) + throw handleAiSdkError(error, "Mistral") } } /** - * Convert OpenAI tool definitions to Mistral format. - * Mistral uses the same format as OpenAI for function tools. + * Complete a prompt using the AI SDK generateText. */ - private convertToolsForMistral(tools: OpenAI.Chat.ChatCompletionTool[]): MistralTool[] { - return tools - .filter((tool) => tool.type === "function") - .map((tool) => ({ - type: "function" as const, - function: { - name: tool.function.name, - description: tool.function.description, - // Mistral SDK requires parameters to be defined, use empty object as fallback - parameters: (tool.function.parameters as Record) || {}, - }, - })) - } - - override getModel() { - const id = this.options.apiModelId ?? mistralDefaultModelId - const info = mistralModels[id as MistralModelId] ?? mistralModels[mistralDefaultModelId] - - // @TODO: Move this to the `getModelParams` function. - const maxTokens = this.options.includeMaxTokens ? info.maxTokens : undefined - const temperature = this.options.modelTemperature ?? MISTRAL_DEFAULT_TEMPERATURE - - return { id, info, maxTokens, temperature } - } - async completePrompt(prompt: string): Promise { - const { id: model, temperature } = this.getModel() + const languageModel = this.getLanguageModel() - try { - const response = await this.client.chat.complete({ - model, - messages: [{ role: "user", content: prompt }], - temperature, - }) + // Use MISTRAL_DEFAULT_TEMPERATURE (1) as fallback to match original behavior + const { text } = await generateText({ + model: languageModel, + prompt, + maxOutputTokens: this.getMaxOutputTokens(), + temperature: this.options.modelTemperature ?? MISTRAL_DEFAULT_TEMPERATURE, + }) - const content = response.choices?.[0]?.message.content + return text + } - if (Array.isArray(content)) { - // Only return text content, filter out thinking content for non-streaming - return (content as ContentChunkWithThinking[]) - .filter((c) => c.type === "text" && c.text) - .map((c) => c.text || "") - .join("") - } - - return content || "" - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, model, "completePrompt") - TelemetryService.instance.captureException(apiError) - throw new Error(`Mistral completion error: ${errorMessage}`) - } + override isAiSdkProvider(): boolean { + return true } } diff --git a/src/api/providers/moonshot.ts b/src/api/providers/moonshot.ts index f7a849cc02..3e90e48f7a 100644 --- a/src/api/providers/moonshot.ts +++ b/src/api/providers/moonshot.ts @@ -15,7 +15,7 @@ export class MoonshotHandler extends OpenAICompatibleHandler { const config: OpenAICompatibleConfig = { providerName: "moonshot", - baseURL: options.moonshotBaseUrl ?? "https://api.moonshot.ai/v1", + baseURL: options.moonshotBaseUrl || "https://api.moonshot.ai/v1", apiKey: options.moonshotApiKey ?? "not-provided", modelId, modelInfo, @@ -29,7 +29,13 @@ export class MoonshotHandler extends OpenAICompatibleHandler { override getModel() { const id = this.options.apiModelId ?? moonshotDefaultModelId const info = moonshotModels[id as keyof typeof moonshotModels] || moonshotModels[moonshotDefaultModelId] - const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options }) + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) return { id, info, ...params } } diff --git a/src/api/providers/openai-compatible.ts b/src/api/providers/openai-compatible.ts index 240de747be..8f810349ab 100644 --- a/src/api/providers/openai-compatible.ts +++ b/src/api/providers/openai-compatible.ts @@ -186,4 +186,8 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si return text } + + override isAiSdkProvider(): boolean { + return true + } } diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index abf1a562c7..4779db8340 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -1,7 +1,8 @@ import * as os from "os" import { v7 as uuidv7 } from "uuid" import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import { createOpenAI } from "@ai-sdk/openai" +import { streamText, generateText, ToolSet, type ModelMessage } from "ai" import { Package } from "../../shared/package" import { @@ -10,86 +11,221 @@ import { OpenAiNativeModelId, openAiNativeModels, OPENAI_NATIVE_DEFAULT_TEMPERATURE, - type ReasoningEffort, type VerbosityLevel, type ReasoningEffortExtended, type ServiceTier, - ApiProviderError, } from "@roo-code/types" -import { TelemetryService } from "@roo-code/telemetry" import type { ApiHandlerOptions } from "../../shared/api" - import { calculateApiCostOpenAI } from "../../shared/cost" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { isMcpTool } from "../../utils/mcp-name" -import { sanitizeOpenAiCallId } from "../../utils/tool-id" export type OpenAiNativeModel = ReturnType +/** + * An encrypted reasoning item extracted from the conversation history. + * These are standalone items injected by `buildCleanConversationHistory` with + * `{ type: "reasoning", encrypted_content: "...", id: "...", summary: [...] }`. + */ +export interface EncryptedReasoningItem { + id: string + encrypted_content: string + summary?: Array<{ type: string; text: string }> + originalIndex: number +} + +/** + * Strip plain-text reasoning blocks from assistant message content arrays. + * + * Plain-text reasoning blocks (`{ type: "reasoning", text: "..." }`) inside + * assistant content arrays would be converted by `convertToAiSdkMessages` + * into AI SDK reasoning parts WITHOUT `providerOptions.openai.itemId`. + * The `@ai-sdk/openai` Responses provider rejects those with console warnings. + * + * This function removes them BEFORE conversion. If an assistant message's + * content becomes empty after filtering, the message is removed entirely. + */ +export function stripPlainTextReasoningBlocks( + messages: Anthropic.Messages.MessageParam[], +): Anthropic.Messages.MessageParam[] { + return messages.reduce((acc, msg) => { + if (msg.role !== "assistant" || typeof msg.content === "string") { + acc.push(msg) + return acc + } + + const filteredContent = msg.content.filter((block) => { + const b = block as unknown as Record + // Remove blocks that are plain-text reasoning: + // type === "reasoning" AND has "text" AND does NOT have "encrypted_content" + if (b.type === "reasoning" && typeof b.text === "string" && !b.encrypted_content) { + return false + } + return true + }) + + // Only include the message if it still has content + if (filteredContent.length > 0) { + acc.push({ ...msg, content: filteredContent }) + } + + return acc + }, []) +} + +/** + * Collect encrypted reasoning items from the messages array. + * + * These are standalone items with `type: "reasoning"` and `encrypted_content`, + * injected by `buildCleanConversationHistory` for OpenAI Responses API + * reasoning continuity. + */ +export function collectEncryptedReasoningItems(messages: Anthropic.Messages.MessageParam[]): EncryptedReasoningItem[] { + const items: EncryptedReasoningItem[] = [] + messages.forEach((msg, index) => { + const m = msg as unknown as Record + if (m.type === "reasoning" && m.encrypted_content) { + items.push({ + id: m.id as string, + encrypted_content: m.encrypted_content as string, + summary: m.summary as Array<{ type: string; text: string }> | undefined, + originalIndex: index, + }) + } + }) + return items +} + +/** + * Inject encrypted reasoning parts into AI SDK messages. + * + * For each encrypted reasoning item, a reasoning part (with + * `providerOptions.openai.itemId` and `reasoningEncryptedContent`) is injected + * at the **beginning** of the next assistant message's content in the AI SDK + * messages array. + * + * @param aiSdkMessages - The converted AI SDK messages (mutated in place). + * @param encryptedItems - Encrypted reasoning items with their original indices. + * @param originalMessages - The original (unfiltered) messages array, used to + * determine which assistant message each encrypted item precedes. + */ +export function injectEncryptedReasoning( + aiSdkMessages: ModelMessage[], + encryptedItems: EncryptedReasoningItem[], + originalMessages: Anthropic.Messages.MessageParam[], +): void { + if (encryptedItems.length === 0) return + + // Map: original-array index of an assistant message -> encrypted items that precede it. + const itemsByAssistantOrigIdx = new Map() + + for (const item of encryptedItems) { + // Walk forward from the encrypted item to find its corresponding assistant message, + // skipping over any other encrypted reasoning items. + for (let i = item.originalIndex + 1; i < originalMessages.length; i++) { + const msg = originalMessages[i] as unknown as Record + if (msg.type === "reasoning" && msg.encrypted_content) continue + if ((msg as { role?: string }).role === "assistant") { + const existing = itemsByAssistantOrigIdx.get(i) || [] + existing.push(item) + itemsByAssistantOrigIdx.set(i, existing) + break + } + // Non-assistant, non-encrypted message — keep searching + } + } + + if (itemsByAssistantOrigIdx.size === 0) return + + // Collect the original indices of assistant messages that remain after + // encrypted reasoning items have been filtered out (order preserved). + const standardAssistantOriginalIndices: number[] = [] + for (let i = 0; i < originalMessages.length; i++) { + const msg = originalMessages[i] as unknown as Record + if (msg.type === "reasoning" && msg.encrypted_content) continue + if ((msg as { role?: string }).role === "assistant") { + standardAssistantOriginalIndices.push(i) + } + } + + // Collect assistant-role indices in the AI SDK messages array. + const aiSdkAssistantIndices: number[] = [] + for (let i = 0; i < aiSdkMessages.length; i++) { + if (aiSdkMessages[i].role === "assistant") { + aiSdkAssistantIndices.push(i) + } + } + + // Match: Nth standard assistant (by original index) -> Nth AI SDK assistant. + for (let n = 0; n < standardAssistantOriginalIndices.length && n < aiSdkAssistantIndices.length; n++) { + const origIdx = standardAssistantOriginalIndices[n] + const items = itemsByAssistantOrigIdx.get(origIdx) + if (!items || items.length === 0) continue + + const aiIdx = aiSdkAssistantIndices[n] + const msg = aiSdkMessages[aiIdx] as Record + const content = Array.isArray(msg.content) ? (msg.content as unknown[]) : [] + + const reasoningParts = items.map((item) => ({ + type: "reasoning" as const, + text: item.summary?.map((s) => s.text).join("\n") || "", + providerOptions: { + openai: { + itemId: item.id, + reasoningEncryptedContent: item.encrypted_content, + }, + }, + })) + + msg.content = [...reasoningParts, ...content] + } +} + +/** + * OpenAI Native provider using the dedicated @ai-sdk/openai package. + * Uses the OpenAI Responses API by default (AI SDK 5+). + * Supports reasoning models, service tiers, verbosity control, + * encrypted reasoning content, and prompt cache retention. + */ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - private client: OpenAI + protected provider: ReturnType private readonly providerName = "OpenAI Native" - // Session ID for request tracking (persists for the lifetime of the handler) private readonly sessionId: string - /** - * Some Responses streams emit tool-call argument deltas without stable call id/name. - * Track the last observed tool identity from output_item events so we can still - * emit `tool_call_partial` chunks (tool-call-only streams). - */ - private pendingToolCallId: string | undefined - private pendingToolCallName: string | undefined - // Resolved service tier from Responses API (actual tier used by OpenAI) - private lastServiceTier: ServiceTier | undefined - // Complete response output array (includes reasoning items with encrypted_content) - private lastResponseOutput: any[] | undefined - // Last top-level response id from Responses API (for troubleshooting) - private lastResponseId: string | undefined - // Abort controller for cancelling ongoing requests - private abortController?: AbortController - // Event types handled by the shared event processor to avoid duplication - private readonly coreHandledEventTypes = new Set([ - "response.text.delta", - "response.output_text.delta", - "response.reasoning.delta", - "response.reasoning_text.delta", - "response.reasoning_summary.delta", - "response.reasoning_summary_text.delta", - "response.refusal.delta", - "response.output_item.added", - "response.output_item.done", - "response.done", - "response.completed", - "response.tool_call_arguments.delta", - "response.function_call_arguments.delta", - "response.tool_call_arguments.done", - "response.function_call_arguments.done", - ]) + private lastResponseId: string | undefined + private lastEncryptedContent: { encrypted_content: string; id?: string } | undefined + private lastServiceTier: ServiceTier | undefined constructor(options: ApiHandlerOptions) { super() this.options = options - // Generate a session ID for request tracking this.sessionId = uuidv7() - // Default to including reasoning.summary: "auto" for models that support Responses API - // reasoning summaries unless explicitly disabled. + if (this.options.enableResponsesReasoningSummary === undefined) { this.options.enableResponsesReasoningSummary = true } + const apiKey = this.options.openAiNativeApiKey ?? "not-provided" - // Include originator, session_id, and User-Agent headers for API tracking and debugging + const baseURL = this.options.openAiNativeBaseUrl || undefined const userAgent = `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}` - this.client = new OpenAI({ - baseURL: this.options.openAiNativeBaseUrl, + + this.provider = createOpenAI({ apiKey, - defaultHeaders: { + baseURL, + headers: { originator: "roo-code", session_id: this.sessionId, "User-Agent": userAgent, @@ -97,1233 +233,10 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio }) } - private normalizeUsage(usage: any, model: OpenAiNativeModel): ApiStreamUsageChunk | undefined { - if (!usage) return undefined - - // Prefer detailed shapes when available (Responses API) - const inputDetails = usage.input_tokens_details ?? usage.prompt_tokens_details - - // Extract cache information from details with better readability - const hasCachedTokens = typeof inputDetails?.cached_tokens === "number" - const hasCacheMissTokens = typeof inputDetails?.cache_miss_tokens === "number" - const cachedFromDetails = hasCachedTokens ? inputDetails.cached_tokens : 0 - const missFromDetails = hasCacheMissTokens ? inputDetails.cache_miss_tokens : 0 - - // If total input tokens are missing but we have details, derive from them - let totalInputTokens = usage.input_tokens ?? usage.prompt_tokens ?? 0 - if (totalInputTokens === 0 && inputDetails && (cachedFromDetails > 0 || missFromDetails > 0)) { - totalInputTokens = cachedFromDetails + missFromDetails - } - - const totalOutputTokens = usage.output_tokens ?? usage.completion_tokens ?? 0 - - // Note: missFromDetails is NOT used as fallback for cache writes - // Cache miss tokens represent tokens that weren't found in cache (part of input) - // Cache write tokens represent tokens being written to cache for future use - const cacheWriteTokens = usage.cache_creation_input_tokens ?? usage.cache_write_tokens ?? 0 - - const cacheReadTokens = - usage.cache_read_input_tokens ?? usage.cache_read_tokens ?? usage.cached_tokens ?? cachedFromDetails ?? 0 - - // Resolve effective tier: prefer actual tier from response; otherwise requested tier - const effectiveTier = - this.lastServiceTier || (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined - const effectiveInfo = this.applyServiceTierPricing(model.info, effectiveTier) - - // Pass total input tokens directly to calculateApiCostOpenAI - // The function handles subtracting both cache reads and writes internally - const { totalCost } = calculateApiCostOpenAI( - effectiveInfo, - totalInputTokens, - totalOutputTokens, - cacheWriteTokens, - cacheReadTokens, - ) - - const reasoningTokens = - typeof usage.output_tokens_details?.reasoning_tokens === "number" - ? usage.output_tokens_details.reasoning_tokens - : undefined - - const out: ApiStreamUsageChunk = { - type: "usage", - // Keep inputTokens as TOTAL input to preserve correct context length - inputTokens: totalInputTokens, - outputTokens: totalOutputTokens, - cacheWriteTokens, - cacheReadTokens, - ...(typeof reasoningTokens === "number" ? { reasoningTokens } : {}), - totalCost, - } - return out - } - - override async *createMessage( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - const model = this.getModel() - - // Use Responses API for ALL models - yield* this.handleResponsesApiMessage(model, systemPrompt, messages, metadata) - } - - private async *handleResponsesApiMessage( - model: OpenAiNativeModel, - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - // Reset resolved tier for this request; will be set from response if present - this.lastServiceTier = undefined - // Reset output array to capture current response output items - this.lastResponseOutput = undefined - // Reset last response id for this request - this.lastResponseId = undefined - // Reset pending tool identity for this request - this.pendingToolCallId = undefined - this.pendingToolCallName = undefined - - // Use Responses API for ALL models - const { verbosity, reasoning } = this.getModel() - - // Resolve reasoning effort for models that support it - const reasoningEffort = this.getReasoningEffort(model) - - // Format full conversation (messages already include reasoning items from API history) - const formattedInput = this.formatFullConversation(systemPrompt, messages) - - // Build request body - const requestBody = this.buildRequestBody( - model, - formattedInput, - systemPrompt, - verbosity, - reasoningEffort, - metadata, - ) - - // Make the request (pass systemPrompt and messages for potential retry) - yield* this.executeRequest(requestBody, model, metadata, systemPrompt, messages) - } - - private buildRequestBody( - model: OpenAiNativeModel, - formattedInput: any, - systemPrompt: string, - verbosity: any, - reasoningEffort: ReasoningEffortExtended | undefined, - metadata?: ApiHandlerCreateMessageMetadata, - ): any { - // Ensure all properties are in the required array for OpenAI's strict mode - // This recursively processes nested objects and array items - const ensureAllRequired = (schema: any): any => { - if (!schema || typeof schema !== "object" || schema.type !== "object") { - return schema - } - - const result = { ...schema } - - // OpenAI Responses API requires additionalProperties: false on all object schemas - // Only add if not already set to false (to avoid unnecessary mutations) - if (result.additionalProperties !== false) { - result.additionalProperties = false - } - - if (result.properties) { - const allKeys = Object.keys(result.properties) - result.required = allKeys - - // Recursively process nested objects - const newProps = { ...result.properties } - for (const key of allKeys) { - const prop = newProps[key] - if (prop.type === "object") { - newProps[key] = ensureAllRequired(prop) - } else if (prop.type === "array" && prop.items?.type === "object") { - newProps[key] = { - ...prop, - items: ensureAllRequired(prop.items), - } - } - } - result.properties = newProps - } - - return result - } - - // Adds additionalProperties: false to all object schemas recursively - // without modifying required array. Used for MCP tools with strict: false - // to comply with OpenAI Responses API requirements. - const ensureAdditionalPropertiesFalse = (schema: any): any => { - if (!schema || typeof schema !== "object" || schema.type !== "object") { - return schema - } - - const result = { ...schema } - - // OpenAI Responses API requires additionalProperties: false on all object schemas - // Only add if not already set to false (to avoid unnecessary mutations) - if (result.additionalProperties !== false) { - result.additionalProperties = false - } - - if (result.properties) { - // Recursively process nested objects - const newProps = { ...result.properties } - for (const key of Object.keys(result.properties)) { - const prop = newProps[key] - if (prop && prop.type === "object") { - newProps[key] = ensureAdditionalPropertiesFalse(prop) - } else if (prop && prop.type === "array" && prop.items?.type === "object") { - newProps[key] = { - ...prop, - items: ensureAdditionalPropertiesFalse(prop.items), - } - } - } - result.properties = newProps - } - - return result - } - - // Build a request body for the OpenAI Responses API. - // Ensure we explicitly pass max_output_tokens based on Roo's reserved model response calculation - // so requests do not default to very large limits (e.g., 120k). - interface ResponsesRequestBody { - model: string - input: Array<{ role: "user" | "assistant"; content: any[] } | { type: string; content: string }> - stream: boolean - reasoning?: { effort?: ReasoningEffortExtended; summary?: "auto" } - text?: { verbosity: VerbosityLevel } - temperature?: number - max_output_tokens?: number - store?: boolean - instructions?: string - service_tier?: ServiceTier - include?: string[] - /** Prompt cache retention policy: "in_memory" (default) or "24h" for extended caching */ - prompt_cache_retention?: "in_memory" | "24h" - tools?: Array<{ - type: "function" - name: string - description?: string - parameters?: any - strict?: boolean - }> - tool_choice?: any - parallel_tool_calls?: boolean - } - - // Validate requested tier against model support; if not supported, omit. - const requestedTier = (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined - const allowedTierNames = new Set(model.info.tiers?.map((t) => t.name).filter(Boolean) || []) - - // Decide whether to enable extended prompt cache retention for this request - const promptCacheRetention = this.getPromptCacheRetention(model) - - const body: ResponsesRequestBody = { - model: model.id, - input: formattedInput, - stream: true, - // Always use stateless operation with encrypted reasoning - store: false, - // Always include instructions (system prompt) for Responses API. - // Unlike Chat Completions, system/developer roles in input have no special semantics here. - // The official way to set system behavior is the top-level `instructions` field. - instructions: systemPrompt, - // Only include encrypted reasoning content when reasoning effort is set - ...(reasoningEffort ? { include: ["reasoning.encrypted_content"] } : {}), - ...(reasoningEffort - ? { - reasoning: { - ...(reasoningEffort ? { effort: reasoningEffort } : {}), - ...(this.options.enableResponsesReasoningSummary ? { summary: "auto" as const } : {}), - }, - } - : {}), - // Only include temperature if the model supports it - ...(model.info.supportsTemperature !== false && { - temperature: this.options.modelTemperature ?? OPENAI_NATIVE_DEFAULT_TEMPERATURE, - }), - // Explicitly include the calculated max output tokens. - // Use the per-request reserved output computed by Roo (params.maxTokens from getModelParams). - ...(model.maxTokens ? { max_output_tokens: model.maxTokens } : {}), - // Include tier when selected and supported by the model, or when explicitly "default" - ...(requestedTier && - (requestedTier === "default" || allowedTierNames.has(requestedTier)) && { - service_tier: requestedTier, - }), - // Enable extended prompt cache retention for models that support it. - // This uses the OpenAI Responses API `prompt_cache_retention` parameter. - ...(promptCacheRetention ? { prompt_cache_retention: promptCacheRetention } : {}), - tools: (metadata?.tools ?? []) - .filter((tool) => tool.type === "function") - .map((tool) => { - // MCP tools use the 'mcp--' prefix - disable strict mode for them - // to preserve optional parameters from the MCP server schema - // But we still need to add additionalProperties: false for OpenAI Responses API - const isMcp = isMcpTool(tool.function.name) - return { - type: "function", - name: tool.function.name, - description: tool.function.description, - parameters: isMcp - ? ensureAdditionalPropertiesFalse(tool.function.parameters) - : ensureAllRequired(tool.function.parameters), - strict: !isMcp, - } - }), - tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, - } - - // Include text.verbosity only when the model explicitly supports it - if (model.info.supportsVerbosity === true) { - body.text = { verbosity: (verbosity || "medium") as VerbosityLevel } - } - - return body - } - - private async *executeRequest( - requestBody: any, - model: OpenAiNativeModel, - metadata?: ApiHandlerCreateMessageMetadata, - systemPrompt?: string, - messages?: Anthropic.Messages.MessageParam[], - ): ApiStream { - // Create AbortController for cancellation - this.abortController = new AbortController() - - // Build per-request headers using taskId when available, falling back to sessionId - const taskId = metadata?.taskId - const userAgent = `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}` - const requestHeaders: Record = { - originator: "roo-code", - session_id: taskId || this.sessionId, - "User-Agent": userAgent, - } - - try { - // Use the official SDK with per-request headers - const stream = (await (this.client as any).responses.create(requestBody, { - signal: this.abortController.signal, - headers: requestHeaders, - })) as AsyncIterable - - if (typeof (stream as any)[Symbol.asyncIterator] !== "function") { - throw new Error( - "OpenAI SDK did not return an AsyncIterable for Responses API streaming. Falling back to SSE.", - ) - } - - for await (const event of stream) { - // Check if request was aborted - if (this.abortController.signal.aborted) { - break - } - - for await (const outChunk of this.processEvent(event, model)) { - yield outChunk - } - } - } catch (sdkErr: any) { - // For errors, fallback to manual SSE via fetch - yield* this.makeResponsesApiRequest(requestBody, model, metadata, systemPrompt, messages) - } finally { - this.abortController = undefined - } - } - - private formatFullConversation(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): any { - // Format the entire conversation history for the Responses API using structured format - // The Responses API (like Realtime API) accepts a list of items, which can be messages, function calls, or function call outputs. - const formattedInput: any[] = [] - - // Do NOT embed the system prompt as a developer message in the Responses API input. - // The Responses API treats roles as free-form; use the top-level `instructions` field instead. - - // Process each message - for (const message of messages) { - // Check if this is a reasoning item (already formatted in API history) - if ((message as any).type === "reasoning") { - // Pass through reasoning items as-is - formattedInput.push(message) - continue - } - - if (message.role === "user") { - const content: any[] = [] - const toolResults: any[] = [] - - if (typeof message.content === "string") { - content.push({ type: "input_text", text: message.content }) - } else if (Array.isArray(message.content)) { - for (const block of message.content) { - if (block.type === "text") { - content.push({ type: "input_text", text: block.text }) - } else if (block.type === "image") { - const image = block as Anthropic.Messages.ImageBlockParam - const imageUrl = `data:${image.source.media_type};base64,${image.source.data}` - content.push({ type: "input_image", image_url: imageUrl }) - } else if (block.type === "tool_result") { - // Map Anthropic tool_result to Responses API function_call_output item - const result = - typeof block.content === "string" - ? block.content - : block.content?.map((c) => (c.type === "text" ? c.text : "")).join("") || "" - toolResults.push({ - type: "function_call_output", - // Sanitize and truncate call_id to fit OpenAI's 64-char limit - call_id: sanitizeOpenAiCallId(block.tool_use_id), - output: result, - }) - } - } - } - - // Add user message first - if (content.length > 0) { - formattedInput.push({ role: "user", content }) - } - - // Add tool results as separate items - if (toolResults.length > 0) { - formattedInput.push(...toolResults) - } - } else if (message.role === "assistant") { - const content: any[] = [] - const toolCalls: any[] = [] - - if (typeof message.content === "string") { - content.push({ type: "output_text", text: message.content }) - } else if (Array.isArray(message.content)) { - for (const block of message.content) { - if (block.type === "text") { - content.push({ type: "output_text", text: block.text }) - } else if (block.type === "tool_use") { - // Map Anthropic tool_use to Responses API function_call item - toolCalls.push({ - type: "function_call", - // Sanitize and truncate call_id to fit OpenAI's 64-char limit - call_id: sanitizeOpenAiCallId(block.id), - name: block.name, - arguments: JSON.stringify(block.input), - }) - } - } - } - - // Add assistant message if it has content - if (content.length > 0) { - formattedInput.push({ role: "assistant", content }) - } - - // Add tool calls as separate items - if (toolCalls.length > 0) { - formattedInput.push(...toolCalls) - } - } - } - - return formattedInput - } - - private async *makeResponsesApiRequest( - requestBody: any, - model: OpenAiNativeModel, - metadata?: ApiHandlerCreateMessageMetadata, - systemPrompt?: string, - messages?: Anthropic.Messages.MessageParam[], - ): ApiStream { - const apiKey = this.options.openAiNativeApiKey ?? "not-provided" - const baseUrl = this.options.openAiNativeBaseUrl || "https://api.openai.com" - const url = `${baseUrl}/v1/responses` - - // Create AbortController for cancellation - this.abortController = new AbortController() - - // Build per-request headers using taskId when available, falling back to sessionId - const taskId = metadata?.taskId - const userAgent = `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}` - - try { - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - originator: "roo-code", - session_id: taskId || this.sessionId, - "User-Agent": userAgent, - }, - body: JSON.stringify(requestBody), - signal: this.abortController.signal, - }) - - if (!response.ok) { - const errorText = await response.text() - - let errorMessage = `OpenAI Responses API request failed (${response.status})` - let errorDetails = "" - - // Try to parse error as JSON for better error messages - try { - const errorJson = JSON.parse(errorText) - if (errorJson.error?.message) { - errorDetails = errorJson.error.message - } else if (errorJson.message) { - errorDetails = errorJson.message - } else { - errorDetails = errorText - } - } catch { - // If not JSON, use the raw text - errorDetails = errorText - } - - // Provide user-friendly error messages based on status code - switch (response.status) { - case 400: - errorMessage = "Invalid request to Responses API. Please check your input parameters." - break - case 401: - errorMessage = "Authentication failed. Please check your OpenAI API key." - break - case 403: - errorMessage = "Access denied. Your API key may not have access to this endpoint." - break - case 404: - errorMessage = - "Responses API endpoint not found. The endpoint may not be available yet or requires a different configuration." - break - case 429: - errorMessage = "Rate limit exceeded. Please try again later." - break - case 500: - case 502: - case 503: - errorMessage = "OpenAI service error. Please try again later." - break - default: - errorMessage = `Responses API error (${response.status})` - } - - // Append details if available - if (errorDetails) { - errorMessage += ` - ${errorDetails}` - } - - throw new Error(errorMessage) - } - - if (!response.body) { - throw new Error("Responses API error: No response body") - } - - // Handle streaming response - yield* this.handleStreamResponse(response.body, model) - } catch (error) { - const model = this.getModel() - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, model.id, "createMessage") - TelemetryService.instance.captureException(apiError) - - if (error instanceof Error) { - // Re-throw with the original error message if it's already formatted - if (error.message.includes("Responses API")) { - throw error - } - // Otherwise, wrap it with context - throw new Error(`Failed to connect to Responses API: ${error.message}`) - } - // Handle non-Error objects - throw new Error(`Unexpected error connecting to Responses API`) - } finally { - this.abortController = undefined - } - } - - /** - * Handles the streaming response from the Responses API. - * - * This function iterates through the Server-Sent Events (SSE) stream, parses each event, - * and yields structured data chunks (`ApiStream`). It handles a wide variety of event types, - * including text deltas, reasoning, usage data, and various status/tool events. - */ - private async *handleStreamResponse(body: ReadableStream, model: OpenAiNativeModel): ApiStream { - const reader = body.getReader() - const decoder = new TextDecoder() - let buffer = "" - let hasContent = false - let totalInputTokens = 0 - let totalOutputTokens = 0 - - try { - while (true) { - // Check if request was aborted - if (this.abortController?.signal.aborted) { - break - } - - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split("\n") - buffer = lines.pop() || "" - - for (const line of lines) { - if (line.startsWith("data: ")) { - const data = line.slice(6).trim() - if (data === "[DONE]") { - continue - } - - try { - const parsed = JSON.parse(data) - - // Capture resolved service tier if present - if (parsed.response?.service_tier) { - this.lastServiceTier = parsed.response.service_tier as ServiceTier - } - // Capture complete output array (includes reasoning items with encrypted_content) - if (parsed.response?.output && Array.isArray(parsed.response.output)) { - this.lastResponseOutput = parsed.response.output - } - // Capture top-level response id - if (parsed.response?.id) { - this.lastResponseId = parsed.response.id as string - } - - // Delegate standard event types to the shared processor to avoid duplication - if (parsed?.type && this.coreHandledEventTypes.has(parsed.type)) { - for await (const outChunk of this.processEvent(parsed, model)) { - // Track whether we've emitted any content so fallback handling can decide appropriately - // Include tool calls so tool-call-only responses aren't treated as empty - if ( - outChunk.type === "text" || - outChunk.type === "reasoning" || - outChunk.type === "tool_call" || - outChunk.type === "tool_call_partial" - ) { - hasContent = true - } - yield outChunk - } - continue - } - - // Check if this is a complete response (non-streaming format) - if (parsed.response && parsed.response.output && Array.isArray(parsed.response.output)) { - // Handle complete response in the initial event - for (const outputItem of parsed.response.output) { - if (outputItem.type === "text" && outputItem.content) { - for (const content of outputItem.content) { - if (content.type === "text" && content.text) { - hasContent = true - yield { - type: "text", - text: content.text, - } - } - } - } - // Additionally handle reasoning summaries if present (non-streaming summary output) - if (outputItem.type === "reasoning" && Array.isArray(outputItem.summary)) { - for (const summary of outputItem.summary) { - if (summary?.type === "summary_text" && typeof summary.text === "string") { - hasContent = true - yield { - type: "reasoning", - text: summary.text, - } - } - } - } - } - // Check for usage in the complete response - if (parsed.response.usage) { - const usageData = this.normalizeUsage(parsed.response.usage, model) - if (usageData) { - yield usageData - } - } - } - // Handle streaming delta events for text content - else if ( - parsed.type === "response.text.delta" || - parsed.type === "response.output_text.delta" - ) { - // Primary streaming event for text deltas - if (parsed.delta) { - hasContent = true - yield { - type: "text", - text: parsed.delta, - } - } - } else if ( - parsed.type === "response.text.done" || - parsed.type === "response.output_text.done" - ) { - // Text streaming completed - final text already streamed via deltas - } - // Handle reasoning delta events - else if ( - parsed.type === "response.reasoning.delta" || - parsed.type === "response.reasoning_text.delta" - ) { - // Streaming reasoning content - if (parsed.delta) { - hasContent = true - yield { - type: "reasoning", - text: parsed.delta, - } - } - } else if ( - parsed.type === "response.reasoning.done" || - parsed.type === "response.reasoning_text.done" - ) { - // Reasoning streaming completed - } - // Handle reasoning summary events - else if ( - parsed.type === "response.reasoning_summary.delta" || - parsed.type === "response.reasoning_summary_text.delta" - ) { - // Streaming reasoning summary - if (parsed.delta) { - hasContent = true - yield { - type: "reasoning", - text: parsed.delta, - } - } - } else if ( - parsed.type === "response.reasoning_summary.done" || - parsed.type === "response.reasoning_summary_text.done" - ) { - // Reasoning summary completed - } - // Handle refusal delta events - else if (parsed.type === "response.refusal.delta") { - // Model is refusing to answer - if (parsed.delta) { - hasContent = true - yield { - type: "text", - text: `[Refusal] ${parsed.delta}`, - } - } - } else if (parsed.type === "response.refusal.done") { - // Refusal completed - } - // Handle audio delta events (for multimodal responses) - else if (parsed.type === "response.audio.delta") { - // Audio streaming - we'll skip for now as we focus on text - // Could be handled in future for voice responses - } else if (parsed.type === "response.audio.done") { - // Audio completed - } - // Handle audio transcript delta events - else if (parsed.type === "response.audio_transcript.delta") { - // Audio transcript streaming - if (parsed.delta) { - hasContent = true - yield { - type: "text", - text: parsed.delta, - } - } - } else if (parsed.type === "response.audio_transcript.done") { - // Audio transcript completed - } - // Handle content part events (for structured content) - else if (parsed.type === "response.content_part.added") { - // New content part added - could be text, image, etc. - if (parsed.part?.type === "text" && parsed.part.text) { - hasContent = true - yield { - type: "text", - text: parsed.part.text, - } - } - } else if (parsed.type === "response.content_part.done") { - // Content part completed - } - // Handle output item events (alternative format) - else if (parsed.type === "response.output_item.added") { - // This is where the actual content comes through in some test cases - if (parsed.item) { - if (parsed.item.type === "text" && parsed.item.text) { - hasContent = true - yield { type: "text", text: parsed.item.text } - } else if (parsed.item.type === "reasoning" && parsed.item.text) { - hasContent = true - yield { type: "reasoning", text: parsed.item.text } - } else if (parsed.item.type === "message" && parsed.item.content) { - // Handle message type items - for (const content of parsed.item.content) { - if (content.type === "text" && content.text) { - hasContent = true - yield { type: "text", text: content.text } - } - } - } - } - } else if (parsed.type === "response.output_item.done") { - // Output item completed - } - // Handle function/tool call events - else if ( - parsed.type === "response.function_call_arguments.delta" || - parsed.type === "response.tool_call_arguments.delta" || - parsed.type === "response.function_call_arguments.done" || - parsed.type === "response.tool_call_arguments.done" - ) { - // Delegated to processEvent (handles accumulation and completion) - for await (const outChunk of this.processEvent(parsed, model)) { - yield outChunk - } - } - // Handle MCP (Model Context Protocol) tool events - else if (parsed.type === "response.mcp_call_arguments.delta") { - // MCP tool call arguments streaming - } else if (parsed.type === "response.mcp_call_arguments.done") { - // MCP tool call completed - } else if (parsed.type === "response.mcp_call.in_progress") { - // MCP tool call in progress - } else if ( - parsed.type === "response.mcp_call.completed" || - parsed.type === "response.mcp_call.failed" - ) { - // MCP tool call status events - } else if (parsed.type === "response.mcp_list_tools.in_progress") { - // MCP list tools in progress - } else if ( - parsed.type === "response.mcp_list_tools.completed" || - parsed.type === "response.mcp_list_tools.failed" - ) { - // MCP list tools status events - } - // Handle web search events - else if (parsed.type === "response.web_search_call.searching") { - // Web search in progress - } else if (parsed.type === "response.web_search_call.in_progress") { - // Processing web search results - } else if (parsed.type === "response.web_search_call.completed") { - // Web search completed - } - // Handle code interpreter events - else if (parsed.type === "response.code_interpreter_call_code.delta") { - // Code interpreter code streaming - if (parsed.delta) { - // Could yield as a special code type if needed - } - } else if (parsed.type === "response.code_interpreter_call_code.done") { - // Code interpreter code completed - } else if (parsed.type === "response.code_interpreter_call.interpreting") { - // Code interpreter running - } else if (parsed.type === "response.code_interpreter_call.in_progress") { - // Code execution in progress - } else if (parsed.type === "response.code_interpreter_call.completed") { - // Code interpreter completed - } - // Handle file search events - else if (parsed.type === "response.file_search_call.searching") { - // File search in progress - } else if (parsed.type === "response.file_search_call.in_progress") { - // Processing file search results - } else if (parsed.type === "response.file_search_call.completed") { - // File search completed - } - // Handle image generation events - else if (parsed.type === "response.image_gen_call.generating") { - // Image generation in progress - } else if (parsed.type === "response.image_gen_call.in_progress") { - // Processing image generation - } else if (parsed.type === "response.image_gen_call.partial_image") { - // Image partially generated - } else if (parsed.type === "response.image_gen_call.completed") { - // Image generation completed - } - // Handle computer use events - else if ( - parsed.type === "response.computer_tool_call.output_item" || - parsed.type === "response.computer_tool_call.output_screenshot" - ) { - // Computer use tool events - } - // Handle annotation events - else if ( - parsed.type === "response.output_text_annotation.added" || - parsed.type === "response.text_annotation.added" - ) { - // Text annotation events - could be citations, references, etc. - } - // Handle error events - else if (parsed.type === "response.error" || parsed.type === "error") { - // Error event from the API - if (parsed.error || parsed.message) { - throw new Error( - `Responses API error: ${parsed.error?.message || parsed.message || "Unknown error"}`, - ) - } - } - // Handle incomplete event - else if (parsed.type === "response.incomplete") { - // Response was incomplete - might need to handle specially - } - // Handle queued event - else if (parsed.type === "response.queued") { - // Response is queued - } - // Handle in_progress event - else if (parsed.type === "response.in_progress") { - // Response is being processed - } - // Handle failed event - else if (parsed.type === "response.failed") { - // Response failed - if (parsed.error || parsed.message) { - throw new Error( - `Response failed: ${parsed.error?.message || parsed.message || "Unknown failure"}`, - ) - } - } else if (parsed.type === "response.completed" || parsed.type === "response.done") { - // Capture resolved service tier if present - if (parsed.response?.service_tier) { - this.lastServiceTier = parsed.response.service_tier as ServiceTier - } - // Capture top-level response id - if (parsed.response?.id) { - this.lastResponseId = parsed.response.id as string - } - // Capture complete output array (includes reasoning items with encrypted_content) - if (parsed.response?.output && Array.isArray(parsed.response.output)) { - this.lastResponseOutput = parsed.response.output - } - - // Check if the done event contains the complete output (as a fallback) - if ( - !hasContent && - parsed.response && - parsed.response.output && - Array.isArray(parsed.response.output) - ) { - for (const outputItem of parsed.response.output) { - if (outputItem.type === "message" && outputItem.content) { - for (const content of outputItem.content) { - if (content.type === "output_text" && content.text) { - hasContent = true - yield { - type: "text", - text: content.text, - } - } - } - } - // Also surface reasoning summaries if present in the final output - if (outputItem.type === "reasoning" && Array.isArray(outputItem.summary)) { - for (const summary of outputItem.summary) { - if ( - summary?.type === "summary_text" && - typeof summary.text === "string" - ) { - hasContent = true - yield { - type: "reasoning", - text: summary.text, - } - } - } - } - } - } - - // Usage for done/completed is already handled by processEvent in the SDK path. - // For SSE path, usage often arrives separately; avoid double-emitting here. - } - // These are structural or status events, we can just log them at a lower level or ignore. - else if ( - parsed.type === "response.created" || - parsed.type === "response.in_progress" || - parsed.type === "response.output_item.done" || - parsed.type === "response.content_part.added" || - parsed.type === "response.content_part.done" - ) { - // Status events - no action needed - } - // Fallback for older formats or unexpected responses - else if (parsed.choices?.[0]?.delta?.content) { - hasContent = true - yield { - type: "text", - text: parsed.choices[0].delta.content, - } - } - // Additional fallback: some events place text under 'item.text' even if type isn't matched above - else if ( - parsed.item && - typeof parsed.item.text === "string" && - parsed.item.text.length > 0 - ) { - hasContent = true - yield { - type: "text", - text: parsed.item.text, - } - } else if (parsed.usage) { - // Handle usage if it arrives in a separate, non-completed event - const usageData = this.normalizeUsage(parsed.usage, model) - if (usageData) { - yield usageData - } - } - } catch (e) { - // Only ignore JSON parsing errors, re-throw actual API errors - if (!(e instanceof SyntaxError)) { - throw e - } - } - } - // Also try to parse non-SSE formatted lines - else if (line.trim() && !line.startsWith(":")) { - try { - const parsed = JSON.parse(line) - - // Try to extract content from various possible locations - if (parsed.content || parsed.text || parsed.message) { - hasContent = true - yield { - type: "text", - text: parsed.content || parsed.text || parsed.message, - } - } - } catch { - // Not JSON, might be plain text - ignore - } - } - } - } - - // If we didn't get any content, don't throw - the API might have returned an empty response - // This can happen in certain edge cases and shouldn't break the flow - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, model.id, "createMessage") - TelemetryService.instance.captureException(apiError) - - if (error instanceof Error) { - throw new Error(`Error processing response stream: ${error.message}`) - } - throw new Error("Unexpected error processing response stream") - } finally { - reader.releaseLock() - } - } - - /** - * Shared processor for Responses API events. - */ - private async *processEvent(event: any, model: OpenAiNativeModel): ApiStream { - // Capture resolved service tier when available - if (event?.response?.service_tier) { - this.lastServiceTier = event.response.service_tier as ServiceTier - } - // Capture complete output array (includes reasoning items with encrypted_content) - if (event?.response?.output && Array.isArray(event.response.output)) { - this.lastResponseOutput = event.response.output - } - // Capture top-level response id - if (event?.response?.id) { - this.lastResponseId = event.response.id as string - } - - // Handle known streaming text deltas - if (event?.type === "response.text.delta" || event?.type === "response.output_text.delta") { - if (event?.delta) { - yield { type: "text", text: event.delta } - } - return - } - - // Handle reasoning deltas (including summary variants) - if ( - event?.type === "response.reasoning.delta" || - event?.type === "response.reasoning_text.delta" || - event?.type === "response.reasoning_summary.delta" || - event?.type === "response.reasoning_summary_text.delta" - ) { - if (event?.delta) { - yield { type: "reasoning", text: event.delta } - } - return - } - - // Handle refusal deltas - if (event?.type === "response.refusal.delta") { - if (event?.delta) { - yield { type: "text", text: `[Refusal] ${event.delta}` } - } - return - } - - // Handle tool/function call deltas - emit as partial chunks - if ( - event?.type === "response.tool_call_arguments.delta" || - event?.type === "response.function_call_arguments.delta" - ) { - // Some streams omit stable identity on delta events; fall back to the - // most recently observed tool identity from output_item events. - const callId = event.call_id || event.tool_call_id || event.id || this.pendingToolCallId || undefined - const name = event.name || event.function_name || this.pendingToolCallName || undefined - const args = event.delta || event.arguments - - // Avoid emitting incomplete tool_call_partial chunks; the downstream - // NativeToolCallParser needs a name to start a call. - if (typeof name === "string" && name.length > 0 && typeof callId === "string" && callId.length > 0) { - yield { - type: "tool_call_partial", - index: event.index ?? 0, - id: callId, - name, - arguments: args, - } - } - return - } - - // Handle tool/function call completion events - if ( - event?.type === "response.tool_call_arguments.done" || - event?.type === "response.function_call_arguments.done" - ) { - // Tool call complete - no action needed, NativeToolCallParser handles completion - return - } - - // Handle output item additions/completions (SDK or Responses API alternative format) - if (event?.type === "response.output_item.added" || event?.type === "response.output_item.done") { - const item = event?.item - if (item) { - // Capture tool identity so subsequent argument deltas can be attributed. - if (item.type === "function_call" || item.type === "tool_call") { - const callId = item.call_id || item.tool_call_id || item.id - const name = item.name || item.function?.name || item.function_name - if (typeof callId === "string" && callId.length > 0) { - this.pendingToolCallId = callId - this.pendingToolCallName = typeof name === "string" ? name : undefined - } - } - - // For "added" events, yield text/reasoning content (streaming path) - // For "done" events, do NOT yield text/reasoning - it's already been streamed via deltas - // and would cause double-emission (A, B, C, ABC). - if (event.type === "response.output_item.added") { - if (item.type === "text" && item.text) { - yield { type: "text", text: item.text } - } else if (item.type === "reasoning" && item.text) { - yield { type: "reasoning", text: item.text } - } else if (item.type === "message" && Array.isArray(item.content)) { - for (const content of item.content) { - // Some implementations send 'text'; others send 'output_text' - if ((content?.type === "text" || content?.type === "output_text") && content?.text) { - yield { type: "text", text: content.text } - } - } - } - } - - // Note: We intentionally do NOT emit tool_call from response.output_item.done - // for function_call/tool_call items. The streaming path handles tool calls via: - // 1. tool_call_partial events during argument deltas - // 2. NativeToolCallParser.finalizeRawChunks() at stream end emitting tool_call_end - // 3. NativeToolCallParser.finalizeStreamingToolCall() creating the final ToolUse - // Emitting tool_call here would cause duplicate tool rendering. - } - return - } - - // Completion events that may carry usage - if (event?.type === "response.done" || event?.type === "response.completed") { - const usage = event?.response?.usage || event?.usage || undefined - const usageData = this.normalizeUsage(usage, model) - if (usageData) { - yield usageData - } - return - } - - // Fallbacks for older formats or unexpected objects - if (event?.choices?.[0]?.delta?.content) { - yield { type: "text", text: event.choices[0].delta.content } - return - } - - if (event?.usage) { - const usageData = this.normalizeUsage(event.usage, model) - if (usageData) { - yield usageData - } - } - } - - private getReasoningEffort(model: OpenAiNativeModel): ReasoningEffortExtended | undefined { - // Single source of truth: user setting overrides, else model default (from types). - const selected = (this.options.reasoningEffort as any) ?? (model.info.reasoningEffort as any) - return selected && selected !== "disable" ? (selected as any) : undefined - } - - /** - * Returns the appropriate prompt cache retention policy for the given model, if any. - * - * The policy is driven by ModelInfo.promptCacheRetention so that model-specific details - * live in the shared types layer rather than this provider. When set to "24h" and the - * model supports prompt caching, extended prompt cache retention is requested. - */ - private getPromptCacheRetention(model: OpenAiNativeModel): "24h" | undefined { - if (!model.info.supportsPromptCache) return undefined - - if (model.info.promptCacheRetention === "24h") { - return "24h" - } - - return undefined - } - - /** - * Returns a shallow-cloned ModelInfo with pricing overridden for the given tier, if available. - * If no tier or no overrides exist, the original ModelInfo is returned. - */ - private applyServiceTierPricing(info: ModelInfo, tier?: ServiceTier): ModelInfo { - if (!tier || tier === "default") return info - - // Find the tier with matching name in the tiers array - const tierInfo = info.tiers?.find((t) => t.name === tier) - if (!tierInfo) return info - - return { - ...info, - inputPrice: tierInfo.inputPrice ?? info.inputPrice, - outputPrice: tierInfo.outputPrice ?? info.outputPrice, - cacheReadsPrice: tierInfo.cacheReadsPrice ?? info.cacheReadsPrice, - cacheWritesPrice: tierInfo.cacheWritesPrice ?? info.cacheWritesPrice, - } - } - - // Removed isResponsesApiModel method as ALL models now use the Responses API - override getModel() { const modelId = this.options.apiModelId - let id = + const id = modelId && modelId in openAiNativeModels ? (modelId as OpenAiNativeModelId) : openAiNativeDefaultModelId const info: ModelInfo = openAiNativeModels[id] @@ -1336,138 +249,303 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio defaultTemperature: OPENAI_NATIVE_DEFAULT_TEMPERATURE, }) - // Reasoning effort inclusion is handled by getModelParams/getOpenAiReasoning. - // Do not re-compute or filter efforts here. - - // The o3 models are named like "o3-mini-[reasoning-effort]", which are - // not valid model ids, so we need to strip the suffix. return { id: id.startsWith("o3-mini") ? "o3-mini" : id, info, ...params, verbosity: params.verbosity } } /** - * Extracts encrypted_content and id from the first reasoning item in the output array. - * This is the minimal data needed for stateless API continuity. - * - * @returns Object with encrypted_content and id, or undefined if not available + * Get the language model for the configured model ID. + * Uses the Responses API (default for @ai-sdk/openai since AI SDK 5). */ - getEncryptedContent(): { encrypted_content: string; id?: string } | undefined { - if (!this.lastResponseOutput) return undefined + protected getLanguageModel() { + const { id } = this.getModel() + return this.provider.responses(id) + } - // Find the first reasoning item with encrypted_content - const reasoningItem = this.lastResponseOutput.find( - (item) => item.type === "reasoning" && item.encrypted_content, - ) + private getReasoningEffort(model: OpenAiNativeModel): ReasoningEffortExtended | undefined { + const selected = (this.options.reasoningEffort as any) ?? (model.info.reasoningEffort as any) + return selected && selected !== "disable" ? (selected as any) : undefined + } - if (!reasoningItem?.encrypted_content) return undefined + /** + * Returns the appropriate prompt cache retention policy for the given model, if any. + */ + private getPromptCacheRetention(model: OpenAiNativeModel): "24h" | undefined { + if (!model.info.supportsPromptCache) return undefined + if (model.info.promptCacheRetention === "24h") return "24h" + return undefined + } + + /** + * Returns a shallow-cloned ModelInfo with pricing overridden for the given tier, if available. + */ + private applyServiceTierPricing(info: ModelInfo, tier?: ServiceTier): ModelInfo { + if (!tier || tier === "default") return info + + const tierInfo = info.tiers?.find((t) => t.name === tier) + if (!tierInfo) return info return { - encrypted_content: reasoningItem.encrypted_content, - ...(reasoningItem.id ? { id: reasoningItem.id } : {}), + ...info, + inputPrice: tierInfo.inputPrice ?? info.inputPrice, + outputPrice: tierInfo.outputPrice ?? info.outputPrice, + cacheReadsPrice: tierInfo.cacheReadsPrice ?? info.cacheReadsPrice, + cacheWritesPrice: tierInfo.cacheWritesPrice ?? info.cacheWritesPrice, } } + /** + * Build OpenAI-specific provider options for the Responses API. + */ + private buildProviderOptions( + model: OpenAiNativeModel, + metadata?: ApiHandlerCreateMessageMetadata, + ): Record { + const reasoningEffort = this.getReasoningEffort(model) + const promptCacheRetention = this.getPromptCacheRetention(model) + + const requestedTier = (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined + const allowedTierNames = new Set(model.info.tiers?.map((t) => t.name).filter(Boolean) || []) + + const openaiOptions: Record = { + store: false, + parallelToolCalls: metadata?.parallelToolCalls ?? true, + } + + if (reasoningEffort) { + openaiOptions.reasoningEffort = reasoningEffort + openaiOptions.include = ["reasoning.encrypted_content"] + + if (this.options.enableResponsesReasoningSummary) { + openaiOptions.reasoningSummary = "auto" + } + } + + if (model.info.supportsVerbosity === true) { + openaiOptions.textVerbosity = (model.verbosity || "medium") as VerbosityLevel + } + + if (requestedTier && (requestedTier === "default" || allowedTierNames.has(requestedTier))) { + openaiOptions.serviceTier = requestedTier + } + + if (promptCacheRetention) { + openaiOptions.promptCacheRetention = promptCacheRetention + } + + return { openai: openaiOptions } + } + + /** + * Process usage metrics from the AI SDK response, including OpenAI-specific + * cache metrics and service-tier-adjusted pricing. + */ + protected processUsageMetrics( + usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + }, + model: OpenAiNativeModel, + providerMetadata?: Record, + ): ApiStreamUsageChunk { + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 + + const cacheReadTokens = usage.details?.cachedInputTokens ?? 0 + // The OpenAI Responses API does not report cache write tokens separately; + // only cached (read) tokens are available via usage.details.cachedInputTokens. + const cacheWriteTokens = 0 + const reasoningTokens = usage.details?.reasoningTokens + + const effectiveTier = + this.lastServiceTier || (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined + const effectiveInfo = this.applyServiceTierPricing(model.info, effectiveTier) + + const { totalCost } = calculateApiCostOpenAI( + effectiveInfo, + inputTokens, + outputTokens, + cacheWriteTokens, + cacheReadTokens, + ) + + return { + type: "usage", + inputTokens, + outputTokens, + cacheWriteTokens: cacheWriteTokens || undefined, + cacheReadTokens: cacheReadTokens || undefined, + ...(typeof reasoningTokens === "number" ? { reasoningTokens } : {}), + totalCost, + } + } + + /** + * Get the max output tokens parameter. + */ + protected getMaxOutputTokens(): number | undefined { + const model = this.getModel() + return model.maxTokens ?? undefined + } + + /** + * Create a message stream using the AI SDK. + */ + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const model = this.getModel() + const languageModel = this.getLanguageModel() + + this.lastResponseId = undefined + this.lastEncryptedContent = undefined + this.lastServiceTier = undefined + + // Step 1: Collect encrypted reasoning items and their positions before filtering. + // These are standalone items injected by buildCleanConversationHistory: + // { type: "reasoning", encrypted_content: "...", id: "...", summary: [...] } + const encryptedReasoningItems = collectEncryptedReasoningItems(messages) + + // Step 2: Filter out standalone encrypted reasoning items (they lack role + // and would break convertToAiSdkMessages which expects user/assistant/tool). + const standardMessages = messages.filter( + (msg) => + (msg as unknown as Record).type !== "reasoning" || + !(msg as unknown as Record).encrypted_content, + ) + + // Step 3: Strip plain-text reasoning blocks from assistant content arrays. + // These would be converted to AI SDK reasoning parts WITHOUT + // providerOptions.openai.itemId, which the Responses provider rejects. + const cleanedMessages = stripPlainTextReasoningBlocks(standardMessages) + + // Step 4: Convert to AI SDK messages. + const aiSdkMessages = convertToAiSdkMessages(cleanedMessages) + + // Step 5: Re-inject encrypted reasoning as properly-formed AI SDK reasoning + // parts with providerOptions.openai.itemId and reasoningEncryptedContent. + if (encryptedReasoningItems.length > 0) { + injectEncryptedReasoning(aiSdkMessages, encryptedReasoningItems, messages) + } + + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + const taskId = metadata?.taskId + const userAgent = `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}` + const requestHeaders: Record = { + originator: "roo-code", + session_id: taskId || this.sessionId, + "User-Agent": userAgent, + } + + const providerOptions = this.buildProviderOptions(model, metadata) + + const requestOptions: Parameters[0] = { + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + headers: requestHeaders, + providerOptions, + ...(model.info.supportsTemperature !== false && { + temperature: this.options.modelTemperature ?? OPENAI_NATIVE_DEFAULT_TEMPERATURE, + }), + ...(model.maxTokens ? { maxOutputTokens: model.maxTokens } : {}), + } + + const result = streamText(requestOptions) + + try { + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } + + const providerMeta = await result.providerMetadata + const openaiMeta = (providerMeta as any)?.openai + + if (openaiMeta?.responseId) { + this.lastResponseId = openaiMeta.responseId + } + if (openaiMeta?.serviceTier) { + this.lastServiceTier = openaiMeta.serviceTier as ServiceTier + } + + // Capture encrypted content from reasoning parts in the response + try { + const content = await (result as any).content + if (Array.isArray(content)) { + for (const part of content) { + if (part.type === "reasoning" && part.providerMetadata) { + const partMeta = (part.providerMetadata as any)?.openai + if (partMeta?.reasoningEncryptedContent) { + this.lastEncryptedContent = { + encrypted_content: partMeta.reasoningEncryptedContent, + ...(partMeta.itemId ? { id: partMeta.itemId } : {}), + } + break + } + } + } + } + } catch { + // Content parts with encrypted reasoning may not always be available + } + + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage, model, providerMeta as any) + } + } catch (error) { + throw handleAiSdkError(error, this.providerName) + } + } + + /** + * Extracts encrypted_content and id from the last response's reasoning output. + */ + getEncryptedContent(): { encrypted_content: string; id?: string } | undefined { + return this.lastEncryptedContent + } + getResponseId(): string | undefined { return this.lastResponseId } + /** + * Complete a prompt using the AI SDK generateText. + */ async completePrompt(prompt: string): Promise { - // Create AbortController for cancellation - this.abortController = new AbortController() + const model = this.getModel() + const languageModel = this.getLanguageModel() + const providerOptions = this.buildProviderOptions(model) try { - const model = this.getModel() - const { verbosity, reasoning } = model - - // Resolve reasoning effort for models that support it - const reasoningEffort = this.getReasoningEffort(model) - - // Build request body for Responses API - const requestBody: any = { - model: model.id, - input: [ - { - role: "user", - content: [{ type: "input_text", text: prompt }], - }, - ], - stream: false, // Non-streaming for completePrompt - store: false, // Don't store prompt completions - // Only include encrypted reasoning content when reasoning effort is set - ...(reasoningEffort ? { include: ["reasoning.encrypted_content"] } : {}), - } - - // Include service tier if selected and supported - const requestedTier = (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined - const allowedTierNames = new Set(model.info.tiers?.map((t) => t.name).filter(Boolean) || []) - if (requestedTier && (requestedTier === "default" || allowedTierNames.has(requestedTier))) { - requestBody.service_tier = requestedTier - } - - // Add reasoning if supported - if (reasoningEffort) { - requestBody.reasoning = { - effort: reasoningEffort, - ...(this.options.enableResponsesReasoningSummary ? { summary: "auto" as const } : {}), - } - } - - // Only include temperature if the model supports it - if (model.info.supportsTemperature !== false) { - requestBody.temperature = this.options.modelTemperature ?? OPENAI_NATIVE_DEFAULT_TEMPERATURE - } - - // Include max_output_tokens if available - if (model.maxTokens) { - requestBody.max_output_tokens = model.maxTokens - } - - // Include text.verbosity only when the model explicitly supports it - if (model.info.supportsVerbosity === true) { - requestBody.text = { verbosity: (verbosity || "medium") as VerbosityLevel } - } - - // Enable extended prompt cache retention for eligible models - const promptCacheRetention = this.getPromptCacheRetention(model) - if (promptCacheRetention) { - requestBody.prompt_cache_retention = promptCacheRetention - } - - // Make the non-streaming request - const response = await (this.client as any).responses.create(requestBody, { - signal: this.abortController.signal, + const { text } = await generateText({ + model: languageModel, + prompt, + providerOptions, + ...(model.info.supportsTemperature !== false && { + temperature: this.options.modelTemperature ?? OPENAI_NATIVE_DEFAULT_TEMPERATURE, + }), + ...(model.maxTokens ? { maxOutputTokens: model.maxTokens } : {}), }) - // Extract text from the response - if (response?.output && Array.isArray(response.output)) { - for (const outputItem of response.output) { - if (outputItem.type === "message" && outputItem.content) { - for (const content of outputItem.content) { - if (content.type === "output_text" && content.text) { - return content.text - } - } - } - } - } - - // Fallback: check for direct text in response - if (response?.text) { - return response.text - } - - return "" + return text } catch (error) { - const errorModel = this.getModel() - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, errorModel.id, "completePrompt") - TelemetryService.instance.captureException(apiError) - - if (error instanceof Error) { - throw new Error(`OpenAI Native completion error: ${error.message}`) - } - throw error - } finally { - this.abortController = undefined + throw handleAiSdkError(error, this.providerName) } } + + override isAiSdkProvider(): boolean { + return true + } } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 87589b9396..33b29abcaf 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -37,7 +37,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl super() this.options = options - const baseURL = this.options.openAiBaseUrl ?? "https://api.openai.com/v1" + const baseURL = this.options.openAiBaseUrl || "https://api.openai.com/v1" const apiKey = this.options.openAiApiKey ?? "not-provided" const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) const urlHost = this._getUrlHost(this.options.openAiBaseUrl) @@ -282,7 +282,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl override getModel() { const id = this.options.openAiModelId ?? "" const info: ModelInfo = this.options.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults - const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options }) + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) return { id, info, ...params } } diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index c3b5accbc3..b241c347b0 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -89,6 +89,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan modelId: id, model: info, settings: this.options, + defaultTemperature: 0, }) return { id, info, ...params } diff --git a/src/api/providers/sambanova.ts b/src/api/providers/sambanova.ts index a15bc12577..e1fee21506 100644 --- a/src/api/providers/sambanova.ts +++ b/src/api/providers/sambanova.ts @@ -1,19 +1,184 @@ -import { type SambaNovaModelId, sambaNovaDefaultModelId, sambaNovaModels } from "@roo-code/types" +import { Anthropic } from "@anthropic-ai/sdk" +import { createSambaNova } from "sambanova-ai-provider" +import { streamText, generateText, ToolSet } from "ai" + +import { sambaNovaModels, sambaNovaDefaultModelId, type ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" -import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, + flattenAiSdkMessagesToStringContent, +} from "../transform/ai-sdk" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { getModelParams } from "../transform/model-params" + +import { DEFAULT_HEADERS } from "./constants" +import { BaseProvider } from "./base-provider" +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" + +const SAMBANOVA_DEFAULT_TEMPERATURE = 0.7 + +/** + * SambaNova provider using the dedicated sambanova-ai-provider package. + * Provides native support for various models including Llama models. + */ +export class SambaNovaHandler extends BaseProvider implements SingleCompletionHandler { + protected options: ApiHandlerOptions + protected provider: ReturnType -export class SambaNovaHandler extends BaseOpenAiCompatibleProvider { constructor(options: ApiHandlerOptions) { - super({ - ...options, - providerName: "SambaNova", + super() + this.options = options + + // Create the SambaNova provider using AI SDK + this.provider = createSambaNova({ baseURL: "https://api.sambanova.ai/v1", - apiKey: options.sambaNovaApiKey, - defaultProviderModelId: sambaNovaDefaultModelId, - providerModels: sambaNovaModels, - defaultTemperature: 0.7, + apiKey: options.sambaNovaApiKey ?? "not-provided", + headers: DEFAULT_HEADERS, }) } + + override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { + const id = this.options.apiModelId ?? sambaNovaDefaultModelId + const info = sambaNovaModels[id as keyof typeof sambaNovaModels] || sambaNovaModels[sambaNovaDefaultModelId] + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: SAMBANOVA_DEFAULT_TEMPERATURE, + }) + return { id, info, ...params } + } + + /** + * Get the language model for the configured model ID. + */ + protected getLanguageModel() { + const { id } = this.getModel() + return this.provider(id) + } + + /** + * Process usage metrics from the AI SDK response. + */ + protected processUsageMetrics( + usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + }, + providerMetadata?: { + sambanova?: { + promptCacheHitTokens?: number + promptCacheMissTokens?: number + } + }, + ): ApiStreamUsageChunk { + // Extract cache metrics from SambaNova's providerMetadata if available + const cacheReadTokens = providerMetadata?.sambanova?.promptCacheHitTokens ?? usage.details?.cachedInputTokens + const cacheWriteTokens = providerMetadata?.sambanova?.promptCacheMissTokens + + return { + type: "usage", + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheReadTokens, + cacheWriteTokens, + reasoningTokens: usage.details?.reasoningTokens, + } + } + + /** + * Get the max tokens parameter to include in the request. + */ + protected getMaxOutputTokens(): number | undefined { + const { info } = this.getModel() + return this.options.modelMaxTokens || info.maxTokens || undefined + } + + /** + * Create a message stream using the AI SDK. + */ + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const { temperature, info } = this.getModel() + const languageModel = this.getLanguageModel() + + // Convert messages to AI SDK format + // For models that don't support multi-part content (like DeepSeek), flatten messages to string content + // SambaNova's DeepSeek models expect string content, not array content + const aiSdkMessages = convertToAiSdkMessages(messages, { + transform: info.supportsImages ? undefined : flattenAiSdkMessagesToStringContent, + }) + + // Convert tools to OpenAI format first, then to AI SDK format + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + // Build the request options + const requestOptions: Parameters[0] = { + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature: this.options.modelTemperature ?? temperature ?? SAMBANOVA_DEFAULT_TEMPERATURE, + maxOutputTokens: this.getMaxOutputTokens(), + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + } + + // Use streamText for streaming responses + const result = streamText(requestOptions) + + try { + // Process the full stream to get all events including reasoning + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } + + // Yield usage metrics at the end, including cache metrics from providerMetadata + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, providerMetadata as any) + } + } catch (error) { + // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) + throw handleAiSdkError(error, "SambaNova") + } + } + + /** + * Complete a prompt using the AI SDK generateText. + */ + async completePrompt(prompt: string): Promise { + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() + + const { text } = await generateText({ + model: languageModel, + prompt, + maxOutputTokens: this.getMaxOutputTokens(), + temperature: this.options.modelTemperature ?? temperature ?? SAMBANOVA_DEFAULT_TEMPERATURE, + }) + + return text + } + + override isAiSdkProvider(): boolean { + return true + } } diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts deleted file mode 100644 index 76dd60d976..0000000000 --- a/src/api/providers/unbound.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" - -import { unboundDefaultModelId, unboundDefaultModelInfo } from "@roo-code/types" - -import type { ApiHandlerOptions } from "../../shared/api" - -import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" -import { convertToOpenAiMessages } from "../transform/openai-format" -import { addCacheBreakpoints as addAnthropicCacheBreakpoints } from "../transform/caching/anthropic" -import { addCacheBreakpoints as addGeminiCacheBreakpoints } from "../transform/caching/gemini" -import { addCacheBreakpoints as addVertexCacheBreakpoints } from "../transform/caching/vertex" - -import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { RouterProvider } from "./router-provider" -import { getModelParams } from "../transform/model-params" -import { getModels } from "./fetchers/modelCache" - -const ORIGIN_APP = "roo-code" - -const DEFAULT_HEADERS = { - "X-Unbound-Metadata": JSON.stringify({ labels: [{ key: "app", value: "roo-code" }] }), -} - -interface UnboundUsage extends OpenAI.CompletionUsage { - cache_creation_input_tokens?: number - cache_read_input_tokens?: number -} - -type UnboundChatCompletionCreateParamsStreaming = OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & { - unbound_metadata: { - originApp: string - taskId?: string - mode?: string - } -} - -type UnboundChatCompletionCreateParamsNonStreaming = OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming & { - unbound_metadata: { - originApp: string - } -} - -export class UnboundHandler extends RouterProvider implements SingleCompletionHandler { - constructor(options: ApiHandlerOptions) { - super({ - options, - name: "unbound", - baseURL: "https://api.getunbound.ai/v1", - apiKey: options.unboundApiKey, - modelId: options.unboundModelId, - defaultModelId: unboundDefaultModelId, - defaultModelInfo: unboundDefaultModelInfo, - }) - } - - public override async fetchModel() { - this.models = await getModels({ provider: this.name, apiKey: this.client.apiKey, baseUrl: this.client.baseURL }) - return this.getModel() - } - - override getModel() { - const requestedId = this.options.unboundModelId ?? unboundDefaultModelId - const modelExists = this.models[requestedId] - const id = modelExists ? requestedId : unboundDefaultModelId - const info = modelExists ? this.models[requestedId] : unboundDefaultModelInfo - - const params = getModelParams({ - format: "openai", - modelId: id, - model: info, - settings: this.options, - }) - - return { id, info, ...params } - } - - override async *createMessage( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - // Ensure we have up-to-date model metadata - await this.fetchModel() - const { id: modelId, info } = this.getModel() - - const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages(messages), - ] - - if (info.supportsPromptCache) { - if (modelId.startsWith("google/")) { - addGeminiCacheBreakpoints(systemPrompt, openAiMessages) - } else if (modelId.startsWith("anthropic/")) { - addAnthropicCacheBreakpoints(systemPrompt, openAiMessages) - } - } - // Custom models from Vertex AI (no configuration) need to be handled differently. - if (modelId.startsWith("vertex-ai/google.") || modelId.startsWith("vertex-ai/anthropic.")) { - addVertexCacheBreakpoints(messages) - } - - // Required by Anthropic; other providers default to max tokens allowed. - let maxTokens: number | undefined - - if (modelId.startsWith("anthropic/")) { - maxTokens = info.maxTokens ?? undefined - } - - const requestOptions: UnboundChatCompletionCreateParamsStreaming = { - model: modelId.split("/")[1], - max_tokens: maxTokens, - messages: openAiMessages, - stream: true, - stream_options: { include_usage: true }, - unbound_metadata: { - originApp: ORIGIN_APP, - taskId: metadata?.taskId, - mode: metadata?.mode, - }, - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, - } - - if (this.supportsTemperature(modelId)) { - requestOptions.temperature = this.options.modelTemperature ?? 0 - } - - const { data: completion } = await this.client.chat.completions - .create(requestOptions, { headers: DEFAULT_HEADERS }) - .withResponse() - - for await (const chunk of completion) { - const delta = chunk.choices[0]?.delta - const usage = chunk.usage as UnboundUsage - - if (delta?.content) { - yield { type: "text", text: delta.content } - } - - // Handle tool calls in stream - emit partial chunks for NativeToolCallParser - if (delta?.tool_calls) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - - if (usage) { - const usageData: ApiStreamUsageChunk = { - type: "usage", - inputTokens: usage.prompt_tokens || 0, - outputTokens: usage.completion_tokens || 0, - } - - // Only add cache tokens if they exist. - if (usage.cache_creation_input_tokens) { - usageData.cacheWriteTokens = usage.cache_creation_input_tokens - } - - if (usage.cache_read_input_tokens) { - usageData.cacheReadTokens = usage.cache_read_input_tokens - } - - yield usageData - } - } - } - - async completePrompt(prompt: string): Promise { - const { id: modelId, info } = await this.fetchModel() - - try { - const requestOptions: UnboundChatCompletionCreateParamsNonStreaming = { - model: modelId.split("/")[1], - messages: [{ role: "user", content: prompt }], - unbound_metadata: { - originApp: ORIGIN_APP, - }, - } - - if (this.supportsTemperature(modelId)) { - requestOptions.temperature = this.options.modelTemperature ?? 0 - } - - if (modelId.startsWith("anthropic/")) { - requestOptions.max_tokens = info.maxTokens - } - - const response = await this.client.chat.completions.create(requestOptions, { headers: DEFAULT_HEADERS }) - return response.choices[0]?.message.content || "" - } catch (error) { - if (error instanceof Error) { - throw new Error(`Unbound completion error: ${error.message}`) - } - - throw error - } - } -} diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index 2c077d97b7..c772741e6a 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -1,22 +1,218 @@ -import { type ModelInfo, type VertexModelId, vertexDefaultModelId, vertexModels } from "@roo-code/types" +import type { Anthropic } from "@anthropic-ai/sdk" +import { createVertex, type GoogleVertexProvider } from "@ai-sdk/google-vertex" +import { streamText, generateText, ToolSet } from "ai" + +import { + type ModelInfo, + type VertexModelId, + vertexDefaultModelId, + vertexModels, + ApiProviderError, +} from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" import type { ApiHandlerOptions } from "../../shared/api" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, +} from "../transform/ai-sdk" +import { t } from "i18next" +import type { ApiStream, ApiStreamUsageChunk, GroundingSource } from "../transform/stream" import { getModelParams } from "../transform/model-params" -import { GeminiHandler } from "./gemini" -import { SingleCompletionHandler } from "../index" +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +import { BaseProvider } from "./base-provider" +import { DEFAULT_HEADERS } from "./constants" + +/** + * Vertex AI provider using the dedicated @ai-sdk/google-vertex package. + * Provides native support for Google's Vertex AI with proper authentication. + */ +export class VertexHandler extends BaseProvider implements SingleCompletionHandler { + protected options: ApiHandlerOptions + protected provider: GoogleVertexProvider + private readonly providerName = "Vertex" + private lastThoughtSignature: string | undefined -export class VertexHandler extends GeminiHandler implements SingleCompletionHandler { constructor(options: ApiHandlerOptions) { - super({ ...options, isVertex: true }) + super() + this.options = options + + // Build googleAuthOptions based on provided credentials + let googleAuthOptions: { credentials?: object; keyFile?: string } | undefined + if (options.vertexJsonCredentials) { + try { + googleAuthOptions = { credentials: JSON.parse(options.vertexJsonCredentials) } + } catch { + // If JSON parsing fails, ignore and try other auth methods + } + } else if (options.vertexKeyFile) { + googleAuthOptions = { keyFile: options.vertexKeyFile } + } + + // Create the Vertex AI provider using AI SDK + this.provider = createVertex({ + project: options.vertexProjectId, + location: options.vertexRegion, + googleAuthOptions, + headers: DEFAULT_HEADERS, + }) + } + + async *createMessage( + systemInstruction: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const { id: modelId, info, reasoning: thinkingConfig, maxTokens } = this.getModel() + + // For hybrid/budget reasoning models (e.g. Gemini 2.5 Pro), respect user-configured + // modelMaxTokens so the ThinkingBudget slider can control the cap. For effort-only or + // standard models (like gemini-3-pro-preview), ignore any stale modelMaxTokens and + // default to the model's computed maxTokens from getModelMaxOutputTokens. + const isHybridReasoningModel = info.supportsReasoningBudget || info.requiredReasoningBudget + const maxOutputTokens = isHybridReasoningModel + ? (this.options.modelMaxTokens ?? maxTokens ?? undefined) + : (maxTokens ?? undefined) + + // Determine temperature respecting model capabilities and defaults: + // - If supportsTemperature is explicitly false, ignore user overrides + // and pin to the model's defaultTemperature (or omit if undefined). + // - Otherwise, allow the user setting to override, falling back to model default, + // then to 1 for Gemini provider default. + const supportsTemperature = info.supportsTemperature !== false + const temperatureConfig: number | undefined = supportsTemperature + ? (this.options.modelTemperature ?? info.defaultTemperature ?? 1) + : info.defaultTemperature + + // The message list can include provider-specific meta entries such as + // `{ type: "reasoning", ... }` that are intended only for providers like + // openai-native. Vertex should never see those; they are not valid + // Anthropic.MessageParam values and will cause failures. + type ReasoningMetaLike = { type?: string } + + const filteredMessages = messages.filter((message): message is Anthropic.Messages.MessageParam => { + const meta = message as ReasoningMetaLike + if (meta.type === "reasoning") { + return false + } + return true + }) + + // Convert messages to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(filteredMessages) + + // Convert tools to OpenAI format first, then to AI SDK format + let openAiTools = this.convertToolsForOpenAI(metadata?.tools) + + // Filter tools based on allowedFunctionNames for mode-restricted tool access + if (metadata?.allowedFunctionNames && metadata.allowedFunctionNames.length > 0 && openAiTools) { + const allowedSet = new Set(metadata.allowedFunctionNames) + openAiTools = openAiTools.filter((tool) => tool.type === "function" && allowedSet.has(tool.function.name)) + } + + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + // Build tool choice - use 'required' when allowedFunctionNames restricts available tools + const toolChoice = + metadata?.allowedFunctionNames && metadata.allowedFunctionNames.length > 0 + ? "required" + : mapToolChoice(metadata?.tool_choice) + + // Build the request options + const requestOptions: Parameters[0] = { + model: this.provider(modelId), + system: systemInstruction, + messages: aiSdkMessages, + temperature: temperatureConfig, + maxOutputTokens, + tools: aiSdkTools, + toolChoice, + // Add thinking/reasoning configuration if present + // Cast to any to bypass strict JSONObject typing - the AI SDK accepts the correct runtime values + ...(thinkingConfig && { + providerOptions: { vertex: { thinkingConfig } } as any, + }), + } + + try { + // Reset thought signature for this request + this.lastThoughtSignature = undefined + + // Use streamText for streaming responses + const result = streamText(requestOptions) + + // Process the full stream to get all events including reasoning + for await (const part of result.fullStream) { + // Capture thoughtSignature from tool-call events (Gemini 3 thought signatures) + // The AI SDK's tool-call event includes providerMetadata with the signature + // Vertex AI stores it under the "vertex" key in providerMetadata + if (part.type === "tool-call") { + const vertexMeta = (part as any).providerMetadata?.vertex + const googleMeta = (part as any).providerMetadata?.google + const sig = vertexMeta?.thoughtSignature ?? googleMeta?.thoughtSignature + if (sig) { + this.lastThoughtSignature = sig + } + } + + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } + + // Extract grounding sources from providerMetadata if available + const providerMetadata = await result.providerMetadata + const groundingMetadata = (providerMetadata?.vertex ?? providerMetadata?.google) as + | { + groundingMetadata?: { + groundingChunks?: Array<{ + web?: { uri?: string; title?: string } + }> + } + } + | undefined + + if (groundingMetadata?.groundingMetadata) { + const sources = this.extractGroundingSources(groundingMetadata.groundingMetadata) + if (sources.length > 0) { + yield { type: "grounding", sources } + } + } + + // Yield usage metrics at the end + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage, info, providerMetadata) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage") + TelemetryService.instance.captureException(apiError) + + if (error instanceof Error) { + throw new Error(t("common:errors.gemini.generate_stream", { error: error.message })) + } + + throw error + } } override getModel() { const modelId = this.options.apiModelId let id = modelId && modelId in vertexModels ? (modelId as VertexModelId) : vertexDefaultModelId - const info: ModelInfo = vertexModels[id] - const params = getModelParams({ format: "gemini", modelId: id, model: info, settings: this.options }) + let info: ModelInfo = vertexModels[id] + + const params = getModelParams({ + format: "gemini", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: info.defaultTemperature ?? 1, + }) // The `:thinking` suffix indicates that the model is a "Hybrid" // reasoning model and that reasoning is required to be enabled. @@ -24,4 +220,200 @@ export class VertexHandler extends GeminiHandler implements SingleCompletionHand // suffix. return { id: id.endsWith(":thinking") ? id.replace(":thinking", "") : id, info, ...params } } + + /** + * Process usage metrics from the AI SDK response. + */ + protected processUsageMetrics( + usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + }, + info: ModelInfo, + providerMetadata?: Record, + ): ApiStreamUsageChunk { + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 + const cacheReadTokens = usage.details?.cachedInputTokens + const reasoningTokens = usage.details?.reasoningTokens + + return { + type: "usage", + inputTokens, + outputTokens, + cacheReadTokens, + reasoningTokens, + totalCost: this.calculateCost({ + info, + inputTokens, + outputTokens, + cacheReadTokens, + reasoningTokens, + }), + } + } + + private extractGroundingSources(groundingMetadata?: { + groundingChunks?: Array<{ + web?: { uri?: string; title?: string } + }> + }): GroundingSource[] { + const chunks = groundingMetadata?.groundingChunks + + if (!chunks) { + return [] + } + + return chunks + .map((chunk): GroundingSource | null => { + const uri = chunk.web?.uri + const title = chunk.web?.title || uri || "Unknown Source" + + if (uri) { + return { + title, + url: uri, + } + } + return null + }) + .filter((source): source is GroundingSource => source !== null) + } + + private extractCitationsOnly(groundingMetadata?: { + groundingChunks?: Array<{ + web?: { uri?: string; title?: string } + }> + }): string | null { + const sources = this.extractGroundingSources(groundingMetadata) + + if (sources.length === 0) { + return null + } + + const citationLinks = sources.map((source, i) => `[${i + 1}](${source.url})`) + return citationLinks.join(", ") + } + + async completePrompt(prompt: string): Promise { + const { id: modelId, info } = this.getModel() + + try { + const supportsTemperature = info.supportsTemperature !== false + const temperatureConfig: number | undefined = supportsTemperature + ? (this.options.modelTemperature ?? info.defaultTemperature ?? 1) + : info.defaultTemperature + + const result = await generateText({ + model: this.provider(modelId), + prompt, + temperature: temperatureConfig, + }) + + let text = result.text ?? "" + + // Extract grounding citations from providerMetadata if available + const providerMetadata = result.providerMetadata + const groundingMetadata = (providerMetadata?.vertex ?? providerMetadata?.google) as + | { + groundingMetadata?: { + groundingChunks?: Array<{ + web?: { uri?: string; title?: string } + }> + } + } + | undefined + + if (groundingMetadata?.groundingMetadata) { + const citations = this.extractCitationsOnly(groundingMetadata.groundingMetadata) + if (citations) { + text += `\n\n${t("common:errors.gemini.sources")} ${citations}` + } + } + + return text + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt") + TelemetryService.instance.captureException(apiError) + + if (error instanceof Error) { + throw new Error(t("common:errors.gemini.generate_complete_prompt", { error: error.message })) + } + + throw error + } + } + + public calculateCost({ + info, + inputTokens, + outputTokens, + cacheReadTokens = 0, + reasoningTokens = 0, + }: { + info: ModelInfo + inputTokens: number + outputTokens: number + cacheReadTokens?: number + reasoningTokens?: number + }) { + // For models with tiered pricing, prices might only be defined in tiers + let inputPrice = info.inputPrice + let outputPrice = info.outputPrice + let cacheReadsPrice = info.cacheReadsPrice + + // If there's tiered pricing then adjust the input and output token prices + // based on the input tokens used. + if (info.tiers) { + const tier = info.tiers.find((tier) => inputTokens <= tier.contextWindow) + + if (tier) { + inputPrice = tier.inputPrice ?? inputPrice + outputPrice = tier.outputPrice ?? outputPrice + cacheReadsPrice = tier.cacheReadsPrice ?? cacheReadsPrice + } + } + + // Check if we have the required prices after considering tiers + if (!inputPrice || !outputPrice) { + return undefined + } + + // cacheReadsPrice is optional - if not defined, treat as 0 + if (!cacheReadsPrice) { + cacheReadsPrice = 0 + } + + // Subtract the cached input tokens from the total input tokens. + const uncachedInputTokens = inputTokens - cacheReadTokens + + // Bill both completion and reasoning ("thoughts") tokens as output. + const billedOutputTokens = outputTokens + reasoningTokens + + let cacheReadCost = cacheReadTokens > 0 ? cacheReadsPrice * (cacheReadTokens / 1_000_000) : 0 + + const inputTokensCost = inputPrice * (uncachedInputTokens / 1_000_000) + const outputTokensCost = outputPrice * (billedOutputTokens / 1_000_000) + const totalCost = inputTokensCost + outputTokensCost + cacheReadCost + + return totalCost + } + + override isAiSdkProvider(): boolean { + return true + } + + /** + * Returns the thought signature captured from the last Vertex AI response. + * Gemini 3 models return thoughtSignature on function call parts, + * which must be round-tripped back for tool use continuations. + */ + getThoughtSignature(): string | undefined { + return this.lastThoughtSignature + } } diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 8df9cc66ec..88a7aceb46 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -1,166 +1,194 @@ import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import { createXai } from "@ai-sdk/xai" +import { streamText, generateText, ToolSet } from "ai" -import { type XAIModelId, xaiDefaultModelId, xaiModels, ApiProviderError } from "@roo-code/types" -import { TelemetryService } from "@roo-code/telemetry" +import { type XAIModelId, xaiDefaultModelId, xaiModels, type ModelInfo } from "@roo-code/types" -import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser" import type { ApiHandlerOptions } from "../../shared/api" -import { ApiStream } from "../transform/stream" -import { convertToOpenAiMessages } from "../transform/openai-format" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { handleOpenAIError } from "./utils/openai-error-handler" const XAI_DEFAULT_TEMPERATURE = 0 +/** + * xAI provider using the dedicated @ai-sdk/xai package. + * Provides native support for Grok models including reasoning models. + */ export class XAIHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - private client: OpenAI - private readonly providerName = "xAI" + protected provider: ReturnType constructor(options: ApiHandlerOptions) { super() this.options = options - const apiKey = this.options.xaiApiKey ?? "not-provided" - - this.client = new OpenAI({ + // Create the xAI provider using AI SDK + this.provider = createXai({ baseURL: "https://api.x.ai/v1", - apiKey: apiKey, - defaultHeaders: DEFAULT_HEADERS, + apiKey: options.xaiApiKey ?? "not-provided", + headers: DEFAULT_HEADERS, }) } - override getModel() { + override getModel(): { + id: XAIModelId + info: ModelInfo + maxTokens?: number + temperature?: number + reasoning?: any + } { const id = this.options.apiModelId && this.options.apiModelId in xaiModels ? (this.options.apiModelId as XAIModelId) : xaiDefaultModelId const info = xaiModels[id] - const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options }) + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: XAI_DEFAULT_TEMPERATURE, + }) return { id, info, ...params } } + /** + * Get the language model for the configured model ID. + */ + protected getLanguageModel() { + const { id } = this.getModel() + return this.provider(id) + } + + /** + * Process usage metrics from the AI SDK response. + */ + protected processUsageMetrics( + usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + }, + providerMetadata?: { + xai?: { + cachedPromptTokens?: number + } + }, + ): ApiStreamUsageChunk { + // Extract cache metrics from xAI's providerMetadata if available + // xAI supports prompt caching through prompt_tokens_details.cached_tokens + const cacheReadTokens = providerMetadata?.xai?.cachedPromptTokens ?? usage.details?.cachedInputTokens + + return { + type: "usage", + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheReadTokens, + cacheWriteTokens: undefined, // xAI doesn't report cache write tokens separately + reasoningTokens: usage.details?.reasoningTokens, + } + } + + /** + * Get the max tokens parameter to include in the request. + */ + protected getMaxOutputTokens(): number | undefined { + const { info } = this.getModel() + return this.options.modelMaxTokens || info.maxTokens || undefined + } + + /** + * Create a message stream using the AI SDK. + */ override async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const { id: modelId, info: modelInfo, reasoning } = this.getModel() + const { temperature, reasoning } = this.getModel() + const languageModel = this.getLanguageModel() - // Use the OpenAI-compatible API. - const requestOptions = { - model: modelId, - max_tokens: modelInfo.maxTokens, - temperature: this.options.modelTemperature ?? XAI_DEFAULT_TEMPERATURE, - messages: [ - { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages(messages), - ] as OpenAI.Chat.ChatCompletionMessageParam[], - stream: true as const, - stream_options: { include_usage: true }, - ...(reasoning && reasoning), - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, + // Convert messages to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(messages) + + // Convert tools to OpenAI format first, then to AI SDK format + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + // Build the request options + const requestOptions: Parameters[0] = { + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature: this.options.modelTemperature ?? temperature ?? XAI_DEFAULT_TEMPERATURE, + maxOutputTokens: this.getMaxOutputTokens(), + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + ...(reasoning && { providerOptions: { xai: reasoning } }), } - let stream + // Use streamText for streaming responses + const result = streamText(requestOptions) + try { - stream = await this.client.chat.completions.create(requestOptions) + // Process the full stream to get all events including reasoning + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } + + // Yield usage metrics at the end, including cache metrics from providerMetadata + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, providerMetadata as any) + } } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage") - TelemetryService.instance.captureException(apiError) - throw handleOpenAIError(error, this.providerName) - } - - for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta - const finishReason = chunk.choices[0]?.finish_reason - - if (delta?.content) { - yield { - type: "text", - text: delta.content, - } - } - - if (delta && "reasoning_content" in delta && delta.reasoning_content) { - yield { - type: "reasoning", - text: delta.reasoning_content as string, - } - } - - // Handle tool calls in stream - emit partial chunks for NativeToolCallParser - if (delta?.tool_calls) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - - // Process finish_reason to emit tool_call_end events - // This ensures tool calls are finalized even if the stream doesn't properly close - if (finishReason) { - const endEvents = NativeToolCallParser.processFinishReason(finishReason) - for (const event of endEvents) { - yield event - } - } - - if (chunk.usage) { - // Extract detailed token information if available - // First check for prompt_tokens_details structure (real API response) - const promptDetails = "prompt_tokens_details" in chunk.usage ? chunk.usage.prompt_tokens_details : null - const cachedTokens = promptDetails && "cached_tokens" in promptDetails ? promptDetails.cached_tokens : 0 - - // Fall back to direct fields in usage (used in test mocks) - const readTokens = - cachedTokens || - ("cache_read_input_tokens" in chunk.usage ? (chunk.usage as any).cache_read_input_tokens : 0) - const writeTokens = - "cache_creation_input_tokens" in chunk.usage ? (chunk.usage as any).cache_creation_input_tokens : 0 - - yield { - type: "usage", - inputTokens: chunk.usage.prompt_tokens || 0, - outputTokens: chunk.usage.completion_tokens || 0, - cacheReadTokens: readTokens, - cacheWriteTokens: writeTokens, - } - } + // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) + throw handleAiSdkError(error, "xAI") } } + /** + * Complete a prompt using the AI SDK generateText. + */ async completePrompt(prompt: string): Promise { - const { id: modelId, reasoning } = this.getModel() + const { temperature, reasoning } = this.getModel() + const languageModel = this.getLanguageModel() try { - const response = await this.client.chat.completions.create({ - model: modelId, - messages: [{ role: "user", content: prompt }], - ...(reasoning && reasoning), + const { text } = await generateText({ + model: languageModel, + prompt, + maxOutputTokens: this.getMaxOutputTokens(), + temperature: this.options.modelTemperature ?? temperature ?? XAI_DEFAULT_TEMPERATURE, + ...(reasoning && { providerOptions: { xai: reasoning } }), }) - return response.choices[0]?.message.content || "" + return text } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt") - TelemetryService.instance.captureException(apiError) - throw handleOpenAIError(error, this.providerName) + throw handleAiSdkError(error, "xAI") } } + + override isAiSdkProvider(): boolean { + return true + } } diff --git a/src/api/providers/zai.ts b/src/api/providers/zai.ts index a2e3740c56..acfdd81129 100644 --- a/src/api/providers/zai.ts +++ b/src/api/providers/zai.ts @@ -1,5 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import { createZhipu } from "zhipu-ai-provider" +import { streamText, generateText, ToolSet } from "ai" import { internationalZAiModels, @@ -11,101 +12,162 @@ import { zaiApiLineConfigs, } from "@roo-code/types" -import { type ApiHandlerOptions, getModelMaxOutputTokens, shouldUseReasoningEffort } from "../../shared/api" -import { convertToZAiFormat } from "../transform/zai-format" +import { type ApiHandlerOptions, shouldUseReasoningEffort } from "../../shared/api" -import type { ApiHandlerCreateMessageMetadata } from "../index" -import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" +import { ApiStream } from "../transform/stream" +import { getModelParams } from "../transform/model-params" -// Custom interface for Z.ai params to support thinking mode -type ZAiChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParamsStreaming & { - thinking?: { type: "enabled" | "disabled" } -} +import { DEFAULT_HEADERS } from "./constants" +import { BaseProvider } from "./base-provider" +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" + +/** + * Z.ai provider using the dedicated zhipu-ai-provider package. + * Provides native support for GLM-4.7 thinking mode and region-based model selection. + */ +export class ZAiHandler extends BaseProvider implements SingleCompletionHandler { + protected options: ApiHandlerOptions + protected provider: ReturnType + private isChina: boolean -export class ZAiHandler extends BaseOpenAiCompatibleProvider { constructor(options: ApiHandlerOptions) { - const isChina = zaiApiLineConfigs[options.zaiApiLine ?? "international_coding"].isChina - const models = (isChina ? mainlandZAiModels : internationalZAiModels) as unknown as Record - const defaultModelId = (isChina ? mainlandZAiDefaultModelId : internationalZAiDefaultModelId) as string + super() + this.options = options + this.isChina = zaiApiLineConfigs[options.zaiApiLine ?? "international_coding"].isChina - super({ - ...options, - providerName: "Z.ai", + this.provider = createZhipu({ baseURL: zaiApiLineConfigs[options.zaiApiLine ?? "international_coding"].baseUrl, apiKey: options.zaiApiKey ?? "not-provided", - defaultProviderModelId: defaultModelId, - providerModels: models, - defaultTemperature: ZAI_DEFAULT_TEMPERATURE, + headers: DEFAULT_HEADERS, }) } + override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { + const models = (this.isChina ? mainlandZAiModels : internationalZAiModels) as unknown as Record< + string, + ModelInfo + > + const defaultModelId = (this.isChina ? mainlandZAiDefaultModelId : internationalZAiDefaultModelId) as string + + const id = this.options.apiModelId ?? defaultModelId + const info = models[id] || models[defaultModelId] + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: ZAI_DEFAULT_TEMPERATURE, + }) + + return { id, info, ...params } + } + /** - * Override createStream to handle GLM-4.7's thinking mode. - * GLM-4.7 has thinking enabled by default in the API, so we need to - * explicitly send { type: "disabled" } when the user turns off reasoning. + * Get the language model for the configured model ID. */ - protected override createStream( + protected getLanguageModel() { + const { id } = this.getModel() + return this.provider(id) + } + + /** + * Get the max tokens parameter to include in the request. + */ + protected getMaxOutputTokens(): number | undefined { + const { info } = this.getModel() + return this.options.modelMaxTokens || info.maxTokens || undefined + } + + /** + * Create a message stream using the AI SDK. + * For GLM-4.7, passes the thinking parameter via providerOptions. + */ + override async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, - requestOptions?: OpenAI.RequestOptions, - ) { - const { id: modelId, info } = this.getModel() + ): ApiStream { + const { id: modelId, info, temperature } = this.getModel() + const languageModel = this.getLanguageModel() - // Check if this is a GLM-4.7 model with thinking support + const aiSdkMessages = convertToAiSdkMessages(messages) + + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + const requestOptions: Parameters[0] = { + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature: this.options.modelTemperature ?? temperature ?? ZAI_DEFAULT_TEMPERATURE, + maxOutputTokens: this.getMaxOutputTokens(), + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + } + + // GLM-4.7 thinking mode: pass thinking parameter via providerOptions const isThinkingModel = modelId === "glm-4.7" && Array.isArray(info.supportsReasoningEffort) if (isThinkingModel) { - // For GLM-4.7, thinking is ON by default in the API. - // We need to explicitly disable it when reasoning is off. const useReasoning = shouldUseReasoningEffort({ model: info, settings: this.options }) - - // Create the stream with our custom thinking parameter - return this.createStreamWithThinking(systemPrompt, messages, metadata, useReasoning) + requestOptions.providerOptions = { + zhipu: { + thinking: useReasoning ? { type: "enabled" } : { type: "disabled" }, + }, + } } - // For non-thinking models, use the default behavior - return super.createStream(systemPrompt, messages, metadata, requestOptions) + const result = streamText(requestOptions) + + try { + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } + + const usage = await result.usage + if (usage) { + yield { + type: "usage" as const, + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + } + } + } catch (error) { + throw handleAiSdkError(error, "Z.ai") + } } /** - * Creates a stream with explicit thinking control for GLM-4.7 + * Complete a prompt using the AI SDK generateText. */ - private createStreamWithThinking( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - useReasoning?: boolean, - ) { - const { id: model, info } = this.getModel() + async completePrompt(prompt: string): Promise { + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() - const max_tokens = - getModelMaxOutputTokens({ - modelId: model, - model: info, - settings: this.options, - format: "openai", - }) ?? undefined + try { + const { text } = await generateText({ + model: languageModel, + prompt, + maxOutputTokens: this.getMaxOutputTokens(), + temperature: this.options.modelTemperature ?? temperature ?? ZAI_DEFAULT_TEMPERATURE, + }) - const temperature = this.options.modelTemperature ?? this.defaultTemperature - - // Use Z.ai format to preserve reasoning_content and merge post-tool text into tool messages - const convertedMessages = convertToZAiFormat(messages, { mergeToolResultText: true }) - - const params: ZAiChatCompletionParams = { - model, - max_tokens, - temperature, - messages: [{ role: "system", content: systemPrompt }, ...convertedMessages], - stream: true, - stream_options: { include_usage: true }, - // For GLM-4.7: thinking is ON by default, so we explicitly disable when needed - thinking: useReasoning ? { type: "enabled" } : { type: "disabled" }, - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, + return text + } catch (error) { + throw handleAiSdkError(error, "Z.ai") } + } - return this.client.chat.completions.create(params) + override isAiSdkProvider(): boolean { + return true } } diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts index bd87fd8eeb..f973fc85a6 100644 --- a/src/api/transform/__tests__/ai-sdk.spec.ts +++ b/src/api/transform/__tests__/ai-sdk.spec.ts @@ -7,6 +7,7 @@ import { mapToolChoice, extractAiSdkErrorMessage, handleAiSdkError, + flattenAiSdkMessagesToStringContent, } from "../ai-sdk" vitest.mock("ai", () => ({ @@ -307,6 +308,199 @@ describe("AI SDK conversion utilities", () => { content: [{ type: "text", text: "" }], }) }) + + it("converts assistant reasoning blocks", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "reasoning" as any, text: "Thinking..." }, + { type: "text", text: "Answer" }, + ], + }, + ] + + const result = convertToAiSdkMessages(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "assistant", + content: [ + { type: "reasoning", text: "Thinking..." }, + { type: "text", text: "Answer" }, + ], + }) + }) + + it("converts assistant thinking blocks to reasoning", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "thinking" as any, thinking: "Deep thought", signature: "sig" }, + { type: "text", text: "OK" }, + ], + }, + ] + + const result = convertToAiSdkMessages(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "assistant", + content: [ + { + type: "reasoning", + text: "Deep thought", + providerOptions: { + bedrock: { signature: "sig" }, + anthropic: { signature: "sig" }, + }, + }, + { type: "text", text: "OK" }, + ], + }) + }) + + it("converts assistant message-level reasoning_content to reasoning part", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [{ type: "text", text: "Answer" }], + reasoning_content: "Thinking...", + } as any, + ] + + const result = convertToAiSdkMessages(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "assistant", + content: [ + { type: "reasoning", text: "Thinking..." }, + { type: "text", text: "Answer" }, + ], + }) + }) + + it("prefers message-level reasoning_content over reasoning blocks", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "reasoning" as any, text: "BLOCK" }, + { type: "text", text: "Answer" }, + ], + reasoning_content: "MSG", + } as any, + ] + + const result = convertToAiSdkMessages(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "assistant", + content: [ + { type: "reasoning", text: "MSG" }, + { type: "text", text: "Answer" }, + ], + }) + }) + + it("attaches thoughtSignature to first tool-call part for Gemini 3 round-tripping", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "text", text: "Let me check that." }, + { + type: "tool_use", + id: "tool-1", + name: "read_file", + input: { path: "test.txt" }, + }, + { type: "thoughtSignature", thoughtSignature: "encrypted-sig-abc" } as any, + ], + }, + ] + + const result = convertToAiSdkMessages(messages) + + expect(result).toHaveLength(1) + const assistantMsg = result[0] + expect(assistantMsg.role).toBe("assistant") + + const content = assistantMsg.content as any[] + expect(content).toHaveLength(2) // text + tool-call (thoughtSignature block is consumed, not passed through) + + const toolCallPart = content.find((p: any) => p.type === "tool-call") + expect(toolCallPart).toBeDefined() + expect(toolCallPart.providerOptions).toEqual({ + google: { thoughtSignature: "encrypted-sig-abc" }, + vertex: { thoughtSignature: "encrypted-sig-abc" }, + }) + }) + + it("attaches thoughtSignature only to the first tool-call in parallel calls", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-1", + name: "get_weather", + input: { city: "Paris" }, + }, + { + type: "tool_use", + id: "tool-2", + name: "get_weather", + input: { city: "London" }, + }, + { type: "thoughtSignature", thoughtSignature: "sig-parallel" } as any, + ], + }, + ] + + const result = convertToAiSdkMessages(messages) + const content = (result[0] as any).content as any[] + + const toolCalls = content.filter((p: any) => p.type === "tool-call") + expect(toolCalls).toHaveLength(2) + + // Only the first tool call should have the signature + expect(toolCalls[0].providerOptions).toEqual({ + google: { thoughtSignature: "sig-parallel" }, + vertex: { thoughtSignature: "sig-parallel" }, + }) + // Second tool call should NOT have the signature + expect(toolCalls[1].providerOptions).toBeUndefined() + }) + + it("does not attach providerOptions when no thoughtSignature block is present", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "text", text: "Using tool" }, + { + type: "tool_use", + id: "tool-1", + name: "read_file", + input: { path: "test.txt" }, + }, + ], + }, + ] + + const result = convertToAiSdkMessages(messages) + const content = (result[0] as any).content as any[] + const toolCallPart = content.find((p: any) => p.type === "tool-call") + + expect(toolCallPart).toBeDefined() + expect(toolCallPart.providerOptions).toBeUndefined() + }) }) describe("convertToolsForAiSdk", () => { @@ -616,9 +810,9 @@ describe("AI SDK conversion utilities", () => { lastError: { message: "Too Many Requests", status: 429 }, } - const result = handleAiSdkError(retryError, "Groq") + const result = handleAiSdkError(retryError, "SambaNova") - expect(result.message).toContain("Groq:") + expect(result.message).toContain("SambaNova:") expect(result.message).toContain("429") expect((result as any).status).toBe(429) }) @@ -639,9 +833,231 @@ describe("AI SDK conversion utilities", () => { it("should preserve original error as cause", () => { const originalError = new Error("Original error") - const result = handleAiSdkError(originalError, "Cerebras") + const result = handleAiSdkError(originalError, "Mistral") expect((result as any).cause).toBe(originalError) }) }) + + describe("flattenAiSdkMessagesToStringContent", () => { + it("should return messages unchanged if content is already a string", () => { + const messages = [ + { role: "user" as const, content: "Hello" }, + { role: "assistant" as const, content: "Hi there" }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages) + + expect(result).toEqual(messages) + }) + + it("should flatten user messages with only text parts to string", () => { + const messages = [ + { + role: "user" as const, + content: [ + { type: "text" as const, text: "Hello" }, + { type: "text" as const, text: "World" }, + ], + }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages) + + expect(result).toHaveLength(1) + expect(result[0].role).toBe("user") + expect(result[0].content).toBe("Hello\nWorld") + }) + + it("should flatten assistant messages with only text parts to string", () => { + const messages = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "I am an assistant" }], + }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages) + + expect(result).toHaveLength(1) + expect(result[0].role).toBe("assistant") + expect(result[0].content).toBe("I am an assistant") + }) + + it("should not flatten user messages with image parts", () => { + const messages = [ + { + role: "user" as const, + content: [ + { type: "text" as const, text: "Look at this" }, + { type: "image" as const, image: "data:image/png;base64,abc123" }, + ], + }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages) + + expect(result).toEqual(messages) + }) + + it("should not flatten assistant messages with tool calls", () => { + const messages = [ + { + role: "assistant" as const, + content: [ + { type: "text" as const, text: "Let me use a tool" }, + { + type: "tool-call" as const, + toolCallId: "123", + toolName: "read_file", + input: { path: "test.txt" }, + }, + ], + }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages) + + expect(result).toEqual(messages) + }) + + it("should not flatten tool role messages", () => { + const messages = [ + { + role: "tool" as const, + content: [ + { + type: "tool-result" as const, + toolCallId: "123", + toolName: "test", + output: { type: "text" as const, value: "result" }, + }, + ], + }, + ] as any + + const result = flattenAiSdkMessagesToStringContent(messages) + + expect(result).toEqual(messages) + }) + + it("should respect flattenUserMessages option", () => { + const messages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: "Hello" }], + }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages, { flattenUserMessages: false }) + + expect(result).toEqual(messages) + }) + + it("should respect flattenAssistantMessages option", () => { + const messages = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Hi" }], + }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages, { flattenAssistantMessages: false }) + + expect(result).toEqual(messages) + }) + + it("should handle mixed message types correctly", () => { + const messages = [ + { role: "user" as const, content: "Simple string" }, + { + role: "user" as const, + content: [{ type: "text" as const, text: "Text parts" }], + }, + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Assistant text" }], + }, + { + role: "assistant" as const, + content: [ + { type: "text" as const, text: "With tool" }, + { type: "tool-call" as const, toolCallId: "456", toolName: "test", input: {} }, + ], + }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages) + + expect(result[0].content).toBe("Simple string") // unchanged + expect(result[1].content).toBe("Text parts") // flattened + expect(result[2].content).toBe("Assistant text") // flattened + expect(result[3]).toEqual(messages[3]) // unchanged (has tool call) + }) + + it("should handle empty text parts", () => { + const messages = [ + { + role: "user" as const, + content: [ + { type: "text" as const, text: "" }, + { type: "text" as const, text: "Hello" }, + ], + }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages) + + expect(result[0].content).toBe("\nHello") + }) + + it("should strip reasoning parts and flatten text for string-only models", () => { + const messages = [ + { + role: "assistant" as const, + content: [ + { type: "reasoning" as const, text: "I am thinking about this..." }, + { type: "text" as const, text: "Here is my answer" }, + ], + }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages) + + // Reasoning should be stripped, only text should remain + expect(result[0].content).toBe("Here is my answer") + }) + + it("should handle messages with only reasoning parts", () => { + const messages = [ + { + role: "assistant" as const, + content: [{ type: "reasoning" as const, text: "Only reasoning, no text" }], + }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages) + + // Should flatten to empty string when only reasoning is present + expect(result[0].content).toBe("") + }) + + it("should not flatten if tool calls are present with reasoning", () => { + const messages = [ + { + role: "assistant" as const, + content: [ + { type: "reasoning" as const, text: "Thinking..." }, + { type: "text" as const, text: "Using tool" }, + { type: "tool-call" as const, toolCallId: "abc", toolName: "test", input: {} }, + ], + }, + ] + + const result = flattenAiSdkMessagesToStringContent(messages) + + // Should not flatten because there's a tool call + expect(result[0]).toEqual(messages[0]) + }) + }) }) diff --git a/src/api/transform/__tests__/bedrock-converse-format.spec.ts b/src/api/transform/__tests__/bedrock-converse-format.spec.ts deleted file mode 100644 index 27319c6562..0000000000 --- a/src/api/transform/__tests__/bedrock-converse-format.spec.ts +++ /dev/null @@ -1,559 +0,0 @@ -// npx vitest run src/api/transform/__tests__/bedrock-converse-format.spec.ts - -import { convertToBedrockConverseMessages } from "../bedrock-converse-format" -import { Anthropic } from "@anthropic-ai/sdk" -import { ContentBlock, ToolResultContentBlock } from "@aws-sdk/client-bedrock-runtime" -import { OPENAI_CALL_ID_MAX_LENGTH } from "../../../utils/tool-id" - -describe("convertToBedrockConverseMessages", () => { - it("converts simple text messages correctly", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there" }, - ] - - const result = convertToBedrockConverseMessages(messages) - - expect(result).toEqual([ - { - role: "user", - content: [{ text: "Hello" }], - }, - { - role: "assistant", - content: [{ text: "Hi there" }], - }, - ]) - }) - - it("converts messages with images correctly", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "Look at this image:", - }, - { - type: "image", - source: { - type: "base64", - data: "SGVsbG8=", // "Hello" in base64 - media_type: "image/jpeg" as const, - }, - }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - if (!result[0] || !result[0].content) { - expect.fail("Expected result to have content") - return - } - - expect(result[0].role).toBe("user") - expect(result[0].content).toHaveLength(2) - expect(result[0].content[0]).toEqual({ text: "Look at this image:" }) - - const imageBlock = result[0].content[1] as ContentBlock - if ("image" in imageBlock && imageBlock.image && imageBlock.image.source) { - expect(imageBlock.image.format).toBe("jpeg") - expect(imageBlock.image.source).toBeDefined() - expect(imageBlock.image.source.bytes).toBeDefined() - } else { - expect.fail("Expected image block not found") - } - }) - - it("converts tool use messages correctly (native tools format; default)", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "test-id", - name: "read_file", - input: { - path: "test.txt", - }, - }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - if (!result[0] || !result[0].content) { - expect.fail("Expected result to have content") - return - } - - expect(result[0].role).toBe("assistant") - const toolBlock = result[0].content[0] as ContentBlock - if ("toolUse" in toolBlock && toolBlock.toolUse) { - expect(toolBlock.toolUse).toEqual({ - toolUseId: "test-id", - name: "read_file", - input: { path: "test.txt" }, - }) - } else { - expect.fail("Expected tool use block not found") - } - }) - - it("converts tool use messages correctly (native tools format)", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "test-id", - name: "read_file", - input: { - path: "test.txt", - }, - }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - if (!result[0] || !result[0].content) { - expect.fail("Expected result to have content") - return - } - - expect(result[0].role).toBe("assistant") - const toolBlock = result[0].content[0] as ContentBlock - if ("toolUse" in toolBlock && toolBlock.toolUse) { - expect(toolBlock.toolUse).toEqual({ - toolUseId: "test-id", - name: "read_file", - input: { path: "test.txt" }, - }) - } else { - expect.fail("Expected tool use block not found") - } - }) - - it("converts tool result messages to native format (default)", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "test-id", - content: [{ type: "text", text: "File contents here" }], - }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - if (!result[0] || !result[0].content) { - expect.fail("Expected result to have content") - return - } - - expect(result[0].role).toBe("user") - const resultBlock = result[0].content[0] as ContentBlock - if ("toolResult" in resultBlock && resultBlock.toolResult) { - const expectedContent: ToolResultContentBlock[] = [{ text: "File contents here" }] - expect(resultBlock.toolResult).toEqual({ - toolUseId: "test-id", - content: expectedContent, - status: "success", - }) - } else { - expect.fail("Expected tool result block not found") - } - }) - - it("converts tool result messages to native format", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "test-id", - content: [{ type: "text", text: "File contents here" }], - }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - if (!result[0] || !result[0].content) { - expect.fail("Expected result to have content") - return - } - - expect(result[0].role).toBe("user") - const resultBlock = result[0].content[0] as ContentBlock - if ("toolResult" in resultBlock && resultBlock.toolResult) { - const expectedContent: ToolResultContentBlock[] = [{ text: "File contents here" }] - expect(resultBlock.toolResult).toEqual({ - toolUseId: "test-id", - content: expectedContent, - status: "success", - }) - } else { - expect.fail("Expected tool result block not found") - } - }) - - it("converts tool result messages with string content to native format (default)", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "test-id", - content: "File: test.txt\nLines 1-5:\nHello World", - } as any, // Anthropic types don't allow string content but runtime can have it - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - if (!result[0] || !result[0].content) { - expect.fail("Expected result to have content") - return - } - - expect(result[0].role).toBe("user") - const resultBlock = result[0].content[0] as ContentBlock - if ("toolResult" in resultBlock && resultBlock.toolResult) { - expect(resultBlock.toolResult).toEqual({ - toolUseId: "test-id", - content: [{ text: "File: test.txt\nLines 1-5:\nHello World" }], - status: "success", - }) - } else { - expect.fail("Expected tool result block not found") - } - }) - - it("converts tool result messages with string content to native format", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "test-id", - content: "File: test.txt\nLines 1-5:\nHello World", - } as any, // Anthropic types don't allow string content but runtime can have it - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - if (!result[0] || !result[0].content) { - expect.fail("Expected result to have content") - return - } - - expect(result[0].role).toBe("user") - const resultBlock = result[0].content[0] as ContentBlock - if ("toolResult" in resultBlock && resultBlock.toolResult) { - expect(resultBlock.toolResult).toEqual({ - toolUseId: "test-id", - content: [{ text: "File: test.txt\nLines 1-5:\nHello World" }], - status: "success", - }) - } else { - expect.fail("Expected tool result block not found") - } - }) - - it("keeps both tool_use and tool_result in native format by default", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "call-123", - name: "read_file", - input: { path: "test.txt" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call-123", - content: "File contents here", - } as any, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - // Both should be native toolUse/toolResult blocks - const assistantContent = result[0]?.content?.[0] as ContentBlock - const userContent = result[1]?.content?.[0] as ContentBlock - - expect("toolUse" in assistantContent).toBe(true) - expect("toolResult" in userContent).toBe(true) - expect("text" in assistantContent).toBe(false) - expect("text" in userContent).toBe(false) - }) - - it("handles text content correctly", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "Hello world", - }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - if (!result[0] || !result[0].content) { - expect.fail("Expected result to have content") - return - } - - expect(result[0].role).toBe("user") - expect(result[0].content).toHaveLength(1) - const textBlock = result[0].content[0] as ContentBlock - expect(textBlock).toEqual({ text: "Hello world" }) - }) - - describe("toolUseId sanitization for Bedrock 64-char limit", () => { - it("truncates toolUseId longer than 64 characters in tool_use blocks", () => { - const longId = "a".repeat(100) - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: longId, - name: "read_file", - input: { path: "test.txt" }, - }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - const toolBlock = result[0]?.content?.[0] as ContentBlock - - if ("toolUse" in toolBlock && toolBlock.toolUse && toolBlock.toolUse.toolUseId) { - expect(toolBlock.toolUse.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH) - expect(toolBlock.toolUse.toolUseId.length).toBe(OPENAI_CALL_ID_MAX_LENGTH) - expect(toolBlock.toolUse.toolUseId).toContain("_") - } else { - expect.fail("Expected tool use block not found") - } - }) - - it("truncates toolUseId longer than 64 characters in tool_result blocks with string content", () => { - const longId = "b".repeat(100) - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: longId, - content: "Result content", - } as any, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - const resultBlock = result[0]?.content?.[0] as ContentBlock - - if ("toolResult" in resultBlock && resultBlock.toolResult && resultBlock.toolResult.toolUseId) { - expect(resultBlock.toolResult.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH) - expect(resultBlock.toolResult.toolUseId.length).toBe(OPENAI_CALL_ID_MAX_LENGTH) - expect(resultBlock.toolResult.toolUseId).toContain("_") - } else { - expect.fail("Expected tool result block not found") - } - }) - - it("truncates toolUseId longer than 64 characters in tool_result blocks with array content", () => { - const longId = "c".repeat(100) - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: longId, - content: [{ type: "text", text: "Result content" }], - }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - const resultBlock = result[0]?.content?.[0] as ContentBlock - - if ("toolResult" in resultBlock && resultBlock.toolResult && resultBlock.toolResult.toolUseId) { - expect(resultBlock.toolResult.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH) - expect(resultBlock.toolResult.toolUseId.length).toBe(OPENAI_CALL_ID_MAX_LENGTH) - } else { - expect.fail("Expected tool result block not found") - } - }) - - it("keeps toolUseId unchanged when under 64 characters", () => { - const shortId = "short-id-123" - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: shortId, - name: "read_file", - input: { path: "test.txt" }, - }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - const toolBlock = result[0]?.content?.[0] as ContentBlock - - if ("toolUse" in toolBlock && toolBlock.toolUse) { - expect(toolBlock.toolUse.toolUseId).toBe(shortId) - } else { - expect.fail("Expected tool use block not found") - } - }) - - it("produces consistent truncated IDs for the same input", () => { - const longId = "d".repeat(100) - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: longId, - name: "read_file", - input: { path: "test.txt" }, - }, - ], - }, - ] - - const result1 = convertToBedrockConverseMessages(messages) - const result2 = convertToBedrockConverseMessages(messages) - - const toolBlock1 = result1[0]?.content?.[0] as ContentBlock - const toolBlock2 = result2[0]?.content?.[0] as ContentBlock - - if ("toolUse" in toolBlock1 && toolBlock1.toolUse && "toolUse" in toolBlock2 && toolBlock2.toolUse) { - expect(toolBlock1.toolUse.toolUseId).toBe(toolBlock2.toolUse.toolUseId) - } else { - expect.fail("Expected tool use blocks not found") - } - }) - - it("produces different truncated IDs for different long inputs", () => { - const longId1 = "e".repeat(100) - const longId2 = "f".repeat(100) - - const messages1: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [{ type: "tool_use", id: longId1, name: "read_file", input: {} }], - }, - ] - const messages2: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [{ type: "tool_use", id: longId2, name: "read_file", input: {} }], - }, - ] - - const result1 = convertToBedrockConverseMessages(messages1) - const result2 = convertToBedrockConverseMessages(messages2) - - const toolBlock1 = result1[0]?.content?.[0] as ContentBlock - const toolBlock2 = result2[0]?.content?.[0] as ContentBlock - - if ("toolUse" in toolBlock1 && toolBlock1.toolUse && "toolUse" in toolBlock2 && toolBlock2.toolUse) { - expect(toolBlock1.toolUse.toolUseId).not.toBe(toolBlock2.toolUse.toolUseId) - } else { - expect.fail("Expected tool use blocks not found") - } - }) - - it("matching tool_use and tool_result IDs are both truncated consistently", () => { - const longId = "g".repeat(100) - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: longId, - name: "read_file", - input: { path: "test.txt" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: longId, - content: "File contents", - } as any, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - const toolUseBlock = result[0]?.content?.[0] as ContentBlock - const toolResultBlock = result[1]?.content?.[0] as ContentBlock - - if ( - "toolUse" in toolUseBlock && - toolUseBlock.toolUse && - toolUseBlock.toolUse.toolUseId && - "toolResult" in toolResultBlock && - toolResultBlock.toolResult && - toolResultBlock.toolResult.toolUseId - ) { - expect(toolUseBlock.toolUse.toolUseId).toBe(toolResultBlock.toolResult.toolUseId) - expect(toolUseBlock.toolUse.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH) - } else { - expect.fail("Expected tool use and result blocks not found") - } - }) - }) -}) diff --git a/src/api/transform/__tests__/gemini-format.spec.ts b/src/api/transform/__tests__/gemini-format.spec.ts deleted file mode 100644 index 23f752e207..0000000000 --- a/src/api/transform/__tests__/gemini-format.spec.ts +++ /dev/null @@ -1,487 +0,0 @@ -// npx vitest run src/api/transform/__tests__/gemini-format.spec.ts - -import { Anthropic } from "@anthropic-ai/sdk" - -import { convertAnthropicMessageToGemini } from "../gemini-format" - -describe("convertAnthropicMessageToGemini", () => { - it("should convert a simple text message", () => { - const anthropicMessage: Anthropic.Messages.MessageParam = { - role: "user", - content: "Hello, world!", - } - - const result = convertAnthropicMessageToGemini(anthropicMessage) - - expect(result).toEqual([ - { - role: "user", - parts: [{ text: "Hello, world!" }], - }, - ]) - }) - - it("should convert assistant role to model role", () => { - const anthropicMessage: Anthropic.Messages.MessageParam = { - role: "assistant", - content: "I'm an assistant", - } - - const result = convertAnthropicMessageToGemini(anthropicMessage) - - expect(result).toEqual([ - { - role: "model", - parts: [{ text: "I'm an assistant" }], - }, - ]) - }) - - it("should convert a message with text blocks", () => { - const anthropicMessage: Anthropic.Messages.MessageParam = { - role: "user", - content: [ - { type: "text", text: "First paragraph" }, - { type: "text", text: "Second paragraph" }, - ], - } - - const result = convertAnthropicMessageToGemini(anthropicMessage) - - expect(result).toEqual([ - { - role: "user", - parts: [{ text: "First paragraph" }, { text: "Second paragraph" }], - }, - ]) - }) - - it("should convert a message with an image", () => { - const anthropicMessage: Anthropic.Messages.MessageParam = { - role: "user", - content: [ - { type: "text", text: "Check out this image:" }, - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "base64encodeddata", - }, - }, - ], - } - - const result = convertAnthropicMessageToGemini(anthropicMessage) - - expect(result).toEqual([ - { - role: "user", - parts: [ - { text: "Check out this image:" }, - { - inlineData: { - data: "base64encodeddata", - mimeType: "image/jpeg", - }, - }, - ], - }, - ]) - }) - - it("should throw an error for unsupported image source type", () => { - const anthropicMessage: Anthropic.Messages.MessageParam = { - role: "user", - content: [ - { - type: "image", - source: { - type: "url", // Not supported - url: "https://example.com/image.jpg", - } as any, - }, - ], - } - - expect(() => convertAnthropicMessageToGemini(anthropicMessage)).toThrow("Unsupported image source type") - }) - - it("should convert a message with tool use", () => { - const anthropicMessage: Anthropic.Messages.MessageParam = { - role: "assistant", - content: [ - { type: "text", text: "Let me calculate that for you." }, - { - type: "tool_use", - id: "calc-123", - name: "calculator", - input: { operation: "add", numbers: [2, 3] }, - }, - ], - } - - const result = convertAnthropicMessageToGemini(anthropicMessage) - - expect(result).toEqual([ - { - role: "model", - parts: [ - { text: "Let me calculate that for you." }, - { - functionCall: { - name: "calculator", - args: { operation: "add", numbers: [2, 3] }, - }, - thoughtSignature: "skip_thought_signature_validator", - }, - ], - }, - ]) - }) - - it("should only attach thoughtSignature to the first functionCall in the message", () => { - const anthropicMessage: Anthropic.Messages.MessageParam = { - role: "assistant", - content: [ - { type: "thoughtSignature", thoughtSignature: "sig-123" } as any, - { type: "tool_use", id: "call-1", name: "toolA", input: { a: 1 } }, - { type: "tool_use", id: "call-2", name: "toolB", input: { b: 2 } }, - ], - } - - const result = convertAnthropicMessageToGemini(anthropicMessage) - expect(result).toHaveLength(1) - - const parts = result[0]!.parts as any[] - const functionCallParts = parts.filter((p) => p.functionCall) - expect(functionCallParts).toHaveLength(2) - - expect(functionCallParts[0].thoughtSignature).toBe("sig-123") - expect(functionCallParts[1].thoughtSignature).toBeUndefined() - }) - - it("should convert a message with tool result as string", () => { - const toolIdToName = new Map() - toolIdToName.set("calculator-123", "calculator") - - const anthropicMessage: Anthropic.Messages.MessageParam = { - role: "user", - content: [ - { type: "text", text: "Here's the result:" }, - { - type: "tool_result", - tool_use_id: "calculator-123", - content: "The result is 5", - }, - ], - } - - const result = convertAnthropicMessageToGemini(anthropicMessage, { toolIdToName }) - - expect(result).toEqual([ - { - role: "user", - parts: [ - { text: "Here's the result:" }, - { - functionResponse: { - name: "calculator", - response: { - name: "calculator", - content: "The result is 5", - }, - }, - }, - ], - }, - ]) - }) - - it("should handle empty tool result content", () => { - const anthropicMessage: Anthropic.Messages.MessageParam = { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "calculator-123", - content: null as any, // Empty content - }, - ], - } - - const result = convertAnthropicMessageToGemini(anthropicMessage) - - // Should skip the empty tool result - expect(result).toEqual([]) - }) - - it("should convert a message with tool result as array with text only", () => { - const toolIdToName = new Map() - toolIdToName.set("search-123", "search") - - const anthropicMessage: Anthropic.Messages.MessageParam = { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "search-123", - content: [ - { type: "text", text: "First result" }, - { type: "text", text: "Second result" }, - ], - }, - ], - } - - const result = convertAnthropicMessageToGemini(anthropicMessage, { toolIdToName }) - - expect(result).toEqual([ - { - role: "user", - parts: [ - { - functionResponse: { - name: "search", - response: { - name: "search", - content: "First result\n\nSecond result", - }, - }, - }, - ], - }, - ]) - }) - - it("should convert a message with tool result as array with text and images", () => { - const toolIdToName = new Map() - toolIdToName.set("search-123", "search") - - const anthropicMessage: Anthropic.Messages.MessageParam = { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "search-123", - content: [ - { type: "text", text: "Search results:" }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "image1data", - }, - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "image2data", - }, - }, - ], - }, - ], - } - - const result = convertAnthropicMessageToGemini(anthropicMessage, { toolIdToName }) - - expect(result).toEqual([ - { - role: "user", - parts: [ - { - functionResponse: { - name: "search", - response: { - name: "search", - content: "Search results:\n\n(See next part for image)", - }, - }, - }, - { - inlineData: { - data: "image1data", - mimeType: "image/png", - }, - }, - { - inlineData: { - data: "image2data", - mimeType: "image/jpeg", - }, - }, - ], - }, - ]) - }) - - it("should convert a message with tool result containing only images", () => { - const toolIdToName = new Map() - toolIdToName.set("imagesearch-123", "imagesearch") - - const anthropicMessage: Anthropic.Messages.MessageParam = { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "imagesearch-123", - content: [ - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "onlyimagedata", - }, - }, - ], - }, - ], - } - - const result = convertAnthropicMessageToGemini(anthropicMessage, { toolIdToName }) - - expect(result).toEqual([ - { - role: "user", - parts: [ - { - functionResponse: { - name: "imagesearch", - response: { - name: "imagesearch", - content: "\n\n(See next part for image)", - }, - }, - }, - { - inlineData: { - data: "onlyimagedata", - mimeType: "image/png", - }, - }, - ], - }, - ]) - }) - - it("should handle tool names with hyphens using toolIdToName map", () => { - const toolIdToName = new Map() - toolIdToName.set("search-files-123", "search-files") - - const anthropicMessage: Anthropic.Messages.MessageParam = { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "search-files-123", - content: "found files", - }, - ], - } - - const result = convertAnthropicMessageToGemini(anthropicMessage, { toolIdToName }) - - expect(result).toEqual([ - { - role: "user", - parts: [ - { - functionResponse: { - name: "search-files", - response: { - name: "search-files", - content: "found files", - }, - }, - }, - ], - }, - ]) - }) - - it("should throw error when toolIdToName map is not provided", () => { - const anthropicMessage: Anthropic.Messages.MessageParam = { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "calculator-123", - content: "result is 5", - }, - ], - } - - expect(() => convertAnthropicMessageToGemini(anthropicMessage)).toThrow( - 'Unable to find tool name for tool_use_id "calculator-123"', - ) - }) - - it("should throw error when tool_use_id is not in the map", () => { - const toolIdToName = new Map() - toolIdToName.set("other-tool-456", "other-tool") - - const anthropicMessage: Anthropic.Messages.MessageParam = { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "calculator-123", - content: "result is 5", - }, - ], - } - - expect(() => convertAnthropicMessageToGemini(anthropicMessage, { toolIdToName })).toThrow( - 'Unable to find tool name for tool_use_id "calculator-123"', - ) - }) - - it("should skip unsupported content block types", () => { - const anthropicMessage: Anthropic.Messages.MessageParam = { - role: "user", - content: [ - { - type: "unknown_type", // Unsupported type - data: "some data", - } as any, - { type: "text", text: "Valid content" }, - ], - } - - const result = convertAnthropicMessageToGemini(anthropicMessage) - - expect(result).toEqual([ - { - role: "user", - parts: [{ text: "Valid content" }], - }, - ]) - }) - - it("should skip reasoning content blocks", () => { - const anthropicMessage: Anthropic.Messages.MessageParam = { - role: "assistant", - content: [ - { - type: "reasoning" as any, - text: "Let me think about this...", - }, - { type: "text", text: "Here's my answer" }, - ], - } - - const result = convertAnthropicMessageToGemini(anthropicMessage) - - expect(result).toEqual([ - { - role: "model", - parts: [{ text: "Here's my answer" }], - }, - ]) - }) -}) diff --git a/src/api/transform/__tests__/image-cleaning.spec.ts b/src/api/transform/__tests__/image-cleaning.spec.ts index e32a4b8770..fc91e0da46 100644 --- a/src/api/transform/__tests__/image-cleaning.spec.ts +++ b/src/api/transform/__tests__/image-cleaning.spec.ts @@ -18,6 +18,7 @@ describe("maybeRemoveImageBlocks", () => { }), createMessage: vitest.fn(), countTokens: vitest.fn(), + isAiSdkProvider: vitest.fn().mockReturnValue(false), } } diff --git a/src/api/transform/__tests__/model-params.spec.ts b/src/api/transform/__tests__/model-params.spec.ts index 75b5c50c59..a50f1291be 100644 --- a/src/api/transform/__tests__/model-params.spec.ts +++ b/src/api/transform/__tests__/model-params.spec.ts @@ -17,16 +17,19 @@ describe("getModelParams", () => { const anthropicParams = { modelId: "test", format: "anthropic" as const, + defaultTemperature: 0, } const openaiParams = { modelId: "test", format: "openai" as const, + defaultTemperature: 0, } const openrouterParams = { modelId: "test", format: "openrouter" as const, + defaultTemperature: 0, } describe("Basic functionality", () => { @@ -48,11 +51,12 @@ describe("getModelParams", () => { }) }) - it("should use default temperature of 0 when no defaultTemperature is provided", () => { + it("should use the provided defaultTemperature when no user or model temperature is set", () => { const result = getModelParams({ ...anthropicParams, settings: {}, model: baseModel, + defaultTemperature: 0, }) expect(result.temperature).toBe(0) @@ -193,6 +197,7 @@ describe("getModelParams", () => { format: "openrouter" as const, settings: {}, model: baseModel, + defaultTemperature: 0, }) expect(result.maxTokens).toBe(ANTHROPIC_DEFAULT_MAX_TOKENS) @@ -214,6 +219,7 @@ describe("getModelParams", () => { format: "openrouter" as const, settings: {}, model: baseModel, + defaultTemperature: 0, }) expect(result.maxTokens).toBeUndefined() @@ -374,6 +380,7 @@ describe("getModelParams", () => { format: "gemini" as const, settings: { modelMaxTokens: 2000, modelMaxThinkingTokens: 50 }, model, + defaultTemperature: 0, }), ).toEqual({ format: "gemini", @@ -400,6 +407,7 @@ describe("getModelParams", () => { format: "openrouter" as const, settings: { modelMaxTokens: 4000 }, model, + defaultTemperature: 0, }), ).toEqual({ format: "openrouter", diff --git a/src/api/transform/__tests__/reasoning.spec.ts b/src/api/transform/__tests__/reasoning.spec.ts index 352aac8e7b..0b402c6d55 100644 --- a/src/api/transform/__tests__/reasoning.spec.ts +++ b/src/api/transform/__tests__/reasoning.spec.ts @@ -765,6 +765,7 @@ describe("reasoning.ts", () => { } const result = getGeminiReasoning(options) + // "none" is not a valid GeminiThinkingLevel, so no fallback — returns undefined expect(result).toBeUndefined() }) @@ -838,6 +839,128 @@ describe("reasoning.ts", () => { const result = getGeminiReasoning(options) as GeminiReasoningParams | undefined expect(result).toEqual({ thinkingLevel: "medium", includeThoughts: true }) }) + + it("should fall back to model default when settings effort is not in supportsReasoningEffort array", () => { + // Simulates gemini-3-pro-preview which only supports ["low", "high"] + // but user has reasoningEffort: "medium" from a different model + const geminiModel: ModelInfo = { + ...baseModel, + supportsReasoningEffort: ["low", "high"] as ModelInfo["supportsReasoningEffort"], + reasoningEffort: "low", + } + + const settings: ProviderSettings = { + apiProvider: "gemini", + reasoningEffort: "medium", + } + + const options: GetModelReasoningOptions = { + model: geminiModel, + reasoningBudget: undefined, + reasoningEffort: "medium", + settings, + } + + const result = getGeminiReasoning(options) as GeminiReasoningParams | undefined + // "medium" is not in ["low", "high"], so falls back to model.reasoningEffort "low" + expect(result).toEqual({ thinkingLevel: "low", includeThoughts: true }) + }) + + it("should return undefined when unsupported effort and model default is also invalid", () => { + const geminiModel: ModelInfo = { + ...baseModel, + supportsReasoningEffort: ["low", "high"] as ModelInfo["supportsReasoningEffort"], + // No reasoningEffort default set + } + + const settings: ProviderSettings = { + apiProvider: "gemini", + reasoningEffort: "medium", + } + + const options: GetModelReasoningOptions = { + model: geminiModel, + reasoningBudget: undefined, + reasoningEffort: "medium", + settings, + } + + const result = getGeminiReasoning(options) + // "medium" is not in ["low", "high"], fallback is undefined → returns undefined + expect(result).toBeUndefined() + }) + + it("should pass through effort that IS in the supportsReasoningEffort array", () => { + const geminiModel: ModelInfo = { + ...baseModel, + supportsReasoningEffort: ["low", "high"] as ModelInfo["supportsReasoningEffort"], + reasoningEffort: "low", + } + + const settings: ProviderSettings = { + apiProvider: "gemini", + reasoningEffort: "high", + } + + const options: GetModelReasoningOptions = { + model: geminiModel, + reasoningBudget: undefined, + reasoningEffort: "high", + settings, + } + + const result = getGeminiReasoning(options) as GeminiReasoningParams | undefined + // "high" IS in ["low", "high"], so it should be used directly + expect(result).toEqual({ thinkingLevel: "high", includeThoughts: true }) + }) + + it("should skip validation when supportsReasoningEffort is boolean (not array)", () => { + const geminiModel: ModelInfo = { + ...baseModel, + supportsReasoningEffort: true, + reasoningEffort: "low", + } + + const settings: ProviderSettings = { + apiProvider: "gemini", + reasoningEffort: "medium", + } + + const options: GetModelReasoningOptions = { + model: geminiModel, + reasoningBudget: undefined, + reasoningEffort: "medium", + settings, + } + + const result = getGeminiReasoning(options) as GeminiReasoningParams | undefined + // boolean supportsReasoningEffort should not trigger array validation + expect(result).toEqual({ thinkingLevel: "medium", includeThoughts: true }) + }) + + it("should fall back to model default when settings has 'minimal' but model only supports ['low', 'high']", () => { + const geminiModel: ModelInfo = { + ...baseModel, + supportsReasoningEffort: ["low", "high"] as ModelInfo["supportsReasoningEffort"], + reasoningEffort: "low", + } + + const settings: ProviderSettings = { + apiProvider: "gemini", + reasoningEffort: "minimal", + } + + const options: GetModelReasoningOptions = { + model: geminiModel, + reasoningBudget: undefined, + reasoningEffort: "minimal", + settings, + } + + const result = getGeminiReasoning(options) as GeminiReasoningParams | undefined + // "minimal" is not in ["low", "high"], falls back to "low" + expect(result).toEqual({ thinkingLevel: "low", includeThoughts: true }) + }) }) describe("Integration scenarios", () => { diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts index ebbf1a8661..c673fad3d2 100644 --- a/src/api/transform/ai-sdk.ts +++ b/src/api/transform/ai-sdk.ts @@ -8,14 +8,29 @@ import OpenAI from "openai" import { tool as createTool, jsonSchema, type ModelMessage, type TextStreamPart } from "ai" import type { ApiStreamChunk } from "./stream" +/** + * Options for converting Anthropic messages to AI SDK format. + */ +export interface ConvertToAiSdkMessagesOptions { + /** + * Optional function to transform the converted messages. + * Useful for transformations like flattening message content for models that require string content. + */ + transform?: (messages: ModelMessage[]) => ModelMessage[] +} + /** * Convert Anthropic messages to AI SDK ModelMessage format. * Handles text, images, tool uses, and tool results. * * @param messages - Array of Anthropic message parameters + * @param options - Optional conversion options including post-processing function * @returns Array of AI SDK ModelMessage objects */ -export function convertToAiSdkMessages(messages: Anthropic.Messages.MessageParam[]): ModelMessage[] { +export function convertToAiSdkMessages( + messages: Anthropic.Messages.MessageParam[], + options?: ConvertToAiSdkMessagesOptions, +): ModelMessage[] { const modelMessages: ModelMessage[] = [] // First pass: build a map of tool call IDs to tool names from assistant messages @@ -111,31 +126,124 @@ export function convertToAiSdkMessages(messages: Anthropic.Messages.MessageParam } } else if (message.role === "assistant") { const textParts: string[] = [] + const reasoningParts: string[] = [] + const reasoningContent = (() => { + const maybe = (message as unknown as { reasoning_content?: unknown }).reasoning_content + return typeof maybe === "string" && maybe.length > 0 ? maybe : undefined + })() const toolCalls: Array<{ type: "tool-call" toolCallId: string toolName: string input: unknown + providerOptions?: Record> }> = [] + // Capture thinking signature for Anthropic-protocol providers (Bedrock, Anthropic). + // Task.ts stores thinking blocks as { type: "thinking", thinking: "...", signature: "..." }. + // The signature must be passed back via providerOptions on reasoning parts. + let thinkingSignature: string | undefined + + // Extract thoughtSignature from content blocks (Gemini 3 thought signature round-tripping). + // Task.ts stores these as { type: "thoughtSignature", thoughtSignature: "..." } blocks. + let thoughtSignature: string | undefined + for (const part of message.content) { + const partAny = part as unknown as { type?: string; thoughtSignature?: string } + if (partAny.type === "thoughtSignature" && partAny.thoughtSignature) { + thoughtSignature = partAny.thoughtSignature + } + } + for (const part of message.content) { if (part.type === "text") { textParts.push(part.text) - } else if (part.type === "tool_use") { - toolCalls.push({ + continue + } + + if (part.type === "tool_use") { + const toolCall: (typeof toolCalls)[number] = { type: "tool-call", toolCallId: part.id, toolName: part.name, input: part.input, - }) + } + + // Attach thoughtSignature as providerOptions on tool-call parts. + // The AI SDK's @ai-sdk/google provider reads providerOptions.google.thoughtSignature + // and attaches it to the Gemini functionCall part. + // Per Gemini 3 rules: only the FIRST functionCall in a parallel batch gets the signature. + if (thoughtSignature && toolCalls.length === 0) { + toolCall.providerOptions = { + google: { thoughtSignature }, + vertex: { thoughtSignature }, + } + } + + toolCalls.push(toolCall) + continue + } + + // Some providers (DeepSeek, Gemini, etc.) require reasoning to be round-tripped. + // Task stores reasoning as a content block (type: "reasoning") and Anthropic extended + // thinking as (type: "thinking"). Convert both to AI SDK's reasoning part. + if ((part as unknown as { type?: string }).type === "reasoning") { + // If message-level reasoning_content is present, treat it as canonical and + // avoid mixing it with content-block reasoning (which can cause duplication). + if (reasoningContent) continue + + const text = (part as unknown as { text?: string }).text + if (typeof text === "string" && text.length > 0) { + reasoningParts.push(text) + } + continue + } + + if ((part as unknown as { type?: string }).type === "thinking") { + if (reasoningContent) continue + + const thinkingPart = part as unknown as { thinking?: string; signature?: string } + if (typeof thinkingPart.thinking === "string" && thinkingPart.thinking.length > 0) { + reasoningParts.push(thinkingPart.thinking) + } + // Capture the signature for round-tripping (Anthropic/Bedrock thinking) + if (thinkingPart.signature) { + thinkingSignature = thinkingPart.signature + } + continue } } const content: Array< + | { type: "reasoning"; text: string; providerOptions?: Record> } | { type: "text"; text: string } - | { type: "tool-call"; toolCallId: string; toolName: string; input: unknown } + | { + type: "tool-call" + toolCallId: string + toolName: string + input: unknown + providerOptions?: Record> + } > = [] + if (reasoningContent) { + content.push({ type: "reasoning", text: reasoningContent }) + } else if (reasoningParts.length > 0) { + const reasoningPart: (typeof content)[number] = { + type: "reasoning", + text: reasoningParts.join(""), + } + // Attach thinking signature for Anthropic/Bedrock round-tripping. + // The AI SDK's @ai-sdk/amazon-bedrock reads providerOptions.bedrock.signature + // and attaches it to reasoningContent.reasoningText.signature in the Bedrock request. + if (thinkingSignature) { + reasoningPart.providerOptions = { + bedrock: { signature: thinkingSignature }, + anthropic: { signature: thinkingSignature }, + } + } + content.push(reasoningPart) + } + if (textParts.length > 0) { content.push({ type: "text", text: textParts.join("\n") }) } @@ -149,9 +257,87 @@ export function convertToAiSdkMessages(messages: Anthropic.Messages.MessageParam } } + // Apply transform if provided + if (options?.transform) { + return options.transform(modelMessages) + } + return modelMessages } +/** + * Options for flattening AI SDK messages. + */ +export interface FlattenMessagesOptions { + /** + * If true, flattens user messages with only text parts to string content. + * Default: true + */ + flattenUserMessages?: boolean + /** + * If true, flattens assistant messages with only text (no tool calls) to string content. + * Default: true + */ + flattenAssistantMessages?: boolean +} + +/** + * Flatten AI SDK messages to use string content where possible. + * Some models (like DeepSeek on SambaNova) require string content instead of array content. + * This function converts messages that contain only text parts to use simple string content. + * + * @param messages - Array of AI SDK ModelMessage objects + * @param options - Options for controlling which message types to flatten + * @returns Array of AI SDK ModelMessage objects with flattened content where applicable + */ +export function flattenAiSdkMessagesToStringContent( + messages: ModelMessage[], + options: FlattenMessagesOptions = {}, +): ModelMessage[] { + const { flattenUserMessages = true, flattenAssistantMessages = true } = options + + return messages.map((message) => { + // Skip if content is already a string + if (typeof message.content === "string") { + return message + } + + // Handle user messages + if (message.role === "user" && flattenUserMessages && Array.isArray(message.content)) { + const parts = message.content as Array<{ type: string; text?: string }> + // Only flatten if all parts are text + const allText = parts.every((part) => part.type === "text") + if (allText && parts.length > 0) { + const textContent = parts.map((part) => part.text || "").join("\n") + return { + ...message, + content: textContent, + } + } + } + + // Handle assistant messages + if (message.role === "assistant" && flattenAssistantMessages && Array.isArray(message.content)) { + const parts = message.content as Array<{ type: string; text?: string }> + // Only flatten if all parts are text or reasoning (no tool calls) + // Reasoning parts are included in text to avoid sending multipart content to string-only models + const allTextOrReasoning = parts.every((part) => part.type === "text" || part.type === "reasoning") + if (allTextOrReasoning && parts.length > 0) { + // Extract only text parts for the flattened content (reasoning is stripped for string-only models) + const textParts = parts.filter((part) => part.type === "text") + const textContent = textParts.map((part) => part.text || "").join("\n") + return { + ...message, + content: textContent, + } + } + } + + // Return unchanged for tool role and messages with non-text content + return message + }) +} + /** * Convert OpenAI-style function tool definitions to AI SDK tool format. * diff --git a/src/api/transform/bedrock-converse-format.ts b/src/api/transform/bedrock-converse-format.ts deleted file mode 100644 index 2a49d72bce..0000000000 --- a/src/api/transform/bedrock-converse-format.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import { ConversationRole, Message, ContentBlock } from "@aws-sdk/client-bedrock-runtime" -import { sanitizeOpenAiCallId } from "../../utils/tool-id" - -interface BedrockMessageContent { - type: "text" | "image" | "video" | "tool_use" | "tool_result" - text?: string - source?: { - type: "base64" - data: string | Uint8Array // string for Anthropic, Uint8Array for Bedrock - media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp" - } - // Video specific fields - format?: string - s3Location?: { - uri: string - bucketOwner?: string - } - // Tool use and result fields - toolUseId?: string - name?: string - input?: any - output?: any // Used for tool_result type -} - -/** - * Convert Anthropic messages to Bedrock Converse format - * @param anthropicMessages Messages in Anthropic format - */ -export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] { - return anthropicMessages.map((anthropicMessage) => { - // Map Anthropic roles to Bedrock roles - const role: ConversationRole = anthropicMessage.role === "assistant" ? "assistant" : "user" - - if (typeof anthropicMessage.content === "string") { - return { - role, - content: [ - { - text: anthropicMessage.content, - }, - ] as ContentBlock[], - } - } - - // Process complex content types - const content = anthropicMessage.content.map((block) => { - const messageBlock = block as BedrockMessageContent & { - id?: string - tool_use_id?: string - content?: string | Array<{ type: string; text: string }> - output?: string | Array<{ type: string; text: string }> - } - - if (messageBlock.type === "text") { - return { - text: messageBlock.text || "", - } as ContentBlock - } - - if (messageBlock.type === "image" && messageBlock.source) { - // Convert base64 string to byte array if needed - let byteArray: Uint8Array - if (typeof messageBlock.source.data === "string") { - const binaryString = atob(messageBlock.source.data) - byteArray = new Uint8Array(binaryString.length) - for (let i = 0; i < binaryString.length; i++) { - byteArray[i] = binaryString.charCodeAt(i) - } - } else { - byteArray = messageBlock.source.data - } - - // Extract format from media_type (e.g., "image/jpeg" -> "jpeg") - const format = messageBlock.source.media_type.split("/")[1] - if (!["png", "jpeg", "gif", "webp"].includes(format)) { - throw new Error(`Unsupported image format: ${format}`) - } - - return { - image: { - format: format as "png" | "jpeg" | "gif" | "webp", - source: { - bytes: byteArray, - }, - }, - } as ContentBlock - } - - if (messageBlock.type === "tool_use") { - // Native-only: keep input as JSON object for Bedrock's toolUse format - return { - toolUse: { - toolUseId: sanitizeOpenAiCallId(messageBlock.id || ""), - name: messageBlock.name || "", - input: messageBlock.input || {}, - }, - } as ContentBlock - } - - if (messageBlock.type === "tool_result") { - // Handle content field - can be string or array (native tool format) - if (messageBlock.content) { - // Content is a string - if (typeof messageBlock.content === "string") { - return { - toolResult: { - toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""), - content: [ - { - text: messageBlock.content, - }, - ], - status: "success", - }, - } as ContentBlock - } - // Content is an array of content blocks - if (Array.isArray(messageBlock.content)) { - return { - toolResult: { - toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""), - content: messageBlock.content.map((item) => ({ - text: typeof item === "string" ? item : item.text || String(item), - })), - status: "success", - }, - } as ContentBlock - } - } - - // Fall back to output handling if content is not available - if (messageBlock.output && typeof messageBlock.output === "string") { - return { - toolResult: { - toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""), - content: [ - { - text: messageBlock.output, - }, - ], - status: "success", - }, - } as ContentBlock - } - // Handle array of content blocks if output is an array - if (Array.isArray(messageBlock.output)) { - return { - toolResult: { - toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""), - content: messageBlock.output.map((part) => { - if (typeof part === "object" && "text" in part) { - return { text: part.text } - } - // Skip images in tool results as they're handled separately - if (typeof part === "object" && "type" in part && part.type === "image") { - return { text: "(see following message for image)" } - } - return { text: String(part) } - }), - status: "success", - }, - } as ContentBlock - } - - // Default case - return { - toolResult: { - toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""), - content: [ - { - text: String(messageBlock.output || ""), - }, - ], - status: "success", - }, - } as ContentBlock - } - - if (messageBlock.type === "video") { - const videoContent = messageBlock.s3Location - ? { - s3Location: { - uri: messageBlock.s3Location.uri, - bucketOwner: messageBlock.s3Location.bucketOwner, - }, - } - : messageBlock.source - - return { - video: { - format: "mp4", // Default to mp4, adjust based on actual format if needed - source: videoContent, - }, - } as ContentBlock - } - - // Default case for unknown block types - return { - text: "[Unknown Block Type]", - } as ContentBlock - }) - - return { - role, - content, - } - }) -} diff --git a/src/api/transform/cache-strategy/__tests__/cache-strategy.spec.ts b/src/api/transform/cache-strategy/__tests__/cache-strategy.spec.ts deleted file mode 100644 index 1e702d88a0..0000000000 --- a/src/api/transform/cache-strategy/__tests__/cache-strategy.spec.ts +++ /dev/null @@ -1,1112 +0,0 @@ -import { ContentBlock, SystemContentBlock, BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime" -import { Anthropic } from "@anthropic-ai/sdk" - -import { MultiPointStrategy } from "../multi-point-strategy" -import { CacheStrategyConfig, ModelInfo, CachePointPlacement } from "../types" -import { AwsBedrockHandler } from "../../../providers/bedrock" - -// Common test utilities -const defaultModelInfo: ModelInfo = { - maxTokens: 8192, - contextWindow: 200_000, - supportsPromptCache: true, - maxCachePoints: 4, - minTokensPerCachePoint: 50, - cachableFields: ["system", "messages", "tools"], -} - -const createConfig = (overrides: Partial = {}): CacheStrategyConfig => ({ - modelInfo: { - ...defaultModelInfo, - ...(overrides.modelInfo || {}), - }, - systemPrompt: "You are a helpful assistant", - messages: [], - usePromptCache: true, - ...overrides, -}) - -const createMessageWithTokens = (role: "user" | "assistant", tokenCount: number) => ({ - role, - content: "x".repeat(tokenCount * 4), // Approximate 4 chars per token -}) - -const hasCachePoint = (block: ContentBlock | SystemContentBlock): boolean => { - return ( - "cachePoint" in block && - typeof block.cachePoint === "object" && - block.cachePoint !== null && - "type" in block.cachePoint && - block.cachePoint.type === "default" - ) -} - -// Create a mock object to store the last config passed to convertToBedrockConverseMessages -interface CacheConfig { - modelInfo: any - systemPrompt?: string - messages: any[] - usePromptCache: boolean -} - -const convertToBedrockConverseMessagesMock = { - lastConfig: null as CacheConfig | null, - result: null as any, -} - -describe("Cache Strategy", () => { - // SECTION 1: Direct Strategy Implementation Tests - describe("Strategy Implementation", () => { - describe("Strategy Selection", () => { - it("should use MultiPointStrategy when caching is not supported", () => { - const config = createConfig({ - modelInfo: { ...defaultModelInfo, supportsPromptCache: false }, - }) - - const strategy = new MultiPointStrategy(config) - expect(strategy).toBeInstanceOf(MultiPointStrategy) - }) - - it("should use MultiPointStrategy when caching is disabled", () => { - const config = createConfig({ usePromptCache: false }) - - const strategy = new MultiPointStrategy(config) - expect(strategy).toBeInstanceOf(MultiPointStrategy) - }) - - it("should use MultiPointStrategy when maxCachePoints is 1", () => { - const config = createConfig({ - modelInfo: { ...defaultModelInfo, maxCachePoints: 1 }, - }) - - const strategy = new MultiPointStrategy(config) - expect(strategy).toBeInstanceOf(MultiPointStrategy) - }) - - it("should use MultiPointStrategy for multi-point cases", () => { - // Setup: Using multiple messages to test multi-point strategy - const config = createConfig({ - messages: [createMessageWithTokens("user", 50), createMessageWithTokens("assistant", 50)], - modelInfo: { - ...defaultModelInfo, - maxCachePoints: 4, - minTokensPerCachePoint: 50, - }, - }) - - const strategy = new MultiPointStrategy(config) - expect(strategy).toBeInstanceOf(MultiPointStrategy) - }) - }) - - describe("Message Formatting with Cache Points", () => { - it("converts simple text messages correctly", () => { - const config = createConfig({ - messages: [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there" }, - ], - systemPrompt: "", - modelInfo: { ...defaultModelInfo, supportsPromptCache: false }, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - expect(result.messages).toEqual([ - { - role: "user", - content: [{ text: "Hello" }], - }, - { - role: "assistant", - content: [{ text: "Hi there" }], - }, - ]) - }) - - describe("system cache block insertion", () => { - it("adds system cache block when prompt caching is enabled, messages exist, and system prompt is long enough", () => { - // Create a system prompt that's at least 50 tokens (200+ characters) - const longSystemPrompt = - "You are a helpful assistant that provides detailed and accurate information. " + - "You should always be polite, respectful, and considerate of the user's needs. " + - "When answering questions, try to provide comprehensive explanations that are easy to understand. " + - "If you don't know something, be honest about it rather than making up information." - - const config = createConfig({ - messages: [{ role: "user", content: "Hello" }], - systemPrompt: longSystemPrompt, - modelInfo: { - ...defaultModelInfo, - supportsPromptCache: true, - cachableFields: ["system", "messages", "tools"], - }, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Check that system blocks include both the text and a cache block - expect(result.system).toHaveLength(2) - expect(result.system[0]).toEqual({ text: longSystemPrompt }) - expect(hasCachePoint(result.system[1])).toBe(true) - }) - - it("adds system cache block when model info specifies it should", () => { - const shortSystemPrompt = "You are a helpful assistant" - - const config = createConfig({ - messages: [{ role: "user", content: "Hello" }], - systemPrompt: shortSystemPrompt, - modelInfo: { - ...defaultModelInfo, - supportsPromptCache: true, - minTokensPerCachePoint: 1, // Set to 1 to ensure it passes the threshold - cachableFields: ["system", "messages", "tools"], - }, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Check that system blocks include both the text and a cache block - expect(result.system).toHaveLength(2) - expect(result.system[0]).toEqual({ text: shortSystemPrompt }) - expect(hasCachePoint(result.system[1])).toBe(true) - }) - - it("does not add system cache block when system prompt is too short", () => { - const shortSystemPrompt = "You are a helpful assistant" - - const config = createConfig({ - messages: [{ role: "user", content: "Hello" }], - systemPrompt: shortSystemPrompt, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Check that system blocks only include the text, no cache block - expect(result.system).toHaveLength(1) - expect(result.system[0]).toEqual({ text: shortSystemPrompt }) - }) - - it("does not add cache blocks when messages array is empty even if prompt caching is enabled", () => { - const config = createConfig({ - messages: [], - systemPrompt: "You are a helpful assistant", - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Check that system blocks only include the text, no cache block - expect(result.system).toHaveLength(1) - expect(result.system[0]).toEqual({ text: "You are a helpful assistant" }) - - // Verify no messages or cache blocks were added - expect(result.messages).toHaveLength(0) - }) - - it("does not add system cache block when prompt caching is disabled", () => { - const config = createConfig({ - messages: [{ role: "user", content: "Hello" }], - systemPrompt: "You are a helpful assistant", - usePromptCache: false, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Check that system blocks only include the text - expect(result.system).toHaveLength(1) - expect(result.system[0]).toEqual({ text: "You are a helpful assistant" }) - }) - - it("does not insert message cache blocks when prompt caching is disabled", () => { - // Create a long conversation that would trigger cache blocks if enabled - const messages: Anthropic.Messages.MessageParam[] = Array(10) - .fill(null) - .map((_, i) => ({ - role: i % 2 === 0 ? "user" : "assistant", - content: - "This is message " + - (i + 1) + - " with some additional text to increase token count. " + - "Adding more text to ensure we exceed the token threshold for cache block insertion.", - })) - - const config = createConfig({ - messages, - systemPrompt: "", - usePromptCache: false, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Verify no cache blocks were inserted - expect(result.messages).toHaveLength(10) - result.messages.forEach((message) => { - if (message.content) { - message.content.forEach((block) => { - expect(hasCachePoint(block)).toBe(false) - }) - } - }) - }) - }) - }) - }) - - // SECTION 2: AwsBedrockHandler Integration Tests - describe("AwsBedrockHandler Integration", () => { - let handler: AwsBedrockHandler - - const mockMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello", - }, - { - role: "assistant", - content: "Hi there!", - }, - ] - - const systemPrompt = "You are a helpful assistant" - - beforeEach(() => { - // Clear all mocks before each test - vitest.clearAllMocks() - - // Create a handler with prompt cache enabled and a model that supports it - handler = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-7-sonnet-20250219-v1:0", // This model supports prompt cache - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", - awsUsePromptCache: true, - }) - - // Mock the getModel method to return a model with cachableFields and multi-point support - vitest.spyOn(handler, "getModel").mockReturnValue({ - id: "anthropic.claude-3-7-sonnet-20250219-v1:0", - info: { - maxTokens: 8192, - contextWindow: 200000, - supportsPromptCache: true, - supportsImages: true, - cachableFields: ["system", "messages"], - maxCachePoints: 4, // Support for multiple cache points - minTokensPerCachePoint: 50, - }, - }) - - // Mock the client.send method - const mockInvoke = vitest.fn().mockResolvedValue({ - stream: { - [Symbol.asyncIterator]: async function* () { - yield { - metadata: { - usage: { - inputTokens: 10, - outputTokens: 5, - }, - }, - } - }, - }, - }) - - handler["client"] = { - send: mockInvoke, - config: { region: "us-east-1" }, - } as unknown as BedrockRuntimeClient - - // Mock the convertToBedrockConverseMessages method to capture the config - vitest.spyOn(handler as any, "convertToBedrockConverseMessages").mockImplementation(function ( - ...args: any[] - ) { - const messages = args[0] - const systemMessage = args[1] - const usePromptCache = args[2] - const modelInfo = args[3] - - // Store the config for later inspection - const config: CacheConfig = { - modelInfo, - systemPrompt: systemMessage, - messages, - usePromptCache, - } - convertToBedrockConverseMessagesMock.lastConfig = config - - // Create a strategy based on the config - let strategy - // Use MultiPointStrategy for all cases - strategy = new MultiPointStrategy(config as any) - - // Store the result - const result = strategy.determineOptimalCachePoints() - convertToBedrockConverseMessagesMock.result = result - - return result - }) - }) - - it("should select MultiPointStrategy when conditions are met", async () => { - // Reset the mock - convertToBedrockConverseMessagesMock.lastConfig = null - - // Call the method that uses convertToBedrockConverseMessages - const stream = handler.createMessage(systemPrompt, mockMessages) - for await (const _chunk of stream) { - // Just consume the stream - } - - // Verify that convertToBedrockConverseMessages was called with the right parameters - expect(convertToBedrockConverseMessagesMock.lastConfig).toMatchObject({ - modelInfo: expect.objectContaining({ - supportsPromptCache: true, - maxCachePoints: 4, - }), - usePromptCache: true, - }) - - // Verify that the config would result in a MultiPointStrategy - expect(convertToBedrockConverseMessagesMock.lastConfig).not.toBeNull() - if (convertToBedrockConverseMessagesMock.lastConfig) { - const strategy = new MultiPointStrategy(convertToBedrockConverseMessagesMock.lastConfig as any) - expect(strategy).toBeInstanceOf(MultiPointStrategy) - } - }) - - it("should use MultiPointStrategy when maxCachePoints is 1", async () => { - // Mock the getModel method to return a model with only single-point support - vitest.spyOn(handler, "getModel").mockReturnValue({ - id: "anthropic.claude-3-7-sonnet-20250219-v1:0", - info: { - maxTokens: 8192, - contextWindow: 200000, - supportsPromptCache: true, - supportsImages: true, - cachableFields: ["system"], - maxCachePoints: 1, // Only supports one cache point - minTokensPerCachePoint: 50, - }, - }) - - // Reset the mock - convertToBedrockConverseMessagesMock.lastConfig = null - - // Call the method that uses convertToBedrockConverseMessages - const stream = handler.createMessage(systemPrompt, mockMessages) - for await (const _chunk of stream) { - // Just consume the stream - } - - // Verify that convertToBedrockConverseMessages was called with the right parameters - expect(convertToBedrockConverseMessagesMock.lastConfig).toMatchObject({ - modelInfo: expect.objectContaining({ - supportsPromptCache: true, - maxCachePoints: 1, - }), - usePromptCache: true, - }) - - // Verify that the config would result in a MultiPointStrategy - expect(convertToBedrockConverseMessagesMock.lastConfig).not.toBeNull() - if (convertToBedrockConverseMessagesMock.lastConfig) { - const strategy = new MultiPointStrategy(convertToBedrockConverseMessagesMock.lastConfig as any) - expect(strategy).toBeInstanceOf(MultiPointStrategy) - } - }) - - it("should use MultiPointStrategy when prompt cache is disabled", async () => { - // Create a handler with prompt cache disabled - handler = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-7-sonnet-20250219-v1:0", - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", - awsUsePromptCache: false, // Prompt cache disabled - }) - - // Mock the getModel method - vitest.spyOn(handler, "getModel").mockReturnValue({ - id: "anthropic.claude-3-7-sonnet-20250219-v1:0", - info: { - maxTokens: 8192, - contextWindow: 200000, - supportsPromptCache: true, - supportsImages: true, - cachableFields: ["system", "messages"], - maxCachePoints: 4, - minTokensPerCachePoint: 50, - }, - }) - - // Mock the client.send method - const mockInvoke = vitest.fn().mockResolvedValue({ - stream: { - [Symbol.asyncIterator]: async function* () { - yield { - metadata: { - usage: { - inputTokens: 10, - outputTokens: 5, - }, - }, - } - }, - }, - }) - - handler["client"] = { - send: mockInvoke, - config: { region: "us-east-1" }, - } as unknown as BedrockRuntimeClient - - // Mock the convertToBedrockConverseMessages method again for the new handler - vitest.spyOn(handler as any, "convertToBedrockConverseMessages").mockImplementation(function ( - ...args: any[] - ) { - const messages = args[0] - const systemMessage = args[1] - const usePromptCache = args[2] - const modelInfo = args[3] - - // Store the config for later inspection - const config: CacheConfig = { - modelInfo, - systemPrompt: systemMessage, - messages, - usePromptCache, - } - convertToBedrockConverseMessagesMock.lastConfig = config - - // Create a strategy based on the config - let strategy - // Use MultiPointStrategy for all cases - strategy = new MultiPointStrategy(config as any) - - // Store the result - const result = strategy.determineOptimalCachePoints() - convertToBedrockConverseMessagesMock.result = result - - return result - }) - - // Reset the mock - convertToBedrockConverseMessagesMock.lastConfig = null - - // Call the method that uses convertToBedrockConverseMessages - const stream = handler.createMessage(systemPrompt, mockMessages) - for await (const _chunk of stream) { - // Just consume the stream - } - - // Verify that convertToBedrockConverseMessages was called with the right parameters - expect(convertToBedrockConverseMessagesMock.lastConfig).toMatchObject({ - usePromptCache: false, - }) - - // Verify that the config would result in a MultiPointStrategy - expect(convertToBedrockConverseMessagesMock.lastConfig).not.toBeNull() - if (convertToBedrockConverseMessagesMock.lastConfig) { - const strategy = new MultiPointStrategy(convertToBedrockConverseMessagesMock.lastConfig as any) - expect(strategy).toBeInstanceOf(MultiPointStrategy) - } - }) - - it("should include cachePoint nodes in API request when using MultiPointStrategy", async () => { - // Mock the convertToBedrockConverseMessages method to return a result with cache points - ;(handler as any).convertToBedrockConverseMessages.mockReturnValueOnce({ - system: [{ text: systemPrompt }, { cachePoint: { type: "default" } }], - messages: mockMessages.map((msg: any) => ({ - role: msg.role, - content: [{ text: typeof msg.content === "string" ? msg.content : msg.content[0].text }], - })), - }) - - // Create a spy for the client.send method - const mockSend = vitest.fn().mockResolvedValue({ - stream: { - [Symbol.asyncIterator]: async function* () { - yield { - metadata: { - usage: { - inputTokens: 10, - outputTokens: 5, - }, - }, - } - }, - }, - }) - - handler["client"] = { - send: mockSend, - config: { region: "us-east-1" }, - } as unknown as BedrockRuntimeClient - - // Call the method that uses convertToBedrockConverseMessages - const stream = handler.createMessage(systemPrompt, mockMessages) - for await (const _chunk of stream) { - // Just consume the stream - } - - // Verify that the API request included system with cachePoint - expect(mockSend).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ - system: expect.arrayContaining([ - expect.objectContaining({ - text: systemPrompt, - }), - expect.objectContaining({ - cachePoint: expect.anything(), - }), - ]), - }), - }), - expect.anything(), - ) - }) - - it("should yield usage results with cache tokens when using MultiPointStrategy", async () => { - // Mock the convertToBedrockConverseMessages method to return a result with cache points - ;(handler as any).convertToBedrockConverseMessages.mockReturnValueOnce({ - system: [{ text: systemPrompt }, { cachePoint: { type: "default" } }], - messages: mockMessages.map((msg: any) => ({ - role: msg.role, - content: [{ text: typeof msg.content === "string" ? msg.content : msg.content[0].text }], - })), - }) - - // Create a mock stream that includes cache token fields - const mockApiResponse = { - metadata: { - usage: { - inputTokens: 10, - outputTokens: 5, - cacheReadInputTokens: 5, - cacheWriteInputTokens: 10, - }, - }, - } - - const mockStream = { - [Symbol.asyncIterator]: async function* () { - yield mockApiResponse - }, - } - - const mockSend = vitest.fn().mockImplementation(() => { - return Promise.resolve({ - stream: mockStream, - }) - }) - - handler["client"] = { - send: mockSend, - config: { region: "us-east-1" }, - } as unknown as BedrockRuntimeClient - - // Call the method that uses convertToBedrockConverseMessages - const stream = handler.createMessage(systemPrompt, mockMessages) - const chunks = [] - - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Verify that usage results with cache tokens are yielded - expect(chunks.length).toBeGreaterThan(0) - // The test already expects cache tokens, but the implementation might not be including them - // Let's make the test more flexible to accept either format - expect(chunks[0]).toMatchObject({ - type: "usage", - inputTokens: 10, - outputTokens: 5, - }) - }) - }) - - // SECTION 3: Multi-Point Strategy Cache Point Placement Tests - describe("Multi-Point Strategy Cache Point Placement", () => { - // These tests match the examples in the cache-strategy-documentation.md file - - // Common model info for all tests - const multiPointModelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 200000, - supportsPromptCache: true, - maxCachePoints: 3, - minTokensPerCachePoint: 50, // Lower threshold to ensure tests pass - cachableFields: ["system", "messages"], - } - - // Helper function to create a message with approximate token count - const createMessage = (role: "user" | "assistant", content: string, tokenCount: number) => { - // Pad the content to reach the desired token count (approx 4 chars per token) - const paddingNeeded = Math.max(0, tokenCount * 4 - content.length) - const padding = " ".repeat(paddingNeeded) - return { - role, - content: content + padding, - } - } - - // Helper to log cache point placements for debugging - const logPlacements = (placements: any[]) => { - console.log( - "Cache point placements:", - placements.map((p) => `index: ${p.index}, tokens: ${p.tokensCovered}`), - ) - } - - describe("Example 1: Initial Cache Point Placement", () => { - it("should place a cache point after the second user message", () => { - // Create messages matching Example 1 from documentation - const messages = [ - createMessage("user", "Tell me about machine learning.", 100), - createMessage("assistant", "Machine learning is a field of study...", 200), - createMessage("user", "What about deep learning?", 100), - createMessage("assistant", "Deep learning is a subset of machine learning...", 200), - ] - - const config = createConfig({ - modelInfo: multiPointModelInfo, - systemPrompt: "You are a helpful assistant.", // ~10 tokens - messages, - usePromptCache: true, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Log placements for debugging - if (result.messageCachePointPlacements) { - logPlacements(result.messageCachePointPlacements) - } - - // Verify cache point placements - expect(result.messageCachePointPlacements).toBeDefined() - expect(result.messageCachePointPlacements?.length).toBeGreaterThan(0) - - // First cache point should be after a user message - const firstPlacement = result.messageCachePointPlacements?.[0] - expect(firstPlacement).toBeDefined() - expect(firstPlacement?.type).toBe("message") - expect(messages[firstPlacement?.index || 0].role).toBe("user") - // Instead of checking for cache points in the messages array, - // we'll verify that the cache point placements array has at least one entry - // This is sufficient since we've already verified that the first placement exists - // and is after a user message - expect(result.messageCachePointPlacements?.length).toBeGreaterThan(0) - }) - }) - - describe("Example 2: Adding One Exchange with Cache Point Preservation", () => { - it("should preserve the previous cache point and add a new one when possible", () => { - // Create messages matching Example 2 from documentation - const messages = [ - createMessage("user", "Tell me about machine learning.", 100), - createMessage("assistant", "Machine learning is a field of study...", 200), - createMessage("user", "What about deep learning?", 100), - createMessage("assistant", "Deep learning is a subset of machine learning...", 200), - createMessage("user", "How do neural networks work?", 100), - createMessage("assistant", "Neural networks are composed of layers of nodes...", 200), - ] - - // Previous cache point placements from Example 1 - const previousCachePointPlacements: CachePointPlacement[] = [ - { - index: 2, // After the second user message (What about deep learning?) - type: "message", - tokensCovered: 300, - }, - ] - - const config = createConfig({ - modelInfo: multiPointModelInfo, - systemPrompt: "You are a helpful assistant.", // ~10 tokens - messages, - usePromptCache: true, - previousCachePointPlacements, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Log placements for debugging - if (result.messageCachePointPlacements) { - logPlacements(result.messageCachePointPlacements) - } - - // Verify cache point placements - expect(result.messageCachePointPlacements).toBeDefined() - - // First cache point should be preserved from previous - expect(result.messageCachePointPlacements?.[0]).toMatchObject({ - index: 2, // After the second user message - type: "message", - }) - - // Check if we have a second cache point (may not always be added depending on token distribution) - if (result.messageCachePointPlacements && result.messageCachePointPlacements.length > 1) { - // Second cache point should be after a user message - const secondPlacement = result.messageCachePointPlacements[1] - expect(secondPlacement.type).toBe("message") - expect(messages[secondPlacement.index].role).toBe("user") - expect(secondPlacement.index).toBeGreaterThan(2) // Should be after the first cache point - } - }) - }) - - describe("Example 3: Adding Another Exchange with Cache Point Preservation", () => { - it("should preserve previous cache points when possible", () => { - // Create messages matching Example 3 from documentation - const messages = [ - createMessage("user", "Tell me about machine learning.", 100), - createMessage("assistant", "Machine learning is a field of study...", 200), - createMessage("user", "What about deep learning?", 100), - createMessage("assistant", "Deep learning is a subset of machine learning...", 200), - createMessage("user", "How do neural networks work?", 100), - createMessage("assistant", "Neural networks are composed of layers of nodes...", 200), - createMessage("user", "Can you explain backpropagation?", 100), - createMessage("assistant", "Backpropagation is an algorithm used to train neural networks...", 200), - ] - - // Previous cache point placements from Example 2 - const previousCachePointPlacements: CachePointPlacement[] = [ - { - index: 2, // After the second user message (What about deep learning?) - type: "message", - tokensCovered: 300, - }, - { - index: 4, // After the third user message (How do neural networks work?) - type: "message", - tokensCovered: 300, - }, - ] - - const config = createConfig({ - modelInfo: multiPointModelInfo, - systemPrompt: "You are a helpful assistant.", // ~10 tokens - messages, - usePromptCache: true, - previousCachePointPlacements, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Log placements for debugging - if (result.messageCachePointPlacements) { - logPlacements(result.messageCachePointPlacements) - } - - // Verify cache point placements - expect(result.messageCachePointPlacements).toBeDefined() - - // First cache point should be preserved from previous - expect(result.messageCachePointPlacements?.[0]).toMatchObject({ - index: 2, // After the second user message - type: "message", - }) - - // Check if we have a second cache point preserved - if (result.messageCachePointPlacements && result.messageCachePointPlacements.length > 1) { - // Second cache point should be preserved or at a new position - const secondPlacement = result.messageCachePointPlacements[1] - expect(secondPlacement.type).toBe("message") - expect(messages[secondPlacement.index].role).toBe("user") - } - - // Check if we have a third cache point - if (result.messageCachePointPlacements && result.messageCachePointPlacements.length > 2) { - // Third cache point should be after a user message - const thirdPlacement = result.messageCachePointPlacements[2] - expect(thirdPlacement.type).toBe("message") - expect(messages[thirdPlacement.index].role).toBe("user") - expect(thirdPlacement.index).toBeGreaterThan(result.messageCachePointPlacements[1].index) // Should be after the second cache point - } - }) - }) - - describe("Example 4: Adding a Fourth Exchange with Cache Point Reallocation", () => { - it("should handle cache point reallocation when all points are used", () => { - // Create messages matching Example 4 from documentation - const messages = [ - createMessage("user", "Tell me about machine learning.", 100), - createMessage("assistant", "Machine learning is a field of study...", 200), - createMessage("user", "What about deep learning?", 100), - createMessage("assistant", "Deep learning is a subset of machine learning...", 200), - createMessage("user", "How do neural networks work?", 100), - createMessage("assistant", "Neural networks are composed of layers of nodes...", 200), - createMessage("user", "Can you explain backpropagation?", 100), - createMessage("assistant", "Backpropagation is an algorithm used to train neural networks...", 200), - createMessage("user", "What are some applications of deep learning?", 100), - createMessage("assistant", "Deep learning has many applications including...", 200), - ] - - // Previous cache point placements from Example 3 - const previousCachePointPlacements: CachePointPlacement[] = [ - { - index: 2, // After the second user message (What about deep learning?) - type: "message", - tokensCovered: 300, - }, - { - index: 4, // After the third user message (How do neural networks work?) - type: "message", - tokensCovered: 300, - }, - { - index: 6, // After the fourth user message (Can you explain backpropagation?) - type: "message", - tokensCovered: 300, - }, - ] - - const config = createConfig({ - modelInfo: multiPointModelInfo, - systemPrompt: "You are a helpful assistant.", // ~10 tokens - messages, - usePromptCache: true, - previousCachePointPlacements, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Log placements for debugging - if (result.messageCachePointPlacements) { - logPlacements(result.messageCachePointPlacements) - } - - // Verify cache point placements - expect(result.messageCachePointPlacements).toBeDefined() - expect(result.messageCachePointPlacements?.length).toBeLessThanOrEqual(3) // Should not exceed max cache points - - // First cache point should be preserved - expect(result.messageCachePointPlacements?.[0]).toMatchObject({ - index: 2, // After the second user message - type: "message", - }) - - // Check that all cache points are at valid user message positions - result.messageCachePointPlacements?.forEach((placement) => { - expect(placement.type).toBe("message") - expect(messages[placement.index].role).toBe("user") - }) - - // Check that cache points are in ascending order by index - for (let i = 1; i < (result.messageCachePointPlacements?.length || 0); i++) { - expect(result.messageCachePointPlacements?.[i].index).toBeGreaterThan( - result.messageCachePointPlacements?.[i - 1].index || 0, - ) - } - - // Check that the last cache point covers the new messages - const lastPlacement = - result.messageCachePointPlacements?.[result.messageCachePointPlacements.length - 1] - expect(lastPlacement?.index).toBeGreaterThanOrEqual(6) // Should be at or after the fourth user message - }) - }) - - describe("Cache Point Optimization", () => { - // Note: This test is skipped because it's meant to verify the documentation is correct, - // but the actual implementation behavior is different. The documentation has been updated - // to match the correct behavior. - it.skip("documentation example 5 verification", () => { - // This test verifies that the documentation for Example 5 is correct - // In Example 5, the third cache point at index 10 should cover 660 tokens - // (260 tokens from messages 7-8 plus 400 tokens from the new messages) - - // Create messages matching Example 5 from documentation - const _messages = [ - createMessage("user", "Tell me about machine learning.", 100), - createMessage("assistant", "Machine learning is a field of study...", 200), - createMessage("user", "What about deep learning?", 100), - createMessage("assistant", "Deep learning is a subset of machine learning...", 200), - createMessage("user", "How do neural networks work?", 100), - createMessage("assistant", "Neural networks are composed of layers of nodes...", 200), - createMessage("user", "Can you explain backpropagation?", 100), - createMessage("assistant", "Backpropagation is an algorithm used to train neural networks...", 200), - createMessage("user", "What are some applications of deep learning?", 100), - createMessage("assistant", "Deep learning has many applications including...", 160), - // New messages with 400 tokens total - createMessage("user", "Can you provide a detailed example?", 100), - createMessage("assistant", "Here's a detailed example...", 300), - ] - - // Previous cache point placements from Example 4 - const _previousCachePointPlacements: CachePointPlacement[] = [ - { - index: 2, // After the second user message - type: "message", - tokensCovered: 240, - }, - { - index: 6, // After the fourth user message - type: "message", - tokensCovered: 440, - }, - { - index: 8, // After the fifth user message - type: "message", - tokensCovered: 260, - }, - ] - - // In the documentation, the algorithm decides to replace the cache point at index 8 - // with a new one at index 10, and the tokensCovered value should be 660 tokens - // (260 tokens from messages 7-8 plus 400 tokens from the new messages) - - // However, the actual implementation may behave differently depending on how - // it calculates token counts and makes decisions about cache point placement - - // The important part is that our fix ensures that when a cache point is created, - // the tokensCovered value represents all tokens from the previous cache point - // to the current cache point, not just the tokens in the new messages - }) - - it("should not combine cache points when new messages have fewer tokens than the smallest combined gap", () => { - // This test verifies that when new messages have fewer tokens than the smallest combined gap, - // the algorithm keeps all existing cache points and doesn't add a new one - - // Create a spy on console.log to capture the actual values - const originalConsoleLog = console.log - const mockConsoleLog = vitest.fn() - console.log = mockConsoleLog - - try { - // Create messages with a small addition at the end - const messages = [ - createMessage("user", "Tell me about machine learning.", 100), - createMessage("assistant", "Machine learning is a field of study...", 200), - createMessage("user", "What about deep learning?", 100), - createMessage("assistant", "Deep learning is a subset of machine learning...", 200), - createMessage("user", "How do neural networks work?", 100), - createMessage("assistant", "Neural networks are composed of layers of nodes...", 200), - createMessage("user", "Can you explain backpropagation?", 100), - createMessage( - "assistant", - "Backpropagation is an algorithm used to train neural networks...", - 200, - ), - // Small addition (only 50 tokens total) - createMessage("user", "Thanks for the explanation.", 20), - createMessage("assistant", "You're welcome!", 30), - ] - - // Previous cache point placements with significant token coverage - const previousCachePointPlacements: CachePointPlacement[] = [ - { - index: 2, // After the second user message - type: "message", - tokensCovered: 400, // Significant token coverage - }, - { - index: 4, // After the third user message - type: "message", - tokensCovered: 300, // Significant token coverage - }, - { - index: 6, // After the fourth user message - type: "message", - tokensCovered: 300, // Significant token coverage - }, - ] - - const config = createConfig({ - modelInfo: multiPointModelInfo, - systemPrompt: "You are a helpful assistant.", // ~10 tokens - messages, - usePromptCache: true, - previousCachePointPlacements, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Verify cache point placements - expect(result.messageCachePointPlacements).toBeDefined() - - // Should keep all three previous cache points since combining would be inefficient - expect(result.messageCachePointPlacements?.length).toBe(3) - - // All original cache points should be preserved - expect(result.messageCachePointPlacements?.[0].index).toBe(2) - expect(result.messageCachePointPlacements?.[1].index).toBe(4) - expect(result.messageCachePointPlacements?.[2].index).toBe(6) - - // No new cache point should be added for the small addition - } finally { - // Restore original console.log - console.log = originalConsoleLog - } - }) - - it("should make correct decisions based on token counts", () => { - // This test verifies that the algorithm correctly compares token counts - // and makes the right decision about combining cache points - - // Create messages with a variety of token counts - const messages = [ - createMessage("user", "Tell me about machine learning.", 100), - createMessage("assistant", "Machine learning is a field of study...", 200), - createMessage("user", "What about deep learning?", 100), - createMessage("assistant", "Deep learning is a subset of machine learning...", 200), - createMessage("user", "How do neural networks work?", 100), - createMessage("assistant", "Neural networks are composed of layers of nodes...", 200), - createMessage("user", "Can you explain backpropagation?", 100), - createMessage("assistant", "Backpropagation is an algorithm used to train neural networks...", 200), - // New messages - createMessage("user", "Can you provide a detailed example?", 100), - createMessage("assistant", "Here's a detailed example...", 200), - ] - - // Previous cache point placements - const previousCachePointPlacements: CachePointPlacement[] = [ - { - index: 2, - type: "message", - tokensCovered: 400, - }, - { - index: 4, - type: "message", - tokensCovered: 150, - }, - { - index: 6, - type: "message", - tokensCovered: 150, - }, - ] - - const config = createConfig({ - modelInfo: multiPointModelInfo, - systemPrompt: "You are a helpful assistant.", - messages, - usePromptCache: true, - previousCachePointPlacements, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Verify we have cache points - expect(result.messageCachePointPlacements).toBeDefined() - expect(result.messageCachePointPlacements?.length).toBeGreaterThan(0) - }) - }) - }) -}) diff --git a/src/api/transform/cache-strategy/base-strategy.ts b/src/api/transform/cache-strategy/base-strategy.ts deleted file mode 100644 index 1bc05cdb84..0000000000 --- a/src/api/transform/cache-strategy/base-strategy.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import { ContentBlock, SystemContentBlock, Message, ConversationRole } from "@aws-sdk/client-bedrock-runtime" -import { CacheStrategyConfig, CacheResult, CachePointPlacement } from "./types" - -export abstract class CacheStrategy { - /** - * Determine optimal cache point placements and return the formatted result - */ - public abstract determineOptimalCachePoints(): CacheResult - - protected config: CacheStrategyConfig - protected systemTokenCount: number = 0 - - constructor(config: CacheStrategyConfig) { - this.config = config - this.initializeMessageGroups() - this.calculateSystemTokens() - } - - /** - * Initialize message groups from the input messages - */ - protected initializeMessageGroups(): void { - if (!this.config.messages.length) return - } - - /** - * Calculate token count for system prompt using a more accurate approach - */ - protected calculateSystemTokens(): void { - if (this.config.systemPrompt) { - const text = this.config.systemPrompt - - // Use a more accurate token estimation than simple character count - // Count words and add overhead for punctuation and special tokens - const words = text.split(/\s+/).filter((word) => word.length > 0) - // Average English word is ~1.3 tokens - let tokenCount = words.length * 1.3 - // Add overhead for punctuation and special characters - tokenCount += (text.match(/[.,!?;:()[\]{}""''`]/g) || []).length * 0.3 - // Add overhead for newlines - tokenCount += (text.match(/\n/g) || []).length * 0.5 - // Add a small overhead for system prompt structure - tokenCount += 5 - - this.systemTokenCount = Math.ceil(tokenCount) - } - } - - /** - * Create a cache point content block - */ - protected createCachePoint(): ContentBlock { - return { cachePoint: { type: "default" } } as unknown as ContentBlock - } - - /** - * Convert messages to content blocks - */ - protected messagesToContentBlocks(messages: Anthropic.Messages.MessageParam[]): Message[] { - return messages.map((message) => { - const role: ConversationRole = message.role === "assistant" ? "assistant" : "user" - - const content: ContentBlock[] = Array.isArray(message.content) - ? message.content.map((block) => { - if (typeof block === "string") { - return { text: block } as unknown as ContentBlock - } - if ("text" in block) { - return { text: block.text } as unknown as ContentBlock - } - // Handle other content types if needed - return { text: "[Unsupported Content]" } as unknown as ContentBlock - }) - : [{ text: message.content } as unknown as ContentBlock] - - return { - role, - content, - } - }) - } - - /** - * Check if a token count meets the minimum threshold for caching - */ - protected meetsMinTokenThreshold(tokenCount: number): boolean { - const minTokens = this.config.modelInfo.minTokensPerCachePoint - if (!minTokens) { - return false - } - return tokenCount >= minTokens - } - - /** - * Estimate token count for a message using a more accurate approach - * This implementation is based on the BaseProvider's countTokens method - * but adapted to work without requiring an instance of BaseProvider - */ - protected estimateTokenCount(message: Anthropic.Messages.MessageParam): number { - // Use a more sophisticated token counting approach - if (!message.content) return 0 - - let totalTokens = 0 - - if (Array.isArray(message.content)) { - for (const block of message.content) { - if (block.type === "text") { - // Use a more accurate token estimation than simple character count - // This is still an approximation but better than character/4 - const text = block.text || "" - if (text.length > 0) { - // Count words and add overhead for punctuation and special tokens - const words = text.split(/\s+/).filter((word) => word.length > 0) - // Average English word is ~1.3 tokens - totalTokens += words.length * 1.3 - // Add overhead for punctuation and special characters - totalTokens += (text.match(/[.,!?;:()[\]{}""''`]/g) || []).length * 0.3 - // Add overhead for newlines - totalTokens += (text.match(/\n/g) || []).length * 0.5 - } - } else if (block.type === "image") { - // For images, use a conservative estimate - totalTokens += 300 - } - } - } else if (typeof message.content === "string") { - const text = message.content - // Count words and add overhead for punctuation and special tokens - const words = text.split(/\s+/).filter((word) => word.length > 0) - // Average English word is ~1.3 tokens - totalTokens += words.length * 1.3 - // Add overhead for punctuation and special characters - totalTokens += (text.match(/[.,!?;:()[\]{}""''`]/g) || []).length * 0.3 - // Add overhead for newlines - totalTokens += (text.match(/\n/g) || []).length * 0.5 - } - - // Add a small overhead for message structure - totalTokens += 10 - - return Math.ceil(totalTokens) - } - - /** - * Apply cache points to content blocks based on placements - */ - protected applyCachePoints(messages: Message[], placements: CachePointPlacement[]): Message[] { - const result: Message[] = [] - for (let i = 0; i < messages.length; i++) { - const placement = placements.find((p) => p.index === i) - - if (placement) { - messages[i].content?.push(this.createCachePoint()) - } - result.push(messages[i]) - } - - return result - } - - /** - * Format the final result with cache points applied - */ - protected formatResult(systemBlocks: SystemContentBlock[] = [], messages: Message[]): CacheResult { - const result = { - system: systemBlocks, - messages, - } - return result - } -} diff --git a/src/api/transform/cache-strategy/multi-point-strategy.ts b/src/api/transform/cache-strategy/multi-point-strategy.ts deleted file mode 100644 index dc82136997..0000000000 --- a/src/api/transform/cache-strategy/multi-point-strategy.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { SystemContentBlock } from "@aws-sdk/client-bedrock-runtime" -import { CacheStrategy } from "./base-strategy" -import { CacheResult, CachePointPlacement } from "./types" -import { logger } from "../../../utils/logging" - -/** - * Strategy for handling multiple cache points. - * Creates cache points after messages as soon as uncached tokens exceed minimumTokenCount. - */ -export class MultiPointStrategy extends CacheStrategy { - /** - * Determine optimal cache point placements and return the formatted result - */ - public determineOptimalCachePoints(): CacheResult { - // If prompt caching is disabled or no messages, return without cache points - if (!this.config.usePromptCache || this.config.messages.length === 0) { - return this.formatWithoutCachePoints() - } - - const supportsSystemCache = this.config.modelInfo.cachableFields.includes("system") - const supportsMessageCache = this.config.modelInfo.cachableFields.includes("messages") - const minTokensPerPoint = this.config.modelInfo.minTokensPerCachePoint - let remainingCachePoints: number = this.config.modelInfo.maxCachePoints - - // First, determine if we'll use a system cache point - const useSystemCache = - supportsSystemCache && this.config.systemPrompt && this.meetsMinTokenThreshold(this.systemTokenCount) - - // Handle system blocks - let systemBlocks: SystemContentBlock[] = [] - if (this.config.systemPrompt) { - systemBlocks = [{ text: this.config.systemPrompt } as unknown as SystemContentBlock] - if (useSystemCache) { - systemBlocks.push(this.createCachePoint() as unknown as SystemContentBlock) - remainingCachePoints-- - } - } - - // If message caching isn't supported, return with just system caching - if (!supportsMessageCache) { - return this.formatResult(systemBlocks, this.messagesToContentBlocks(this.config.messages)) - } - - const placements = this.determineMessageCachePoints(minTokensPerPoint, remainingCachePoints) - const messages = this.messagesToContentBlocks(this.config.messages) - let cacheResult = this.formatResult(systemBlocks, this.applyCachePoints(messages, placements)) - - // Store the placements for future use (to maintain consistency across consecutive messages) - // This needs to be handled by the caller by passing these placements back in the next call - cacheResult.messageCachePointPlacements = placements - - return cacheResult - } - - /** - * Determine optimal cache point placements for messages - * This method handles both new conversations and growing conversations - * - * @param minTokensPerPoint Minimum tokens required per cache point - * @param remainingCachePoints Number of cache points available - * @returns Array of cache point placements - */ - private determineMessageCachePoints( - minTokensPerPoint: number, - remainingCachePoints: number, - ): CachePointPlacement[] { - if (this.config.messages.length <= 1) { - return [] - } - - const placements: CachePointPlacement[] = [] - const totalMessages = this.config.messages.length - const previousPlacements = this.config.previousCachePointPlacements || [] - - // Special case: If previousPlacements is empty, place initial cache points - if (previousPlacements.length === 0) { - let currentIndex = 0 - - while (currentIndex < totalMessages && remainingCachePoints > 0) { - const newPlacement = this.findOptimalPlacementForRange( - currentIndex, - totalMessages - 1, - minTokensPerPoint, - ) - - if (newPlacement) { - placements.push(newPlacement) - currentIndex = newPlacement.index + 1 - remainingCachePoints-- - } else { - break - } - } - - return placements - } - - // Calculate tokens in new messages (added since last cache point placement) - const lastPreviousIndex = previousPlacements[previousPlacements.length - 1].index - const newMessagesTokens = this.config.messages - .slice(lastPreviousIndex + 1) - .reduce((acc, curr) => acc + this.estimateTokenCount(curr), 0) - - // If new messages have enough tokens for a cache point, we need to decide - // whether to keep all previous cache points or combine some - if (newMessagesTokens >= minTokensPerPoint) { - // If we have enough cache points for all previous placements plus a new one, keep them all - if (remainingCachePoints > previousPlacements.length) { - // Keep all previous placements - for (const placement of previousPlacements) { - if (placement.index < totalMessages) { - placements.push(placement) - } - } - - // Add a new placement for the new messages - const newPlacement = this.findOptimalPlacementForRange( - lastPreviousIndex + 1, - totalMessages - 1, - minTokensPerPoint, - ) - - if (newPlacement) { - placements.push(newPlacement) - } - } else { - // We need to decide which previous cache points to keep and which to combine - // Strategy: Compare the token count of new messages with the smallest combined token gap - - // First, analyze the token distribution between previous cache points - const tokensBetweenPlacements: number[] = [] - let startIdx = 0 - - for (const placement of previousPlacements) { - const tokens = this.config.messages - .slice(startIdx, placement.index + 1) - .reduce((acc, curr) => acc + this.estimateTokenCount(curr), 0) - - tokensBetweenPlacements.push(tokens) - startIdx = placement.index + 1 - } - - // Find the two consecutive placements with the smallest token gap - let smallestGapIndex = 0 - let smallestGap = Number.MAX_VALUE - - for (let i = 0; i < tokensBetweenPlacements.length - 1; i++) { - const gap = tokensBetweenPlacements[i] + tokensBetweenPlacements[i + 1] - if (gap < smallestGap) { - smallestGap = gap - smallestGapIndex = i - } - } - - // Only combine cache points if it's beneficial - // Compare the token count of new messages with the smallest combined token gap - // Apply a required percentage increase to ensure reallocation is worth it - const requiredPercentageIncrease = 1.2 // 20% increase required - const requiredTokenThreshold = smallestGap * requiredPercentageIncrease - - if (newMessagesTokens >= requiredTokenThreshold) { - // It's beneficial to combine cache points since new messages have significantly more tokens - logger.info("Combining cache points is beneficial", { - ctx: "cache-strategy", - newMessagesTokens, - smallestGap, - requiredTokenThreshold, - action: "combining_cache_points", - }) - - // Combine the two placements with the smallest gap - for (let i = 0; i < previousPlacements.length; i++) { - if (i !== smallestGapIndex && i !== smallestGapIndex + 1) { - // Keep this placement - if (previousPlacements[i].index < totalMessages) { - placements.push(previousPlacements[i]) - } - } else if (i === smallestGapIndex) { - // Replace with a combined placement - const combinedEndIndex = previousPlacements[i + 1].index - - // Find the optimal placement within this combined range - const startOfRange = i === 0 ? 0 : previousPlacements[i - 1].index + 1 - const combinedPlacement = this.findOptimalPlacementForRange( - startOfRange, - combinedEndIndex, - minTokensPerPoint, - ) - - if (combinedPlacement) { - placements.push(combinedPlacement) - } - - // Skip the next placement as we've combined it - i++ - } - } - - // If we freed up a cache point, use it for the new messages - if (placements.length < remainingCachePoints) { - const newPlacement = this.findOptimalPlacementForRange( - lastPreviousIndex + 1, - totalMessages - 1, - minTokensPerPoint, - ) - - if (newPlacement) { - placements.push(newPlacement) - } - } - } else { - // It's not beneficial to combine cache points - // Keep all previous placements and don't add a new one for the new messages - logger.info("Combining cache points is not beneficial", { - ctx: "cache-strategy", - newMessagesTokens, - smallestGap, - action: "keeping_existing_cache_points", - }) - - // Keep all previous placements that are still valid - for (const placement of previousPlacements) { - if (placement.index < totalMessages) { - placements.push(placement) - } - } - } - } - - return placements - } else { - // New messages don't have enough tokens for a cache point - // Keep all previous placements that are still valid - for (const placement of previousPlacements) { - if (placement.index < totalMessages) { - placements.push(placement) - } - } - - return placements - } - } - - /** - * Find the optimal placement for a cache point within a specified range of messages - * Simply finds the last user message in the range - */ - private findOptimalPlacementForRange( - startIndex: number, - endIndex: number, - minTokensPerPoint: number, - ): CachePointPlacement | null { - if (startIndex >= endIndex) { - return null - } - - // Find the last user message in the range - let lastUserMessageIndex = -1 - for (let i = endIndex; i >= startIndex; i--) { - if (this.config.messages[i].role === "user") { - lastUserMessageIndex = i - break - } - } - - if (lastUserMessageIndex >= 0) { - // Calculate the total tokens covered from the previous cache point (or start of conversation) - // to this cache point. This ensures tokensCovered represents the full span of tokens - // that will be cached by this cache point. - let totalTokensCovered = 0 - - // Find the previous cache point index - const previousPlacements = this.config.previousCachePointPlacements || [] - let previousCachePointIndex = -1 - - for (const placement of previousPlacements) { - if (placement.index < startIndex && placement.index > previousCachePointIndex) { - previousCachePointIndex = placement.index - } - } - - // Calculate tokens from previous cache point (or start) to this cache point - const tokenStartIndex = previousCachePointIndex + 1 - totalTokensCovered = this.config.messages - .slice(tokenStartIndex, lastUserMessageIndex + 1) - .reduce((acc, curr) => acc + this.estimateTokenCount(curr), 0) - - // Guard clause: ensure we have enough tokens to justify a cache point - if (totalTokensCovered < minTokensPerPoint) { - return null - } - return { - index: lastUserMessageIndex, - type: "message", - tokensCovered: totalTokensCovered, - } - } - - return null - } - - /** - * Format result without cache points - * - * @returns Cache result without cache points - */ - private formatWithoutCachePoints(): CacheResult { - const systemBlocks: SystemContentBlock[] = this.config.systemPrompt - ? [{ text: this.config.systemPrompt } as unknown as SystemContentBlock] - : [] - - return this.formatResult(systemBlocks, this.messagesToContentBlocks(this.config.messages)) - } -} diff --git a/src/api/transform/cache-strategy/types.ts b/src/api/transform/cache-strategy/types.ts deleted file mode 100644 index 2b5d5736c9..0000000000 --- a/src/api/transform/cache-strategy/types.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import { SystemContentBlock, Message } from "@aws-sdk/client-bedrock-runtime" - -/** - * Information about a model's capabilities and constraints - */ -export interface ModelInfo { - /** Maximum number of tokens the model can generate */ - maxTokens: number - /** Maximum context window size in tokens */ - contextWindow: number - /** Whether the model supports prompt caching */ - supportsPromptCache: boolean - /** Maximum number of cache points supported by the model */ - maxCachePoints: number - /** Minimum number of tokens required for a cache point */ - minTokensPerCachePoint: number - /** Fields that can be cached */ - cachableFields: Array<"system" | "messages" | "tools"> -} - -/** - * Cache point definition - */ -export interface CachePoint { - /** Type of cache point */ - type: "default" -} - -/** - * Result of cache strategy application - */ -export interface CacheResult { - /** System content blocks */ - system: SystemContentBlock[] - /** Message content blocks */ - messages: Message[] - /** Cache point placements for messages (for maintaining consistency across consecutive messages) */ - messageCachePointPlacements?: CachePointPlacement[] -} - -/** - * Represents the position and metadata for a cache point - */ -export interface CachePointPlacement { - /** Where to insert the cache point */ - index: number - /** Type of cache point */ - type: "system" | "message" - /** Number of tokens this cache point covers */ - tokensCovered: number -} - -/** - * Configuration for the caching strategy - */ -export interface CacheStrategyConfig { - /** Model information */ - modelInfo: ModelInfo - /** System prompt text */ - systemPrompt?: string - /** Messages to process */ - messages: Anthropic.Messages.MessageParam[] - /** Whether to use prompt caching */ - usePromptCache: boolean - /** Previous cache point placements (for maintaining consistency across consecutive messages) */ - previousCachePointPlacements?: CachePointPlacement[] -} diff --git a/src/api/transform/gemini-format.ts b/src/api/transform/gemini-format.ts deleted file mode 100644 index 6f24036296..0000000000 --- a/src/api/transform/gemini-format.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import { Content, Part } from "@google/genai" - -type ThoughtSignatureContentBlock = { - type: "thoughtSignature" - thoughtSignature?: string -} - -type ReasoningContentBlock = { - type: "reasoning" - text: string -} - -type ExtendedContentBlockParam = Anthropic.ContentBlockParam | ThoughtSignatureContentBlock | ReasoningContentBlock -type ExtendedAnthropicContent = string | ExtendedContentBlockParam[] - -// Extension type to safely add thoughtSignature to Part -type PartWithThoughtSignature = Part & { - thoughtSignature?: string -} - -function isThoughtSignatureContentBlock(block: ExtendedContentBlockParam): block is ThoughtSignatureContentBlock { - return block.type === "thoughtSignature" -} - -export function convertAnthropicContentToGemini( - content: ExtendedAnthropicContent, - options?: { includeThoughtSignatures?: boolean; toolIdToName?: Map }, -): Part[] { - const includeThoughtSignatures = options?.includeThoughtSignatures ?? true - const toolIdToName = options?.toolIdToName - - // First pass: find thoughtSignature if it exists in the content blocks - let activeThoughtSignature: string | undefined - if (Array.isArray(content)) { - const sigBlock = content.find((block) => isThoughtSignatureContentBlock(block)) as ThoughtSignatureContentBlock - if (sigBlock?.thoughtSignature) { - activeThoughtSignature = sigBlock.thoughtSignature - } - } - - // Determine the signature to attach to function calls. - // If we're in a mode that expects signatures (includeThoughtSignatures is true): - // 1. Use the actual signature if we found one in the history/content. - // 2. Fallback to "skip_thought_signature_validator" if missing (e.g. cross-model history). - let functionCallSignature: string | undefined - if (includeThoughtSignatures) { - functionCallSignature = activeThoughtSignature || "skip_thought_signature_validator" - } - - if (typeof content === "string") { - return [{ text: content }] - } - - const parts = content.flatMap((block): Part | Part[] => { - // Handle thoughtSignature blocks first - if (isThoughtSignatureContentBlock(block)) { - // We process thought signatures globally and attach them to the relevant parts - // or create a placeholder part if no other content exists. - return [] - } - - switch (block.type) { - case "text": - return { text: block.text } - case "image": - if (block.source.type !== "base64") { - throw new Error("Unsupported image source type") - } - - return { inlineData: { data: block.source.data, mimeType: block.source.media_type } } - case "tool_use": - // Gemini 3 validation rules: - // - In a parallel function calling response, only the FIRST functionCall part has a signature. - // - In sequential steps, each step's first functionCall must include its signature. - // When converting from our history, we don't always have enough information to perfectly - // recreate the original per-part distribution, but we can and should avoid attaching the - // signature to every parallel call in a single assistant message. - return { - functionCall: { - name: block.name, - args: block.input as Record, - }, - // Inject the thoughtSignature into the functionCall part if required. - // This is necessary for Gemini 3+ thinking models to validate the tool call. - ...(functionCallSignature ? { thoughtSignature: functionCallSignature } : {}), - } as Part - case "tool_result": { - if (!block.content) { - return [] - } - - // Get tool name from the map (built from tool_use blocks in message history). - // The map must contain the tool name - if it doesn't, this indicates a bug - // where the conversation history is incomplete or tool_use blocks are missing. - const toolName = toolIdToName?.get(block.tool_use_id) - if (!toolName) { - throw new Error( - `Unable to find tool name for tool_use_id "${block.tool_use_id}". ` + - `This indicates the conversation history is missing the corresponding tool_use block. ` + - `Available tool IDs: ${Array.from(toolIdToName?.keys() ?? []).join(", ") || "none"}`, - ) - } - - if (typeof block.content === "string") { - return { - functionResponse: { name: toolName, response: { name: toolName, content: block.content } }, - } - } - - if (!Array.isArray(block.content)) { - return [] - } - - const textParts: string[] = [] - const imageParts: Part[] = [] - - for (const item of block.content) { - if (item.type === "text") { - textParts.push(item.text) - } else if (item.type === "image" && item.source.type === "base64") { - const { data, media_type } = item.source - imageParts.push({ inlineData: { data, mimeType: media_type } }) - } - } - - // Create content text with a note about images if present - const contentText = - textParts.join("\n\n") + (imageParts.length > 0 ? "\n\n(See next part for image)" : "") - - // Return function response followed by any images - return [ - { functionResponse: { name: toolName, response: { name: toolName, content: contentText } } }, - ...imageParts, - ] - } - default: - // Skip unsupported content block types (e.g., "reasoning", "thinking", "redacted_thinking", "document") - // These are typically metadata from other providers that don't need to be sent to Gemini - console.warn(`Skipping unsupported content block type: ${block.type}`) - return [] - } - }) - - // Post-processing: - // 1) Ensure thought signature is attached if required - // 2) For multiple function calls in a single message, keep the signature only on the first - // functionCall part to match Gemini 3 parallel-calling behavior. - if (includeThoughtSignatures && activeThoughtSignature) { - const hasSignature = parts.some((p) => "thoughtSignature" in p) - - if (!hasSignature) { - if (parts.length > 0) { - // Attach to the first part (usually text) - // We use the intersection type to allow adding the property safely - ;(parts[0] as PartWithThoughtSignature).thoughtSignature = activeThoughtSignature - } else { - // Create a placeholder part if no other content exists - const placeholder: PartWithThoughtSignature = { text: "", thoughtSignature: activeThoughtSignature } - parts.push(placeholder) - } - } - } - - if (includeThoughtSignatures) { - let seenFirstFunctionCall = false - for (const part of parts) { - if (part && typeof part === "object" && "functionCall" in part && (part as any).functionCall) { - const partWithSig = part as PartWithThoughtSignature - if (!seenFirstFunctionCall) { - seenFirstFunctionCall = true - } else { - // Remove signature from subsequent function calls in this message. - delete partWithSig.thoughtSignature - } - } - } - } - - return parts -} - -export function convertAnthropicMessageToGemini( - message: Anthropic.Messages.MessageParam, - options?: { includeThoughtSignatures?: boolean; toolIdToName?: Map }, -): Content[] { - const parts = convertAnthropicContentToGemini(message.content, options) - - if (parts.length === 0) { - return [] - } - - return [ - { - role: message.role === "assistant" ? "model" : "user", - parts, - }, - ] -} diff --git a/src/api/transform/model-params.ts b/src/api/transform/model-params.ts index e862c5cf5e..ac04bce37d 100644 --- a/src/api/transform/model-params.ts +++ b/src/api/transform/model-params.ts @@ -33,7 +33,7 @@ type GetModelParamsOptions = { modelId: string model: ModelInfo settings: ProviderSettings - defaultTemperature?: number + defaultTemperature: number } type BaseModelParams = { @@ -77,7 +77,7 @@ export function getModelParams({ modelId, model, settings, - defaultTemperature = 0, + defaultTemperature, }: GetModelParamsOptions): ModelParams { const { modelMaxTokens: customMaxTokens, diff --git a/src/api/transform/reasoning.ts b/src/api/transform/reasoning.ts index e726ce3223..446221d256 100644 --- a/src/api/transform/reasoning.ts +++ b/src/api/transform/reasoning.ts @@ -150,10 +150,20 @@ export const getGeminiReasoning = ({ return undefined } + // Validate that the selected effort is supported by this specific model. + // e.g. gemini-3-pro-preview only supports ["low", "high"] — sending + // "medium" (carried over from a different model's settings) causes errors. + const effortToUse = + Array.isArray(model.supportsReasoningEffort) && + isGeminiThinkingLevel(selectedEffort) && + !model.supportsReasoningEffort.includes(selectedEffort) + ? model.reasoningEffort + : selectedEffort + // Effort-based models on Google GenAI support minimal/low/medium/high levels. - if (!isGeminiThinkingLevel(selectedEffort)) { + if (!effortToUse || !isGeminiThinkingLevel(effortToUse)) { return undefined } - return { thinkingLevel: selectedEffort, includeThoughts: true } + return { thinkingLevel: effortToUse, includeThoughts: true } } diff --git a/src/api/transform/zai-format.ts b/src/api/transform/zai-format.ts deleted file mode 100644 index 79b2e88aeb..0000000000 --- a/src/api/transform/zai-format.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" - -type ContentPartText = OpenAI.Chat.ChatCompletionContentPartText -type ContentPartImage = OpenAI.Chat.ChatCompletionContentPartImage -type UserMessage = OpenAI.Chat.ChatCompletionUserMessageParam -type AssistantMessage = OpenAI.Chat.ChatCompletionAssistantMessageParam -type SystemMessage = OpenAI.Chat.ChatCompletionSystemMessageParam -type ToolMessage = OpenAI.Chat.ChatCompletionToolMessageParam -type Message = OpenAI.Chat.ChatCompletionMessageParam -type AnthropicMessage = Anthropic.Messages.MessageParam - -/** - * Extended assistant message type to support Z.ai's interleaved thinking. - * Z.ai's API returns reasoning_content alongside content and tool_calls, - * and requires it to be passed back in subsequent requests for preserved thinking. - */ -export type ZAiAssistantMessage = AssistantMessage & { - reasoning_content?: string -} - -/** - * Converts Anthropic messages to OpenAI format optimized for Z.ai's GLM-4.7 thinking mode. - * - * Key differences from standard OpenAI format: - * - Preserves reasoning_content on assistant messages for interleaved thinking - * - Text content after tool_results (like environment_details) is merged into the last tool message - * to avoid creating user messages that would cause reasoning_content to be dropped - * - * @param messages Array of Anthropic messages - * @param options Optional configuration for message conversion - * @param options.mergeToolResultText If true, merge text content after tool_results into the last - * tool message instead of creating a separate user message. - * This is critical for Z.ai's interleaved thinking mode. - * @returns Array of OpenAI messages optimized for Z.ai's thinking mode - */ -export function convertToZAiFormat( - messages: AnthropicMessage[], - options?: { mergeToolResultText?: boolean }, -): Message[] { - const result: Message[] = [] - - for (const message of messages) { - // Check if the message has reasoning_content (for Z.ai interleaved thinking) - const messageWithReasoning = message as AnthropicMessage & { reasoning_content?: string } - const reasoningContent = messageWithReasoning.reasoning_content - - if (message.role === "user") { - // Handle user messages - may contain tool_result blocks - if (Array.isArray(message.content)) { - const textParts: string[] = [] - const imageParts: ContentPartImage[] = [] - const toolResults: { tool_use_id: string; content: string }[] = [] - - for (const part of message.content) { - if (part.type === "text") { - textParts.push(part.text) - } else if (part.type === "image") { - imageParts.push({ - type: "image_url", - image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` }, - }) - } else if (part.type === "tool_result") { - // Convert tool_result to OpenAI tool message format - let content: string - if (typeof part.content === "string") { - content = part.content - } else if (Array.isArray(part.content)) { - content = - part.content - ?.map((c) => { - if (c.type === "text") return c.text - if (c.type === "image") return "(image)" - return "" - }) - .join("\n") ?? "" - } else { - content = "" - } - toolResults.push({ - tool_use_id: part.tool_use_id, - content, - }) - } - } - - // Add tool messages first (they must follow assistant tool_use) - for (const toolResult of toolResults) { - const toolMessage: ToolMessage = { - role: "tool", - tool_call_id: toolResult.tool_use_id, - content: toolResult.content, - } - result.push(toolMessage) - } - - // Handle text/image content after tool results - if (textParts.length > 0 || imageParts.length > 0) { - // For Z.ai interleaved thinking: when mergeToolResultText is enabled and we have - // tool results followed by text, merge the text into the last tool message to avoid - // creating a user message that would cause reasoning_content to be dropped. - // This is critical because Z.ai drops all reasoning_content when it sees a user message. - const shouldMergeIntoToolMessage = - options?.mergeToolResultText && toolResults.length > 0 && imageParts.length === 0 - - if (shouldMergeIntoToolMessage) { - // Merge text content into the last tool message - const lastToolMessage = result[result.length - 1] as ToolMessage - if (lastToolMessage?.role === "tool") { - const additionalText = textParts.join("\n") - lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}` - } - } else { - // Standard behavior: add user message with text/image content - let content: UserMessage["content"] - if (imageParts.length > 0) { - const parts: (ContentPartText | ContentPartImage)[] = [] - if (textParts.length > 0) { - parts.push({ type: "text", text: textParts.join("\n") }) - } - parts.push(...imageParts) - content = parts - } else { - content = textParts.join("\n") - } - - // Check if we can merge with the last message - const lastMessage = result[result.length - 1] - if (lastMessage?.role === "user") { - // Merge with existing user message - if (typeof lastMessage.content === "string" && typeof content === "string") { - lastMessage.content += `\n${content}` - } else { - const lastContent = Array.isArray(lastMessage.content) - ? lastMessage.content - : [{ type: "text" as const, text: lastMessage.content || "" }] - const newContent = Array.isArray(content) - ? content - : [{ type: "text" as const, text: content }] - lastMessage.content = [...lastContent, ...newContent] as UserMessage["content"] - } - } else { - result.push({ role: "user", content }) - } - } - } - } else { - // Simple string content - const lastMessage = result[result.length - 1] - if (lastMessage?.role === "user") { - if (typeof lastMessage.content === "string") { - lastMessage.content += `\n${message.content}` - } else { - ;(lastMessage.content as (ContentPartText | ContentPartImage)[]).push({ - type: "text", - text: message.content, - }) - } - } else { - result.push({ role: "user", content: message.content }) - } - } - } else if (message.role === "assistant") { - // Handle assistant messages - may contain tool_use blocks and reasoning blocks - if (Array.isArray(message.content)) { - const textParts: string[] = [] - const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = [] - let extractedReasoning: string | undefined - - for (const part of message.content) { - if (part.type === "text") { - textParts.push(part.text) - } else if (part.type === "tool_use") { - toolCalls.push({ - id: part.id, - type: "function", - function: { - name: part.name, - arguments: JSON.stringify(part.input), - }, - }) - } else if ((part as any).type === "reasoning" && (part as any).text) { - // Extract reasoning from content blocks (Task stores it this way) - extractedReasoning = (part as any).text - } - } - - // Use reasoning from content blocks if not provided at top level - const finalReasoning = reasoningContent || extractedReasoning - - const assistantMessage: ZAiAssistantMessage = { - role: "assistant", - content: textParts.length > 0 ? textParts.join("\n") : null, - ...(toolCalls.length > 0 && { tool_calls: toolCalls }), - // Preserve reasoning_content for Z.ai interleaved thinking - ...(finalReasoning && { reasoning_content: finalReasoning }), - } - - // Check if we can merge with the last message (only if no tool calls) - const lastMessage = result[result.length - 1] - if (lastMessage?.role === "assistant" && !toolCalls.length && !(lastMessage as any).tool_calls) { - // Merge text content - if (typeof lastMessage.content === "string" && typeof assistantMessage.content === "string") { - lastMessage.content += `\n${assistantMessage.content}` - } else if (assistantMessage.content) { - const lastContent = lastMessage.content || "" - lastMessage.content = `${lastContent}\n${assistantMessage.content}` - } - // Preserve reasoning_content from the new message if present - if (finalReasoning) { - ;(lastMessage as ZAiAssistantMessage).reasoning_content = finalReasoning - } - } else { - result.push(assistantMessage) - } - } else { - // Simple string content - const lastMessage = result[result.length - 1] - if (lastMessage?.role === "assistant" && !(lastMessage as any).tool_calls) { - if (typeof lastMessage.content === "string") { - lastMessage.content += `\n${message.content}` - } else { - lastMessage.content = message.content - } - // Preserve reasoning_content from the new message if present - if (reasoningContent) { - ;(lastMessage as ZAiAssistantMessage).reasoning_content = reasoningContent - } - } else { - const assistantMessage: ZAiAssistantMessage = { - role: "assistant", - content: message.content, - ...(reasoningContent && { reasoning_content: reasoningContent }), - } - result.push(assistantMessage) - } - } - } - } - - return result -} diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index b1322c65bf..1eb4e433b9 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -250,7 +250,6 @@ export class NativeToolCallParser { public static processStreamingChunk(id: string, chunk: string): ToolUse | null { const toolCall = this.streamingToolCalls.get(id) if (!toolCall) { - console.warn(`[NativeToolCallParser] Received chunk for unknown tool call: ${id}`) return null } @@ -295,7 +294,6 @@ export class NativeToolCallParser { public static finalizeStreamingToolCall(id: string): ToolUse | McpToolUse | null { const toolCall = this.streamingToolCalls.get(id) if (!toolCall) { - console.warn(`[NativeToolCallParser] Attempting to finalize unknown tool call: ${id}`) return null } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index c22c369b42..c183d51ca5 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -335,7 +335,7 @@ export async function presentAssistantMessage(cline: Task) { // Fetch state early so it's available for toolDescription and validation const state = await cline.providerRef.deref()?.getState() - const { mode, customModes, experiments: stateExperiments } = state ?? {} + const { mode, customModes, experiments: stateExperiments, disabledTools } = state ?? {} const toolDescription = (): string => { switch (block.name) { @@ -625,11 +625,20 @@ export async function presentAssistantMessage(cline: Task) { const includedTools = rawIncludedTools?.map((tool) => resolveToolAlias(tool)) try { + const toolRequirements = + disabledTools?.reduce( + (acc: Record, tool: string) => { + acc[tool] = false + return acc + }, + {} as Record, + ) ?? {} + validateToolUse( block.name as ToolName, mode ?? defaultModeSlug, customModes ?? [], - {}, + toolRequirements, block.params, stateExperiments, includedTools, diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts index 75190985db..10092f71dc 100644 --- a/src/core/condense/__tests__/index.spec.ts +++ b/src/core/condense/__tests__/index.spec.ts @@ -15,6 +15,10 @@ import { cleanupAfterTruncation, extractCommandBlocks, injectSyntheticToolResults, + toolUseToText, + toolResultToText, + convertToolBlocksToText, + transformMessagesForCondensing, } from "../index" vi.mock("../../../api/transform/image-cleaning", () => ({ @@ -1282,3 +1286,306 @@ describe("summarizeConversation with custom settings", () => { ) }) }) + +describe("toolUseToText", () => { + it("should convert tool_use block with object input to text", () => { + const block: Anthropic.Messages.ToolUseBlockParam = { + type: "tool_use", + id: "tool-123", + name: "read_file", + input: { path: "test.ts", encoding: "utf-8" }, + } + + const result = toolUseToText(block) + + expect(result).toBe("[Tool Use: read_file]\npath: test.ts\nencoding: utf-8") + }) + + it("should convert tool_use block with nested object input to text", () => { + const block: Anthropic.Messages.ToolUseBlockParam = { + type: "tool_use", + id: "tool-456", + name: "write_file", + input: { + path: "output.json", + content: { key: "value", nested: { a: 1 } }, + }, + } + + const result = toolUseToText(block) + + expect(result).toContain("[Tool Use: write_file]") + expect(result).toContain("path: output.json") + expect(result).toContain("content:") + expect(result).toContain('"key"') + expect(result).toContain('"value"') + }) + + it("should convert tool_use block with string input to text", () => { + const block: Anthropic.Messages.ToolUseBlockParam = { + type: "tool_use", + id: "tool-789", + name: "execute_command", + input: "ls -la" as unknown as Record, + } + + const result = toolUseToText(block) + + expect(result).toBe("[Tool Use: execute_command]\nls -la") + }) + + it("should handle empty object input", () => { + const block: Anthropic.Messages.ToolUseBlockParam = { + type: "tool_use", + id: "tool-empty", + name: "some_tool", + input: {}, + } + + const result = toolUseToText(block) + + expect(result).toBe("[Tool Use: some_tool]\n") + }) +}) + +describe("toolResultToText", () => { + it("should convert tool_result with string content to text", () => { + const block: Anthropic.Messages.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "tool-123", + content: "File contents here", + } + + const result = toolResultToText(block) + + expect(result).toBe("[Tool Result]\nFile contents here") + }) + + it("should convert tool_result with error flag to text", () => { + const block: Anthropic.Messages.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "tool-456", + content: "File not found", + is_error: true, + } + + const result = toolResultToText(block) + + expect(result).toBe("[Tool Result (Error)]\nFile not found") + }) + + it("should convert tool_result with array content to text", () => { + const block: Anthropic.Messages.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "tool-789", + content: [ + { type: "text", text: "First line" }, + { type: "text", text: "Second line" }, + ], + } + + const result = toolResultToText(block) + + expect(result).toBe("[Tool Result]\nFirst line\nSecond line") + }) + + it("should handle tool_result with image in array content", () => { + const block: Anthropic.Messages.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "tool-img", + content: [ + { type: "text", text: "Screenshot:" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "abc123" } }, + ], + } + + const result = toolResultToText(block) + + expect(result).toBe("[Tool Result]\nScreenshot:\n[Image]") + }) + + it("should handle tool_result with no content", () => { + const block: Anthropic.Messages.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "tool-empty", + } + + const result = toolResultToText(block) + + expect(result).toBe("[Tool Result]") + }) +}) + +describe("convertToolBlocksToText", () => { + it("should return string content unchanged", () => { + const content = "Simple text content" + + const result = convertToolBlocksToText(content) + + expect(result).toBe("Simple text content") + }) + + it("should convert tool_use blocks to text blocks", () => { + const content: Anthropic.Messages.ContentBlockParam[] = [ + { + type: "tool_use", + id: "tool-123", + name: "read_file", + input: { path: "test.ts" }, + }, + ] + + const result = convertToolBlocksToText(content) + + expect(Array.isArray(result)).toBe(true) + expect((result as Anthropic.Messages.ContentBlockParam[])[0].type).toBe("text") + expect((result as Anthropic.Messages.TextBlockParam[])[0].text).toContain("[Tool Use: read_file]") + }) + + it("should convert tool_result blocks to text blocks", () => { + const content: Anthropic.Messages.ContentBlockParam[] = [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: "File contents", + }, + ] + + const result = convertToolBlocksToText(content) + + expect(Array.isArray(result)).toBe(true) + expect((result as Anthropic.Messages.ContentBlockParam[])[0].type).toBe("text") + expect((result as Anthropic.Messages.TextBlockParam[])[0].text).toContain("[Tool Result]") + }) + + it("should preserve non-tool blocks unchanged", () => { + const content: Anthropic.Messages.ContentBlockParam[] = [ + { type: "text", text: "Hello" }, + { + type: "tool_use", + id: "tool-123", + name: "read_file", + input: { path: "test.ts" }, + }, + { type: "text", text: "World" }, + ] + + const result = convertToolBlocksToText(content) + + expect(Array.isArray(result)).toBe(true) + const resultArray = result as Anthropic.Messages.ContentBlockParam[] + expect(resultArray).toHaveLength(3) + expect(resultArray[0]).toEqual({ type: "text", text: "Hello" }) + expect(resultArray[1].type).toBe("text") + expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Use: read_file]") + expect(resultArray[2]).toEqual({ type: "text", text: "World" }) + }) + + it("should handle mixed content with multiple tool blocks", () => { + const content: Anthropic.Messages.ContentBlockParam[] = [ + { + type: "tool_use", + id: "tool-1", + name: "read_file", + input: { path: "a.ts" }, + }, + { + type: "tool_result", + tool_use_id: "tool-1", + content: "contents of a.ts", + }, + ] + + const result = convertToolBlocksToText(content) + + expect(Array.isArray(result)).toBe(true) + const resultArray = result as Anthropic.Messages.ContentBlockParam[] + expect(resultArray).toHaveLength(2) + expect((resultArray[0] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Use: read_file]") + expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Result]") + expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("contents of a.ts") + }) +}) + +describe("transformMessagesForCondensing", () => { + it("should transform all messages with tool blocks to text", () => { + const messages = [ + { role: "user" as const, content: "Hello" }, + { + role: "assistant" as const, + content: [ + { + type: "tool_use" as const, + id: "tool-1", + name: "read_file", + input: { path: "test.ts" }, + }, + ], + }, + { + role: "user" as const, + content: [ + { + type: "tool_result" as const, + tool_use_id: "tool-1", + content: "file contents", + }, + ], + }, + ] + + const result = transformMessagesForCondensing(messages) + + expect(result).toHaveLength(3) + expect(result[0].content).toBe("Hello") + expect(Array.isArray(result[1].content)).toBe(true) + expect((result[1].content as any[])[0].type).toBe("text") + expect((result[1].content as any[])[0].text).toContain("[Tool Use: read_file]") + expect(Array.isArray(result[2].content)).toBe(true) + expect((result[2].content as any[])[0].type).toBe("text") + expect((result[2].content as any[])[0].text).toContain("[Tool Result]") + }) + + it("should preserve message role and other properties", () => { + const messages = [ + { + role: "assistant" as const, + content: [ + { + type: "tool_use" as const, + id: "tool-1", + name: "execute", + input: { cmd: "ls" }, + }, + ], + }, + ] + + const result = transformMessagesForCondensing(messages) + + expect(result[0].role).toBe("assistant") + }) + + it("should handle empty messages array", () => { + const result = transformMessagesForCondensing([]) + + expect(result).toEqual([]) + }) + + it("should not mutate original messages", () => { + const originalContent = [ + { + type: "tool_use" as const, + id: "tool-1", + name: "read_file", + input: { path: "test.ts" }, + }, + ] + const messages = [{ role: "assistant" as const, content: originalContent }] + + transformMessagesForCondensing(messages) + + // Original should still have tool_use type + expect(messages[0].content[0].type).toBe("tool_use") + }) +}) diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index 5a65f0a96f..0438bf6bcb 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -14,6 +14,100 @@ import { generateFoldedFileContext } from "./foldedFileContext" export type { FoldedFileContextResult, FoldedFileContextOptions } from "./foldedFileContext" +/** + * Converts a tool_use block to a text representation. + * This allows the conversation to be summarized without requiring the tools parameter. + */ +export function toolUseToText(block: Anthropic.Messages.ToolUseBlockParam): string { + let input: string + if (typeof block.input === "object" && block.input !== null) { + input = Object.entries(block.input) + .map(([key, value]) => { + const formattedValue = + typeof value === "object" && value !== null ? JSON.stringify(value, null, 2) : String(value) + return `${key}: ${formattedValue}` + }) + .join("\n") + } else { + input = String(block.input) + } + return `[Tool Use: ${block.name}]\n${input}` +} + +/** + * Converts a tool_result block to a text representation. + * This allows the conversation to be summarized without requiring the tools parameter. + */ +export function toolResultToText(block: Anthropic.Messages.ToolResultBlockParam): string { + const errorSuffix = block.is_error ? " (Error)" : "" + if (typeof block.content === "string") { + return `[Tool Result${errorSuffix}]\n${block.content}` + } else if (Array.isArray(block.content)) { + const contentText = block.content + .map((contentBlock) => { + if (contentBlock.type === "text") { + return contentBlock.text + } + if (contentBlock.type === "image") { + return "[Image]" + } + // Handle any other content block types + return `[${(contentBlock as { type: string }).type}]` + }) + .join("\n") + return `[Tool Result${errorSuffix}]\n${contentText}` + } + return `[Tool Result${errorSuffix}]` +} + +/** + * Converts all tool_use and tool_result blocks in a message's content to text representations. + * This is necessary for providers like Bedrock that require the tools parameter when tool blocks are present. + * By converting to text, we can send the conversation for summarization without the tools parameter. + * + * @param content - The message content (string or array of content blocks) + * @returns The transformed content with tool blocks converted to text blocks + */ +export function convertToolBlocksToText( + content: string | Anthropic.Messages.ContentBlockParam[], +): string | Anthropic.Messages.ContentBlockParam[] { + if (typeof content === "string") { + return content + } + + return content.map((block) => { + if (block.type === "tool_use") { + return { + type: "text" as const, + text: toolUseToText(block), + } + } + if (block.type === "tool_result") { + return { + type: "text" as const, + text: toolResultToText(block), + } + } + return block + }) +} + +/** + * Transforms all messages by converting tool_use and tool_result blocks to text representations. + * This ensures the conversation can be sent for summarization without requiring the tools parameter. + * + * @param messages - The messages to transform + * @returns The transformed messages with tool blocks converted to text + */ +export function transformMessagesForCondensing< + T extends { role: string; content: string | Anthropic.Messages.ContentBlockParam[] }, +>(messages: T[]): T[] { + return messages.map((msg) => ({ + ...msg, + content: convertToolBlocksToText(msg.content), + })) +} + export const MIN_CONDENSE_THRESHOLD = 5 // Minimum percentage of context window to trigger condensing export const MAX_CONDENSE_THRESHOLD = 100 // Maximum percentage of context window to trigger condensing @@ -213,10 +307,16 @@ export async function summarizeConversation(options: SummarizeConversationOption // (e.g., when user triggers condense after receiving attempt_completion but before responding) const messagesWithToolResults = injectSyntheticToolResults(messagesToSummarize) - const requestMessages = maybeRemoveImageBlocks([...messagesWithToolResults, finalRequestMessage], apiHandler).map( - ({ role, content }) => ({ role, content }), + // Transform tool_use and tool_result blocks to text representations. + // This is necessary because some providers (like Bedrock via LiteLLM) require the `tools` parameter + // when tool blocks are present. By converting them to text, we can send the conversation for + // summarization without needing to pass the tools parameter. + const messagesWithTextToolBlocks = transformMessagesForCondensing( + maybeRemoveImageBlocks([...messagesWithToolResults, finalRequestMessage], apiHandler), ) + const requestMessages = messagesWithTextToolBlocks.map(({ role, content }) => ({ role, content })) + // Note: this doesn't need to be a stream, consider using something like apiHandler.completePrompt const promptToUse = SUMMARY_PROMPT diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 87ce79a325..2825d1c945 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -16,6 +16,7 @@ import { globalSettingsSchema, isSecretStateKey, isProviderName, + isRetiredProvider, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -223,14 +224,16 @@ export class ContextProxy { } /** - * Migrates invalid/removed apiProvider values by clearing them from storage. - * This handles cases where a user had a provider selected that was later removed - * from the extension (e.g., "glama"). + * Migrates unknown apiProvider values by clearing them from storage. + * Retired providers are preserved so users can keep historical configuration. */ private async migrateInvalidApiProvider() { try { const apiProvider = this.stateCache.apiProvider - if (apiProvider !== undefined && !isProviderName(apiProvider)) { + const isKnownProvider = + typeof apiProvider === "string" && (isProviderName(apiProvider) || isRetiredProvider(apiProvider)) + + if (apiProvider !== undefined && !isKnownProvider) { logger.info(`[ContextProxy] Found invalid provider "${apiProvider}" in storage - clearing it`) // Clear the invalid provider from both cache and storage this.stateCache.apiProvider = undefined @@ -439,8 +442,8 @@ export class ContextProxy { } /** - * Sanitizes provider values by resetting invalid/removed apiProvider values. - * This prevents schema validation errors for removed providers. + * Sanitizes provider values by resetting unknown apiProvider values. + * Active and retired providers are preserved. */ private sanitizeProviderValues(values: RooCodeSettings): RooCodeSettings { // Remove legacy Claude Code CLI wrapper keys that may still exist in global state. @@ -456,7 +459,11 @@ export class ContextProxy { } } - if (values.apiProvider !== undefined && !isProviderName(values.apiProvider)) { + const isKnownProvider = + typeof values.apiProvider === "string" && + (isProviderName(values.apiProvider) || isRetiredProvider(values.apiProvider)) + + if (values.apiProvider !== undefined && !isKnownProvider) { logger.info(`[ContextProxy] Sanitizing invalid provider "${values.apiProvider}" - resetting to undefined`) // Return a new values object without the invalid apiProvider const { apiProvider, ...restValues } = sanitizedValues diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 3024540b67..6088bd68fe 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -12,6 +12,7 @@ import { getModelId, type ProviderName, isProviderName, + isRetiredProvider, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -359,8 +360,14 @@ export class ProviderSettingsManager { const existingId = providerProfiles.apiConfigs[name]?.id const id = config.id || existingId || this.generateId() - // Filter out settings from other providers. - const filteredConfig = discriminatedProviderSettingsWithIdSchema.parse(config) + // For active providers, filter out settings from other providers. + // For retired providers, preserve full profile fields (including legacy + // provider-specific keys) to avoid data loss — passthrough() keeps + // unknown keys that strict parse() would strip. + const filteredConfig = + typeof config.apiProvider === "string" && isRetiredProvider(config.apiProvider) + ? providerSettingsWithIdSchema.passthrough().parse(config) + : discriminatedProviderSettingsWithIdSchema.parse(config) providerProfiles.apiConfigs[name] = { ...filteredConfig, id } await this.store(providerProfiles) return id @@ -507,7 +514,14 @@ export class ProviderSettingsManager { const profiles = providerProfilesSchema.parse(await this.load()) const configs = profiles.apiConfigs for (const name in configs) { - // Avoid leaking properties from other providers. + const apiProvider = configs[name].apiProvider + + if (typeof apiProvider === "string" && isRetiredProvider(apiProvider)) { + // Preserve retired-provider profiles as-is to prevent dropping legacy fields. + continue + } + + // Avoid leaking properties from other active providers. configs[name] = discriminatedProviderSettingsWithIdSchema.parse(configs[name]) // If it has no apiProvider, skip filtering @@ -582,7 +596,21 @@ export class ProviderSettingsManager { // First, sanitize invalid apiProvider values before parsing // This handles removed providers (like "glama") gracefully const sanitizedConfig = this.sanitizeProviderConfig(apiConfig) - const result = providerSettingsWithIdSchema.safeParse(sanitizedConfig) + + // For retired providers, use passthrough() to preserve legacy + // provider-specific fields (e.g. groqApiKey, deepInfraModelId) + // that strict parse() would strip. + const providerValue = + typeof sanitizedConfig === "object" && + sanitizedConfig !== null && + "apiProvider" in sanitizedConfig + ? (sanitizedConfig as Record).apiProvider + : undefined + const schema = + typeof providerValue === "string" && isRetiredProvider(providerValue) + ? providerSettingsWithIdSchema.passthrough() + : providerSettingsWithIdSchema + const result = schema.safeParse(sanitizedConfig) return result.success ? { ...acc, [key]: result.data } : acc }, {} as Record, @@ -607,7 +635,8 @@ export class ProviderSettingsManager { } /** - * Sanitizes a provider config by resetting invalid/removed apiProvider values. + * Sanitizes a provider config by resetting unknown apiProvider values. + * Retired providers are preserved. * This handles cases where a user had a provider selected that was later removed * from the extension (e.g., "glama"). */ @@ -618,10 +647,15 @@ export class ProviderSettingsManager { const config = apiConfig as Record - // Check if apiProvider is set and if it's still valid - if (config.apiProvider !== undefined && !isProviderName(config.apiProvider)) { + const apiProvider = config.apiProvider + + // Check if apiProvider is set and if it's still recognized (active or retired) + if ( + apiProvider !== undefined && + (typeof apiProvider !== "string" || (!isProviderName(apiProvider) && !isRetiredProvider(apiProvider))) + ) { console.log( - `[ProviderSettingsManager] Sanitizing invalid provider "${config.apiProvider}" - resetting to undefined`, + `[ProviderSettingsManager] Sanitizing unknown provider "${config.apiProvider}" - resetting to undefined`, ) // Return a new config object without the invalid apiProvider // This effectively resets the profile so the user can select a valid provider diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 2060260c6c..7c1d2a6e3c 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -424,7 +424,7 @@ describe("ContextProxy", () => { it("should reinitialize caches after reset", async () => { // Spy on initialization methods - const initializeSpy = vi.spyOn(proxy as any, "initialize") + const initializeSpy = vi.spyOn(proxy, "initialize") // Reset all state await proxy.resetAllState() @@ -452,6 +452,25 @@ describe("ContextProxy", () => { expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", undefined) }) + it("should not clear retired apiProvider from storage during initialization", async () => { + // Reset and create a new proxy with retired provider in state + vi.clearAllMocks() + mockGlobalState.get.mockImplementation((key: string) => { + if (key === "apiProvider") { + return "groq" // Retired provider + } + return undefined + }) + + const proxyWithRetiredProvider = new ContextProxy(mockContext) + await proxyWithRetiredProvider.initialize() + + // Should NOT have called update for apiProvider (retired should be preserved) + const updateCalls = mockGlobalState.update.mock.calls + const apiProviderUpdateCalls = updateCalls.filter((call: unknown[]) => call[0] === "apiProvider") + expect(apiProviderUpdateCalls).toHaveLength(0) + }) + it("should not modify valid apiProvider during initialization", async () => { // Reset and create a new proxy with valid provider in state vi.clearAllMocks() @@ -467,18 +486,29 @@ describe("ContextProxy", () => { // Should NOT have called update for apiProvider (it's valid) const updateCalls = mockGlobalState.update.mock.calls - const apiProviderUpdateCalls = updateCalls.filter((call: any[]) => call[0] === "apiProvider") + const apiProviderUpdateCalls = updateCalls.filter((call: unknown[]) => call[0] === "apiProvider") expect(apiProviderUpdateCalls.length).toBe(0) }) }) describe("getProviderSettings", () => { it("should sanitize invalid apiProvider before parsing", async () => { - // Set an invalid provider in state - await proxy.updateGlobalState("apiProvider", "invalid-removed-provider" as any) - await proxy.updateGlobalState("apiModelId", "some-model") + // Reset and create a new proxy with an unknown provider in state + vi.clearAllMocks() + mockGlobalState.get.mockImplementation((key: string) => { + if (key === "apiProvider") { + return "invalid-removed-provider" + } + if (key === "apiModelId") { + return "some-model" + } + return undefined + }) - const settings = proxy.getProviderSettings() + const proxyWithInvalidProvider = new ContextProxy(mockContext) + await proxyWithInvalidProvider.initialize() + + const settings = proxyWithInvalidProvider.getProviderSettings() // The invalid apiProvider should be sanitized (removed) expect(settings.apiProvider).toBeUndefined() @@ -486,6 +516,22 @@ describe("ContextProxy", () => { expect(settings.apiModelId).toBe("some-model") }) + it("should preserve retired apiProvider and provider fields", async () => { + await proxy.setValues({ + apiProvider: "groq", + apiModelId: "llama3-70b", + openAiBaseUrl: "https://api.retired-provider.example/v1", + apiKey: "retired-provider-key", + }) + + const settings = proxy.getProviderSettings() + + expect(settings.apiProvider).toBe("groq") + expect(settings.apiModelId).toBe("llama3-70b") + expect(settings.openAiBaseUrl).toBe("https://api.retired-provider.example/v1") + expect(settings.apiKey).toBe("retired-provider-key") + }) + it("should pass through valid apiProvider", async () => { // Set a valid provider in state await proxy.updateGlobalState("apiProvider", "anthropic") diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index e233fc913c..3f6b4f7847 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -566,6 +566,47 @@ describe("ProviderSettingsManager", () => { "Failed to save config: Error: Failed to write provider profiles to secrets: Error: Storage failed", ) }) + + it("should preserve full fields including legacy provider-specific keys when saving retired provider profiles", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "default", + apiConfigs: { + default: {}, + }, + modeApiConfigs: { + code: "default", + architect: "default", + ask: "default", + }, + }), + ) + + // Include a legacy provider-specific field (groqApiKey) that is no + // longer in the schema — passthrough() must keep it. + const retiredConfig = { + apiProvider: "groq", + apiKey: "legacy-key", + apiModelId: "legacy-model", + openAiBaseUrl: "https://legacy.example/v1", + openAiApiKey: "legacy-openai-key", + modelMaxTokens: 4096, + groqApiKey: "legacy-groq-specific-key", + } as ProviderSettings + + await providerSettingsManager.saveConfig("retired", retiredConfig) + + const storedConfig = JSON.parse(mockSecrets.store.mock.calls[mockSecrets.store.mock.calls.length - 1][1]) + expect(storedConfig.apiConfigs.retired.apiProvider).toBe("groq") + expect(storedConfig.apiConfigs.retired.apiKey).toBe("legacy-key") + expect(storedConfig.apiConfigs.retired.apiModelId).toBe("legacy-model") + expect(storedConfig.apiConfigs.retired.openAiBaseUrl).toBe("https://legacy.example/v1") + expect(storedConfig.apiConfigs.retired.openAiApiKey).toBe("legacy-openai-key") + expect(storedConfig.apiConfigs.retired.modelMaxTokens).toBe(4096) + // Verify legacy provider-specific field is preserved via passthrough + expect(storedConfig.apiConfigs.retired.groqApiKey).toBe("legacy-groq-specific-key") + expect(storedConfig.apiConfigs.retired.id).toBeTruthy() + }) }) describe("DeleteConfig", () => { @@ -695,9 +736,9 @@ describe("ProviderSettingsManager", () => { ) }) - it("should sanitize invalid/removed providers by resetting apiProvider to undefined", async () => { + it("should sanitize unknown providers by resetting apiProvider to undefined", async () => { // This tests the fix for the infinite loop issue when a provider is removed - const configWithRemovedProvider = { + const configWithUnknownProvider = { currentApiConfigName: "valid", apiConfigs: { valid: { @@ -706,8 +747,8 @@ describe("ProviderSettingsManager", () => { apiModelId: "claude-3-opus-20240229", id: "valid-id", }, - removedProvider: { - // Provider that was removed from the extension (e.g., "invalid-removed-provider") + unknownProvider: { + // Provider value that is neither active nor retired. id: "removed-id", apiProvider: "invalid-removed-provider", apiKey: "some-key", @@ -722,7 +763,7 @@ describe("ProviderSettingsManager", () => { }, } - mockSecrets.get.mockResolvedValue(JSON.stringify(configWithRemovedProvider)) + mockSecrets.get.mockResolvedValue(JSON.stringify(configWithUnknownProvider)) await providerSettingsManager.initialize() @@ -735,11 +776,55 @@ describe("ProviderSettingsManager", () => { expect(storedConfig.apiConfigs.valid).toBeDefined() expect(storedConfig.apiConfigs.valid.apiProvider).toBe("anthropic") - // The config with the removed provider should have its apiProvider reset to undefined + // The config with the unknown provider should have its apiProvider reset to undefined // but still be present (not filtered out entirely) - expect(storedConfig.apiConfigs.removedProvider).toBeDefined() - expect(storedConfig.apiConfigs.removedProvider.apiProvider).toBeUndefined() - expect(storedConfig.apiConfigs.removedProvider.id).toBe("removed-id") + expect(storedConfig.apiConfigs.unknownProvider).toBeDefined() + expect(storedConfig.apiConfigs.unknownProvider.apiProvider).toBeUndefined() + expect(storedConfig.apiConfigs.unknownProvider.id).toBe("removed-id") + }) + + it("should preserve retired providers and their fields including legacy provider-specific keys during initialize", async () => { + const configWithRetiredProvider = { + currentApiConfigName: "retiredProvider", + apiConfigs: { + retiredProvider: { + id: "retired-id", + apiProvider: "groq", + apiKey: "legacy-key", + apiModelId: "legacy-model", + openAiBaseUrl: "https://legacy.example/v1", + modelMaxTokens: 1024, + // Legacy provider-specific field no longer in schema + groqApiKey: "legacy-groq-key", + }, + }, + migrations: { + rateLimitSecondsMigrated: false, + openAiHeadersMigrated: true, + consecutiveMistakeLimitMigrated: true, + todoListEnabledMigrated: true, + claudeCodeLegacySettingsMigrated: true, + }, + } + + mockGlobalState.get.mockResolvedValue(0) + mockSecrets.get.mockResolvedValue(JSON.stringify(configWithRetiredProvider)) + + await providerSettingsManager.initialize() + + const storeCalls = mockSecrets.store.mock.calls + expect(storeCalls.length).toBeGreaterThan(0) + const finalStoredConfigJson = storeCalls[storeCalls.length - 1][1] + const storedConfig = JSON.parse(finalStoredConfigJson) + + expect(storedConfig.apiConfigs.retiredProvider).toBeDefined() + expect(storedConfig.apiConfigs.retiredProvider.apiProvider).toBe("groq") + expect(storedConfig.apiConfigs.retiredProvider.apiKey).toBe("legacy-key") + expect(storedConfig.apiConfigs.retiredProvider.apiModelId).toBe("legacy-model") + expect(storedConfig.apiConfigs.retiredProvider.openAiBaseUrl).toBe("https://legacy.example/v1") + expect(storedConfig.apiConfigs.retiredProvider.modelMaxTokens).toBe(1024) + // Verify legacy provider-specific field is preserved via passthrough + expect(storedConfig.apiConfigs.retiredProvider.groqApiKey).toBe("legacy-groq-key") }) it("should sanitize invalid providers and remove non-object profiles during load", async () => { @@ -791,6 +876,36 @@ describe("ProviderSettingsManager", () => { }) }) + describe("Export", () => { + it("should preserve retired provider profiles with full fields", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "retired", + apiConfigs: { + retired: { + id: "retired-id", + apiProvider: "groq", + apiKey: "legacy-key", + apiModelId: "legacy-model", + openAiBaseUrl: "https://legacy.example/v1", + modelMaxTokens: 4096, + modelMaxThinkingTokens: 2048, + }, + }, + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const exported = await providerSettingsManager.export() + + expect(exported.apiConfigs.retired.apiProvider).toBe("groq") + expect(exported.apiConfigs.retired.apiKey).toBe("legacy-key") + expect(exported.apiConfigs.retired.apiModelId).toBe("legacy-model") + expect(exported.apiConfigs.retired.openAiBaseUrl).toBe("https://legacy.example/v1") + expect(exported.apiConfigs.retired.modelMaxTokens).toBe(4096) + expect(exported.apiConfigs.retired.modelMaxThinkingTokens).toBe(2048) + }) + }) + describe("ResetAllConfigs", () => { it("should delete all stored configs", async () => { // Setup initial config diff --git a/src/core/context/context-management/__tests__/context-error-handling.test.ts b/src/core/context/context-management/__tests__/context-error-handling.test.ts index d26ac837f0..8ba431b05c 100644 --- a/src/core/context/context-management/__tests__/context-error-handling.test.ts +++ b/src/core/context/context-management/__tests__/context-error-handling.test.ts @@ -193,37 +193,6 @@ describe("checkContextWindowExceededError", () => { }) }) - describe("Cerebras errors", () => { - it("should detect Cerebras context window error", () => { - const error = { - status: 400, - message: "Please reduce the length of the messages or completion", - } - - expect(checkContextWindowExceededError(error)).toBe(true) - }) - - it("should detect Cerebras error with nested structure", () => { - const error = { - error: { - status: 400, - message: "Please reduce the length of the messages or completion", - }, - } - - expect(checkContextWindowExceededError(error)).toBe(true) - }) - - it("should not detect non-context Cerebras errors", () => { - const error = { - status: 400, - message: "Invalid request parameters", - } - - expect(checkContextWindowExceededError(error)).toBe(false) - }) - }) - describe("Edge cases", () => { it("should handle null input", () => { expect(checkContextWindowExceededError(null)).toBe(false) @@ -317,13 +286,6 @@ describe("checkContextWindowExceededError", () => { }, } expect(checkContextWindowExceededError(error2)).toBe(true) - - // This error should be detected by Cerebras check - const error3 = { - status: 400, - message: "Please reduce the length of the messages or completion", - } - expect(checkContextWindowExceededError(error3)).toBe(true) }) }) }) diff --git a/src/core/context/context-management/context-error-handling.ts b/src/core/context/context-management/context-error-handling.ts index 006d7b1607..6cfe993f95 100644 --- a/src/core/context/context-management/context-error-handling.ts +++ b/src/core/context/context-management/context-error-handling.ts @@ -4,8 +4,7 @@ export function checkContextWindowExceededError(error: unknown): boolean { return ( checkIsOpenAIContextWindowError(error) || checkIsOpenRouterContextWindowError(error) || - checkIsAnthropicContextWindowError(error) || - checkIsCerebrasContextWindowError(error) + checkIsAnthropicContextWindowError(error) ) } @@ -94,21 +93,3 @@ function checkIsAnthropicContextWindowError(response: unknown): boolean { return false } } - -function checkIsCerebrasContextWindowError(response: unknown): boolean { - try { - // Type guard to safely access properties - if (!response || typeof response !== "object") { - return false - } - - // Use type assertions with proper checks - const res = response as Record - const status = res.status ?? res.code ?? res.error?.status ?? res.response?.status - const message: string = String(res.message || res.error?.message || "") - - return String(status) === "400" && message.includes("Please reduce the length of the messages or completion") - } catch { - return false - } -} diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index db5a0cd088..4de2e20e37 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -221,13 +221,10 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo language: language ?? formatLanguage(vscode.env.language), }) - const toolFormat = "native" - details += `\n\n# Current Mode\n` details += `${currentMode}\n` details += `${modeDetails.name}\n` details += `${modelId}\n` - details += `${toolFormat}\n` // Add browser session status - Only show when active to prevent cluttering context const isBrowserActive = cline.browserSession.isSessionActive() diff --git a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts index ea3b31ee93..68fa2d37f5 100644 --- a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts +++ b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts @@ -1587,4 +1587,136 @@ describe("Rules directory reading", () => { const result = await loadRuleFiles("/fake/path") expect(result).toBe("\n# Rules from .roorules:\nfallback content\n") }) + + it("should load AGENTS.local.md alongside AGENTS.md for personal overrides", async () => { + // Simulate no .roo/rules-test-mode directory + statMock.mockRejectedValueOnce({ code: "ENOENT" }) + + // Mock lstat to indicate both AGENTS.md and AGENTS.local.md exist (not symlinks) + lstatMock.mockImplementation((filePath: PathLike) => { + const pathStr = filePath.toString() + if (pathStr.endsWith("AGENTS.md") || pathStr.endsWith("AGENTS.local.md")) { + return Promise.resolve({ + isSymbolicLink: vi.fn().mockReturnValue(false), + }) + } + return Promise.reject({ code: "ENOENT" }) + }) + + readFileMock.mockImplementation((filePath: PathLike) => { + const pathStr = filePath.toString() + if (pathStr.endsWith("AGENTS.local.md")) { + return Promise.resolve("Local overrides from AGENTS.local.md") + } + if (pathStr.endsWith("AGENTS.md")) { + return Promise.resolve("Base rules from AGENTS.md") + } + return Promise.reject({ code: "ENOENT" }) + }) + + const result = await addCustomInstructions( + "mode instructions", + "global instructions", + "/fake/path", + "test-mode", + { + settings: { + todoListEnabled: true, + useAgentRules: true, + newTaskRequireTodos: false, + }, + }, + ) + + // Should contain both AGENTS.md and AGENTS.local.md content + expect(result).toContain("# Agent Rules Standard (AGENTS.md):") + expect(result).toContain("Base rules from AGENTS.md") + expect(result).toContain("# Agent Rules Local (AGENTS.local.md):") + expect(result).toContain("Local overrides from AGENTS.local.md") + }) + + it("should load AGENTS.local.md even when base AGENTS.md does not exist", async () => { + // Simulate no .roo/rules-test-mode directory + statMock.mockRejectedValueOnce({ code: "ENOENT" }) + + // Mock lstat to indicate only AGENTS.local.md exists (no base file) + lstatMock.mockImplementation((filePath: PathLike) => { + const pathStr = filePath.toString() + if (pathStr.endsWith("AGENTS.local.md")) { + return Promise.resolve({ + isSymbolicLink: vi.fn().mockReturnValue(false), + }) + } + return Promise.reject({ code: "ENOENT" }) + }) + + readFileMock.mockImplementation((filePath: PathLike) => { + const pathStr = filePath.toString() + if (pathStr.endsWith("AGENTS.local.md")) { + return Promise.resolve("Local overrides without base file") + } + return Promise.reject({ code: "ENOENT" }) + }) + + const result = await addCustomInstructions( + "mode instructions", + "global instructions", + "/fake/path", + "test-mode", + { + settings: { + todoListEnabled: true, + useAgentRules: true, + newTaskRequireTodos: false, + }, + }, + ) + + // Should contain AGENTS.local.md content even without base AGENTS.md + expect(result).toContain("# Agent Rules Local (AGENTS.local.md):") + expect(result).toContain("Local overrides without base file") + }) + + it("should load AGENTS.md without .local.md when local file does not exist", async () => { + // Simulate no .roo/rules-test-mode directory + statMock.mockRejectedValueOnce({ code: "ENOENT" }) + + // Mock lstat to indicate only AGENTS.md exists (no local override) + lstatMock.mockImplementation((filePath: PathLike) => { + const pathStr = filePath.toString() + if (pathStr.endsWith("AGENTS.md")) { + return Promise.resolve({ + isSymbolicLink: vi.fn().mockReturnValue(false), + }) + } + return Promise.reject({ code: "ENOENT" }) + }) + + readFileMock.mockImplementation((filePath: PathLike) => { + const pathStr = filePath.toString() + if (pathStr.endsWith("AGENTS.md")) { + return Promise.resolve("Base rules from AGENTS.md only") + } + return Promise.reject({ code: "ENOENT" }) + }) + + const result = await addCustomInstructions( + "mode instructions", + "global instructions", + "/fake/path", + "test-mode", + { + settings: { + todoListEnabled: true, + useAgentRules: true, + newTaskRequireTodos: false, + }, + }, + ) + + // Should contain only AGENTS.md content + expect(result).toContain("# Agent Rules Standard (AGENTS.md):") + expect(result).toContain("Base rules from AGENTS.md only") + expect(result).not.toContain("AGENTS.local.md") + }) }) diff --git a/src/core/prompts/sections/custom-instructions.ts b/src/core/prompts/sections/custom-instructions.ts index 8eee0a0998..46cf1bf1f9 100644 --- a/src/core/prompts/sections/custom-instructions.ts +++ b/src/core/prompts/sections/custom-instructions.ts @@ -238,9 +238,48 @@ export async function loadRuleFiles(cwd: string, enableSubfolderRules: boolean = return "" } +/** + * Read content from an agent rules file (AGENTS.md, AGENT.md, etc.) + * Handles symlink resolution. + * + * @param filePath - Full path to the agent rules file + * @returns File content or empty string if file doesn't exist + */ +async function readAgentRulesFile(filePath: string): Promise { + let resolvedPath = filePath + + // Check if file exists and handle symlinks + try { + const stats = await fs.lstat(filePath) + if (stats.isSymbolicLink()) { + // Create a temporary fileInfo array to use with resolveSymLink + const fileInfo: Array<{ + originalPath: string + resolvedPath: string + }> = [] + + // Use the existing resolveSymLink function to handle symlink resolution + await resolveSymLink(filePath, fileInfo, 0) + + // Extract the resolved path from fileInfo + if (fileInfo.length > 0) { + resolvedPath = fileInfo[0].resolvedPath + } + } + } catch (err) { + // If lstat fails (file doesn't exist), return empty + return "" + } + + // Read the content from the resolved path + return safeReadFile(resolvedPath) +} + /** * Load AGENTS.md or AGENT.md file from a specific directory * Checks for both AGENTS.md (standard) and AGENT.md (alternative) for compatibility + * Also loads AGENTS.local.md for personal overrides (not checked in to version control) + * AGENTS.local.md can be loaded even if AGENTS.md doesn't exist * * @param directory - Directory to check for AGENTS.md * @param showPath - Whether to include the directory path in the header @@ -253,50 +292,46 @@ async function loadAgentRulesFileFromDirectory( ): Promise { // Try both filenames - AGENTS.md (standard) first, then AGENT.md (alternative) const filenames = ["AGENTS.md", "AGENT.md"] + const results: string[] = [] + const displayPath = cwd ? path.relative(cwd, directory) : directory for (const filename of filenames) { try { const agentPath = path.join(directory, filename) - let resolvedPath = agentPath + const content = await readAgentRulesFile(agentPath) - // Check if file exists and handle symlinks - try { - const stats = await fs.lstat(agentPath) - if (stats.isSymbolicLink()) { - // Create a temporary fileInfo array to use with resolveSymLink - const fileInfo: Array<{ - originalPath: string - resolvedPath: string - }> = [] - - // Use the existing resolveSymLink function to handle symlink resolution - await resolveSymLink(agentPath, fileInfo, 0) - - // Extract the resolved path from fileInfo - if (fileInfo.length > 0) { - resolvedPath = fileInfo[0].resolvedPath - } - } - } catch (err) { - // If lstat fails (file doesn't exist), try next filename - continue - } - - // Read the content from the resolved path - const content = await safeReadFile(resolvedPath) if (content) { // Compute relative path for display if cwd is provided - const displayPath = cwd ? path.relative(cwd, directory) : directory const header = showPath ? `# Agent Rules Standard (${filename}) from ${displayPath}:` : `# Agent Rules Standard (${filename}):` - return `${header}\n${content}` + results.push(`${header}\n${content}`) + + // Found a standard file, don't check alternative + break } } catch (err) { // Silently ignore errors - agent rules files are optional } } - return "" + + // Always try to load AGENTS.local.md for personal overrides (even if AGENTS.md doesn't exist) + try { + const localFilename = "AGENTS.local.md" + const localPath = path.join(directory, localFilename) + const localContent = await readAgentRulesFile(localPath) + + if (localContent) { + const localHeader = showPath + ? `# Agent Rules Local (${localFilename}) from ${displayPath}:` + : `# Agent Rules Local (${localFilename}):` + results.push(`${localHeader}\n${localContent}`) + } + } catch (err) { + // Silently ignore errors - local agent rules file is optional + } + + return results.join("\n\n") } /** diff --git a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts new file mode 100644 index 0000000000..8c6d7ede17 --- /dev/null +++ b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts @@ -0,0 +1,80 @@ +// npx vitest run core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts + +import type OpenAI from "openai" + +import { filterNativeToolsForMode } from "../filter-tools-for-mode" + +function makeTool(name: string): OpenAI.Chat.ChatCompletionTool { + return { + type: "function", + function: { + name, + description: `${name} tool`, + parameters: { type: "object", properties: {} }, + }, + } as OpenAI.Chat.ChatCompletionTool +} + +describe("filterNativeToolsForMode - disabledTools", () => { + const nativeTools: OpenAI.Chat.ChatCompletionTool[] = [ + makeTool("execute_command"), + makeTool("read_file"), + makeTool("write_to_file"), + makeTool("browser_action"), + makeTool("apply_diff"), + ] + + it("removes tools listed in settings.disabledTools", () => { + const settings = { + disabledTools: ["execute_command", "browser_action"], + } + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + + const resultNames = result.map((t) => (t as any).function.name) + expect(resultNames).not.toContain("execute_command") + expect(resultNames).not.toContain("browser_action") + expect(resultNames).toContain("read_file") + expect(resultNames).toContain("write_to_file") + expect(resultNames).toContain("apply_diff") + }) + + it("does not remove any tools when disabledTools is empty", () => { + const settings = { + disabledTools: [], + } + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + + const resultNames = result.map((t) => (t as any).function.name) + expect(resultNames).toContain("execute_command") + expect(resultNames).toContain("read_file") + expect(resultNames).toContain("write_to_file") + expect(resultNames).toContain("browser_action") + expect(resultNames).toContain("apply_diff") + }) + + it("does not remove any tools when disabledTools is undefined", () => { + const settings = {} + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + + const resultNames = result.map((t) => (t as any).function.name) + expect(resultNames).toContain("execute_command") + expect(resultNames).toContain("read_file") + }) + + it("combines disabledTools with other setting-based exclusions", () => { + const settings = { + browserToolEnabled: false, + disabledTools: ["execute_command"], + } + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + + const resultNames = result.map((t) => (t as any).function.name) + expect(resultNames).not.toContain("execute_command") + expect(resultNames).not.toContain("browser_action") + expect(resultNames).toContain("read_file") + }) +}) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 5560fe9bc6..c034b972d6 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -296,6 +296,13 @@ export function filterNativeToolsForMode( allowedToolNames.delete("browser_action") } + // Remove tools that are explicitly disabled via the disabledTools setting + if (settings?.disabledTools?.length) { + for (const toolName of settings.disabledTools) { + allowedToolNames.delete(toolName) + } + } + // Conditionally exclude access_mcp_resource if MCP is not enabled or there are no resources if (!mcpHub || !hasAnyMcpResources(mcpHub)) { allowedToolNames.delete("access_mcp_resource") diff --git a/src/core/task-persistence/__tests__/apiMessages.spec.ts b/src/core/task-persistence/__tests__/apiMessages.spec.ts new file mode 100644 index 0000000000..aa725f4744 --- /dev/null +++ b/src/core/task-persistence/__tests__/apiMessages.spec.ts @@ -0,0 +1,86 @@ +// cd src && npx vitest run core/task-persistence/__tests__/apiMessages.spec.ts + +import * as os from "os" +import * as path from "path" +import * as fs from "fs/promises" + +import { readApiMessages } from "../apiMessages" + +let tmpBaseDir: string + +beforeEach(async () => { + tmpBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), "roo-test-api-")) +}) + +describe("apiMessages.readApiMessages", () => { + it("returns empty array when api_conversation_history.json contains invalid JSON", async () => { + const taskId = "task-corrupt-api" + const taskDir = path.join(tmpBaseDir, "tasks", taskId) + await fs.mkdir(taskDir, { recursive: true }) + const filePath = path.join(taskDir, "api_conversation_history.json") + await fs.writeFile(filePath, "<<>>", "utf8") + + const result = await readApiMessages({ + taskId, + globalStoragePath: tmpBaseDir, + }) + + expect(result).toEqual([]) + }) + + it("returns empty array when claude_messages.json fallback contains invalid JSON", async () => { + const taskId = "task-corrupt-fallback" + const taskDir = path.join(tmpBaseDir, "tasks", taskId) + await fs.mkdir(taskDir, { recursive: true }) + + // Only write the old fallback file (claude_messages.json), NOT the new one + const oldPath = path.join(taskDir, "claude_messages.json") + await fs.writeFile(oldPath, "not json at all {[!", "utf8") + + const result = await readApiMessages({ + taskId, + globalStoragePath: tmpBaseDir, + }) + + expect(result).toEqual([]) + + // The corrupted fallback file should NOT be deleted + const stillExists = await fs + .access(oldPath) + .then(() => true) + .catch(() => false) + expect(stillExists).toBe(true) + }) + + it("returns [] when file contains valid JSON that is not an array", async () => { + const taskId = "task-non-array-api" + const taskDir = path.join(tmpBaseDir, "tasks", taskId) + await fs.mkdir(taskDir, { recursive: true }) + const filePath = path.join(taskDir, "api_conversation_history.json") + await fs.writeFile(filePath, JSON.stringify("hello"), "utf8") + + const result = await readApiMessages({ + taskId, + globalStoragePath: tmpBaseDir, + }) + + expect(result).toEqual([]) + }) + + it("returns [] when fallback file contains valid JSON that is not an array", async () => { + const taskId = "task-non-array-fallback" + const taskDir = path.join(tmpBaseDir, "tasks", taskId) + await fs.mkdir(taskDir, { recursive: true }) + + // Only write the old fallback file, NOT the new one + const oldPath = path.join(taskDir, "claude_messages.json") + await fs.writeFile(oldPath, JSON.stringify({ key: "value" }), "utf8") + + const result = await readApiMessages({ + taskId, + globalStoragePath: tmpBaseDir, + }) + + expect(result).toEqual([]) + }) +}) diff --git a/src/core/task-persistence/__tests__/taskMessages.spec.ts b/src/core/task-persistence/__tests__/taskMessages.spec.ts index 98148d6ed6..c6bc360c05 100644 --- a/src/core/task-persistence/__tests__/taskMessages.spec.ts +++ b/src/core/task-persistence/__tests__/taskMessages.spec.ts @@ -12,7 +12,7 @@ vi.mock("../../../utils/safeWriteJson", () => ({ })) // Import after mocks -import { saveTaskMessages } from "../taskMessages" +import { saveTaskMessages, readTaskMessages } from "../taskMessages" let tmpBaseDir: string @@ -66,3 +66,36 @@ describe("taskMessages.saveTaskMessages", () => { expect(persisted).toEqual(messages) }) }) + +describe("taskMessages.readTaskMessages", () => { + it("returns empty array when file contains invalid JSON", async () => { + const taskId = "task-corrupt-json" + // Manually create the task directory and write corrupted JSON + const taskDir = path.join(tmpBaseDir, "tasks", taskId) + await fs.mkdir(taskDir, { recursive: true }) + const filePath = path.join(taskDir, "ui_messages.json") + await fs.writeFile(filePath, "{not valid json!!!", "utf8") + + const result = await readTaskMessages({ + taskId, + globalStoragePath: tmpBaseDir, + }) + + expect(result).toEqual([]) + }) + + it("returns [] when file contains valid JSON that is not an array", async () => { + const taskId = "task-non-array-json" + const taskDir = path.join(tmpBaseDir, "tasks", taskId) + await fs.mkdir(taskDir, { recursive: true }) + const filePath = path.join(taskDir, "ui_messages.json") + await fs.writeFile(filePath, JSON.stringify("hello"), "utf8") + + const result = await readTaskMessages({ + taskId, + globalStoragePath: tmpBaseDir, + }) + + expect(result).toEqual([]) + }) +}) diff --git a/src/core/task-persistence/apiMessages.ts b/src/core/task-persistence/apiMessages.ts index 097679e4a7..7672f6f7ee 100644 --- a/src/core/task-persistence/apiMessages.ts +++ b/src/core/task-persistence/apiMessages.ts @@ -51,17 +51,23 @@ export async function readApiMessages({ const fileContent = await fs.readFile(filePath, "utf8") try { const parsedData = JSON.parse(fileContent) - if (Array.isArray(parsedData) && parsedData.length === 0) { + if (!Array.isArray(parsedData)) { + console.warn( + `[readApiMessages] Parsed data is not an array (got ${typeof parsedData}), returning empty. TaskId: ${taskId}, Path: ${filePath}`, + ) + return [] + } + if (parsedData.length === 0) { console.error( `[Roo-Debug] readApiMessages: Found API conversation history file, but it's empty (parsed as []). TaskId: ${taskId}, Path: ${filePath}`, ) } return parsedData } catch (error) { - console.error( - `[Roo-Debug] readApiMessages: Error parsing API conversation history file. TaskId: ${taskId}, Path: ${filePath}, Error: ${error}`, + console.warn( + `[readApiMessages] Error parsing API conversation history file, returning empty. TaskId: ${taskId}, Path: ${filePath}, Error: ${error}`, ) - throw error + return [] } } else { const oldPath = path.join(taskDir, "claude_messages.json") @@ -70,7 +76,13 @@ export async function readApiMessages({ const fileContent = await fs.readFile(oldPath, "utf8") try { const parsedData = JSON.parse(fileContent) - if (Array.isArray(parsedData) && parsedData.length === 0) { + if (!Array.isArray(parsedData)) { + console.warn( + `[readApiMessages] Parsed OLD data is not an array (got ${typeof parsedData}), returning empty. TaskId: ${taskId}, Path: ${oldPath}`, + ) + return [] + } + if (parsedData.length === 0) { console.error( `[Roo-Debug] readApiMessages: Found OLD API conversation history file (claude_messages.json), but it's empty (parsed as []). TaskId: ${taskId}, Path: ${oldPath}`, ) @@ -78,11 +90,11 @@ export async function readApiMessages({ await fs.unlink(oldPath) return parsedData } catch (error) { - console.error( - `[Roo-Debug] readApiMessages: Error parsing OLD API conversation history file (claude_messages.json). TaskId: ${taskId}, Path: ${oldPath}, Error: ${error}`, + console.warn( + `[readApiMessages] Error parsing OLD API conversation history file (claude_messages.json), returning empty. TaskId: ${taskId}, Path: ${oldPath}, Error: ${error}`, ) - // DO NOT unlink oldPath if parsing failed, throw error instead. - throw error + // DO NOT unlink oldPath if parsing failed. + return [] } } } diff --git a/src/core/task-persistence/taskMessages.ts b/src/core/task-persistence/taskMessages.ts index 63a2eefbaa..cee66432d9 100644 --- a/src/core/task-persistence/taskMessages.ts +++ b/src/core/task-persistence/taskMessages.ts @@ -23,7 +23,21 @@ export async function readTaskMessages({ const fileExists = await fileExistsAtPath(filePath) if (fileExists) { - return JSON.parse(await fs.readFile(filePath, "utf8")) + try { + const parsedData = JSON.parse(await fs.readFile(filePath, "utf8")) + if (!Array.isArray(parsedData)) { + console.warn( + `[readTaskMessages] Parsed data is not an array (got ${typeof parsedData}), returning empty. TaskId: ${taskId}, Path: ${filePath}`, + ) + return [] + } + return parsedData + } catch (error) { + console.warn( + `[readTaskMessages] Failed to parse ${filePath} for task ${taskId}, returning empty: ${error instanceof Error ? error.message : String(error)}`, + ) + return [] + } } return [] diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d6fafe7060..caea2e9e09 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -41,6 +41,7 @@ import { TodoItem, getApiProtocol, getModelId, + isRetiredProvider, isIdleAsk, isInteractiveAsk, isResumableAsk, @@ -515,6 +516,7 @@ export class Task extends EventEmitter implements TaskLike { didAlreadyUseTool = false didToolFailInCurrentTurn = false didCompleteReadingStream = false + private _started = false // No streaming parser is required. assistantMessageParser?: undefined private providerProfileChangeListener?: (config: { name: string; provider?: string }) => void @@ -675,6 +677,7 @@ export class Task extends EventEmitter implements TaskLike { this.messageQueueStateChangedHandler = () => { this.emit(RooCodeEventName.TaskUserMessage, this.taskId) + this.emit(RooCodeEventName.QueuedMessagesUpdated, this.taskId, this.messageQueueService.messages) this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory() } @@ -718,6 +721,7 @@ export class Task extends EventEmitter implements TaskLike { onCreated?.(this) if (startTask) { + this._started = true if (task || images) { this.startTask(task, images) } else if (historyItem) { @@ -1020,6 +1024,7 @@ export class Task extends EventEmitter implements TaskLike { getThoughtSignature?: () => string | undefined getSummary?: () => any[] | undefined getReasoningDetails?: () => any[] | undefined + getRedactedThinkingBlocks?: () => Array<{ type: "redacted_thinking"; data: string }> | undefined } if (message.role === "assistant") { @@ -1033,7 +1038,11 @@ export class Task extends EventEmitter implements TaskLike { // Other providers (notably Gemini 3) use different signature semantics (e.g. `thoughtSignature`) // and require round-tripping the signature in their own format. const modelId = getModelId(this.apiConfiguration) - const apiProtocol = getApiProtocol(this.apiConfiguration.apiProvider, modelId) + const apiProvider = this.apiConfiguration.apiProvider + const apiProtocol = getApiProtocol( + apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined, + modelId, + ) const isAnthropicProtocol = apiProtocol === "anthropic" // Start from the original assistant message @@ -1070,6 +1079,15 @@ export class Task extends EventEmitter implements TaskLike { } else if (!messageWithTs.content) { messageWithTs.content = [thinkingBlock] } + + // Also insert any redacted_thinking blocks after the thinking block. + // Anthropic returns these when safety filters trigger on reasoning content. + // They must be passed back verbatim for proper reasoning continuity. + const redactedBlocks = handler.getRedactedThinkingBlocks?.() + if (redactedBlocks && Array.isArray(messageWithTs.content)) { + // Insert after the thinking block (index 1, right after thinking at index 0) + messageWithTs.content.splice(1, 0, ...redactedBlocks) + } } else if (reasoning && !reasoningDetails) { // Other providers (non-Anthropic): Store as generic reasoning block const reasoningBlock = { @@ -1192,10 +1210,10 @@ export class Task extends EventEmitter implements TaskLike { * tools execute (added in recursivelyMakeClineRequests after streaming completes). * So we usually only need to flush the pending user message with tool_results. */ - public async flushPendingToolResultsToHistory(): Promise { + public async flushPendingToolResultsToHistory(): Promise { // Only flush if there's actually pending content to save if (this.userMessageContent.length === 0) { - return + return true } // CRITICAL: Wait for the assistant message to be saved to API history first. @@ -1225,7 +1243,7 @@ export class Task extends EventEmitter implements TaskLike { // If task was aborted while waiting, don't flush if (this.abort) { - return + return false } // Save the user message with tool_result blocks @@ -1242,25 +1260,58 @@ export class Task extends EventEmitter implements TaskLike { const userMessageWithTs = { ...validatedMessage, ts: Date.now() } this.apiConversationHistory.push(userMessageWithTs as ApiMessage) - await this.saveApiConversationHistory() + const saved = await this.saveApiConversationHistory() - // Clear the pending content since it's now saved - this.userMessageContent = [] + if (saved) { + // Clear the pending content since it's now saved + this.userMessageContent = [] + } else { + console.warn( + `[Task#${this.taskId}] flushPendingToolResultsToHistory: save failed, retaining pending tool results in memory`, + ) + } + + return saved } - private async saveApiConversationHistory() { + private async saveApiConversationHistory(): Promise { try { await saveApiMessages({ - messages: this.apiConversationHistory, + messages: structuredClone(this.apiConversationHistory), taskId: this.taskId, globalStoragePath: this.globalStoragePath, }) + return true } catch (error) { - // In the off chance this fails, we don't want to stop the task. console.error("Failed to save API conversation history:", error) + return false } } + /** + * Public wrapper to retry saving the API conversation history. + * Uses exponential backoff: up to 3 attempts with delays of 100 ms, 500 ms, 1500 ms. + * Used by delegation flow when flushPendingToolResultsToHistory reports failure. + */ + public async retrySaveApiConversationHistory(): Promise { + const delays = [100, 500, 1500] + + for (let attempt = 0; attempt < delays.length; attempt++) { + await new Promise((resolve) => setTimeout(resolve, delays[attempt])) + console.warn( + `[Task#${this.taskId}] retrySaveApiConversationHistory: retry attempt ${attempt + 1}/${delays.length}`, + ) + + const success = await this.saveApiConversationHistory() + + if (success) { + return true + } + } + + return false + } + // Cline Messages private async getSavedClineMessages(): Promise { @@ -1322,10 +1373,10 @@ export class Task extends EventEmitter implements TaskLike { } } - private async saveClineMessages() { + private async saveClineMessages(): Promise { try { await saveTaskMessages({ - messages: this.clineMessages, + messages: structuredClone(this.clineMessages), taskId: this.taskId, globalStoragePath: this.globalStoragePath, }) @@ -1355,8 +1406,10 @@ export class Task extends EventEmitter implements TaskLike { this.debouncedEmitTokenUsage(tokenUsage, this.toolUsage) await this.providerRef.deref()?.updateTaskHistory(historyItem) + return true } catch (error) { console.error("Failed to save Roo messages:", error) + return false } } @@ -1776,6 +1829,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, browserToolEnabled: state?.browserToolEnabled ?? true, + disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: false, }) @@ -2019,6 +2073,30 @@ export class Task extends EventEmitter implements TaskLike { } } + /** + * Manually start a **new** task when it was created with `startTask: false`. + * + * This fires `startTask` as a background async operation for the + * `task/images` code-path only. It does **not** handle the + * `historyItem` resume path (use the constructor with `startTask: true` + * for that). The primary use-case is in the delegation flow where the + * parent's metadata must be persisted to globalState **before** the + * child task begins writing its own history (avoiding a read-modify-write + * race on globalState). + */ + public start(): void { + if (this._started) { + return + } + this._started = true + + const { task, images } = this.metadata + + if (task || images) { + this.startTask(task ?? undefined, images ?? undefined) + } + } + private async startTask(task?: string, images?: string[]): Promise { try { if (this.enableBridge) { @@ -2683,7 +2761,11 @@ export class Task extends EventEmitter implements TaskLike { // Determine API protocol based on provider and model const modelId = getModelId(this.apiConfiguration) - const apiProtocol = getApiProtocol(this.apiConfiguration.apiProvider, modelId) + const apiProvider = this.apiConfiguration.apiProvider + const apiProtocol = getApiProtocol( + apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined, + modelId, + ) // Respect user-configured provider rate limiting BEFORE we emit api_req_started. // This prevents the UI from showing an "API Request..." spinner while we are @@ -2804,7 +2886,11 @@ export class Task extends EventEmitter implements TaskLike { // Calculate total tokens and cost using provider-aware function const modelId = getModelId(this.apiConfiguration) - const apiProtocol = getApiProtocol(this.apiConfiguration.apiProvider, modelId) + const apiProvider = this.apiConfiguration.apiProvider + const apiProtocol = getApiProtocol( + apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined, + modelId, + ) const costResult = apiProtocol === "anthropic" @@ -3128,7 +3214,11 @@ export class Task extends EventEmitter implements TaskLike { // Capture telemetry with provider-aware cost calculation const modelId = getModelId(this.apiConfiguration) - const apiProtocol = getApiProtocol(this.apiConfiguration.apiProvider, modelId) + const apiProvider = this.apiConfiguration.apiProvider + const apiProtocol = getApiProtocol( + apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined, + modelId, + ) // Use the appropriate cost function based on the API protocol const costResult = @@ -3877,6 +3967,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, browserToolEnabled: state?.browserToolEnabled ?? true, + disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: false, }) @@ -4091,6 +4182,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, browserToolEnabled: state?.browserToolEnabled ?? true, + disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: false, }) @@ -4255,6 +4347,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, browserToolEnabled: state?.browserToolEnabled ?? true, + disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: supportsAllowedFunctionNames, }) @@ -4563,14 +4656,15 @@ export class Task extends EventEmitter implements TaskLike { continue } else if (hasPlainTextReasoning) { - // Check if the model's preserveReasoning flag is set - // If true, include the reasoning block in API requests - // If false/undefined, strip it out (stored for history only, not sent back to API) - const shouldPreserveForApi = this.api.getModel().info.preserveReasoning === true + // Preserve plain-text reasoning blocks for: + // - models explicitly opting in via preserveReasoning + // - AI SDK providers (provider packages decide what to include in the native request) + const shouldPreserveForApi = + this.api.getModel().info.preserveReasoning === true || this.api.isAiSdkProvider() + let assistantContent: Anthropic.Messages.MessageParam["content"] if (shouldPreserveForApi) { - // Include reasoning block in the content sent to API assistantContent = contentArray } else { // Strip reasoning out - stored for history only, not sent back to API diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts new file mode 100644 index 0000000000..1e4acc9713 --- /dev/null +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -0,0 +1,471 @@ +// cd src && npx vitest run core/task/__tests__/Task.persistence.spec.ts + +import * as os from "os" +import * as path from "path" +import * as vscode from "vscode" + +import type { GlobalState, ProviderSettings } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" + +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" +import { ContextProxy } from "../../config/ContextProxy" + +// ─── Hoisted mocks ─────────────────────────────────────────────────────────── + +const { + mockSaveApiMessages, + mockSaveTaskMessages, + mockReadApiMessages, + mockReadTaskMessages, + mockTaskMetadata, + mockPWaitFor, +} = vi.hoisted(() => ({ + mockSaveApiMessages: vi.fn().mockResolvedValue(undefined), + mockSaveTaskMessages: vi.fn().mockResolvedValue(undefined), + mockReadApiMessages: vi.fn().mockResolvedValue([]), + mockReadTaskMessages: vi.fn().mockResolvedValue([]), + mockTaskMetadata: vi.fn().mockResolvedValue({ + historyItem: { id: "test-id", ts: Date.now(), task: "test" }, + tokenUsage: { + totalTokensIn: 0, + totalTokensOut: 0, + totalCacheWrites: 0, + totalCacheReads: 0, + totalCost: 0, + contextTokens: 0, + }, + }), + mockPWaitFor: vi.fn().mockResolvedValue(undefined), +})) + +// ─── Module mocks ──────────────────────────────────────────────────────────── + +vi.mock("delay", () => ({ + __esModule: true, + default: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("execa", () => ({ + execa: vi.fn(), +})) + +vi.mock("fs/promises", async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue("[]"), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), + default: { + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue("[]"), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), + }, + } +}) + +vi.mock("p-wait-for", () => ({ + default: mockPWaitFor, +})) + +vi.mock("../../task-persistence", () => ({ + saveApiMessages: mockSaveApiMessages, + saveTaskMessages: mockSaveTaskMessages, + readApiMessages: mockReadApiMessages, + readTaskMessages: mockReadTaskMessages, + taskMetadata: mockTaskMetadata, +})) + +vi.mock("vscode", () => { + const mockDisposable = { dispose: vi.fn() } + const mockEventEmitter = { event: vi.fn(), fire: vi.fn() } + const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } } + const mockTextEditor = { document: mockTextDocument } + const mockTab = { input: { uri: { fsPath: "/mock/workspace/path/file.ts" } } } + const mockTabGroup = { tabs: [mockTab] } + + return { + TabInputTextDiff: vi.fn(), + CodeActionKind: { + QuickFix: { value: "quickfix" }, + RefactorRewrite: { value: "refactor.rewrite" }, + }, + window: { + createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }), + visibleTextEditors: [mockTextEditor], + tabGroups: { + all: [mockTabGroup], + close: vi.fn(), + onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })), + }, + showErrorMessage: vi.fn(), + }, + workspace: { + workspaceFolders: [ + { + uri: { fsPath: "/mock/workspace/path" }, + name: "mock-workspace", + index: 0, + }, + ], + createFileSystemWatcher: vi.fn(() => ({ + onDidCreate: vi.fn(() => mockDisposable), + onDidDelete: vi.fn(() => mockDisposable), + onDidChange: vi.fn(() => mockDisposable), + dispose: vi.fn(), + })), + fs: { + stat: vi.fn().mockResolvedValue({ type: 1 }), + }, + onDidSaveTextDocument: vi.fn(() => mockDisposable), + getConfiguration: vi.fn(() => ({ get: (_key: string, defaultValue: unknown) => defaultValue })), + }, + env: { + uriScheme: "vscode", + language: "en", + }, + EventEmitter: vi.fn().mockImplementation(() => mockEventEmitter), + Disposable: { + from: vi.fn(), + }, + TabInputText: vi.fn(), + } +}) + +vi.mock("../../mentions", () => ({ + parseMentions: vi.fn().mockImplementation((text) => { + return Promise.resolve({ text: `processed: ${text}`, mode: undefined, contentBlocks: [] }) + }), + openMention: vi.fn(), + getLatestTerminalOutput: vi.fn(), +})) + +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"), +})) + +vi.mock("../../environment/getEnvironmentDetails", () => ({ + getEnvironmentDetails: vi.fn().mockResolvedValue(""), +})) + +vi.mock("../../ignore/RooIgnoreController") + +vi.mock("../../condense", async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + summarizeConversation: vi.fn().mockResolvedValue({ + messages: [{ role: "user", content: [{ type: "text", text: "continued" }], ts: Date.now() }], + summary: "summary", + cost: 0, + newContextTokens: 1, + }), + } +}) + +vi.mock("../../../utils/storage", () => ({ + getTaskDirectoryPath: vi + .fn() + .mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)), + getSettingsDirectoryPath: vi + .fn() + .mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)), +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockReturnValue(false), +})) + +// ─── Test suite ────────────────────────────────────────────────────────────── + +describe("Task persistence", () => { + let mockProvider: ClineProvider & Record + let mockApiConfig: ProviderSettings + let mockOutputChannel: vscode.OutputChannel + let mockExtensionContext: vscode.ExtensionContext + + beforeEach(() => { + vi.clearAllMocks() + + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + const storageUri = { fsPath: path.join(os.tmpdir(), "test-storage") } + + mockExtensionContext = { + globalState: { + get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined), + update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), + keys: vi.fn().mockReturnValue([]), + }, + globalStorageUri: storageUri, + workspaceState: { + get: vi.fn().mockImplementation((_key) => undefined), + update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), + keys: vi.fn().mockReturnValue([]), + }, + secrets: { + get: vi.fn().mockImplementation((_key) => Promise.resolve(undefined)), + store: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), + delete: vi.fn().mockImplementation((_key) => Promise.resolve()), + }, + extensionUri: { fsPath: "/mock/extension/path" }, + extension: { packageJSON: { version: "1.0.0" } }, + } as unknown as vscode.ExtensionContext + + mockOutputChannel = { + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), + } as unknown as vscode.OutputChannel + + mockProvider = new ClineProvider( + mockExtensionContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockExtensionContext), + ) as ClineProvider & Record + + mockApiConfig = { + apiProvider: "anthropic", + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-api-key", + } + + mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) + mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) + mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) + mockProvider.updateTaskHistory = vi.fn().mockResolvedValue(undefined) + }) + + // ── saveApiConversationHistory (via retrySaveApiConversationHistory) ── + + describe("saveApiConversationHistory", () => { + it("returns true on success", async () => { + mockSaveApiMessages.mockResolvedValueOnce(undefined) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + task.apiConversationHistory.push({ + role: "user", + content: [{ type: "text", text: "hello" }], + }) + + const result = await task.retrySaveApiConversationHistory() + expect(result).toBe(true) + }) + + it("returns false on failure", async () => { + vi.useFakeTimers() + + // All 3 retry attempts must fail for retrySaveApiConversationHistory to return false + mockSaveApiMessages + .mockRejectedValueOnce(new Error("fail 1")) + .mockRejectedValueOnce(new Error("fail 2")) + .mockRejectedValueOnce(new Error("fail 3")) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const promise = task.retrySaveApiConversationHistory() + await vi.runAllTimersAsync() + const result = await promise + + expect(result).toBe(false) + expect(mockSaveApiMessages).toHaveBeenCalledTimes(3) + + vi.useRealTimers() + }) + + it("succeeds on 2nd retry attempt", async () => { + vi.useFakeTimers() + + mockSaveApiMessages.mockRejectedValueOnce(new Error("fail 1")).mockResolvedValueOnce(undefined) // succeeds on 2nd try + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const promise = task.retrySaveApiConversationHistory() + await vi.runAllTimersAsync() + const result = await promise + + expect(result).toBe(true) + expect(mockSaveApiMessages).toHaveBeenCalledTimes(2) + + vi.useRealTimers() + }) + + it("snapshots the array before passing to saveApiMessages", async () => { + mockSaveApiMessages.mockResolvedValueOnce(undefined) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const originalMsg = { + role: "user" as const, + content: [{ type: "text" as const, text: "snapshot test" }], + } + task.apiConversationHistory.push(originalMsg) + + await task.retrySaveApiConversationHistory() + + expect(mockSaveApiMessages).toHaveBeenCalledTimes(1) + + const callArgs = mockSaveApiMessages.mock.calls[0][0] + // The messages passed should be a COPY, not the live reference + expect(callArgs.messages).not.toBe(task.apiConversationHistory) + // But the content should be the same + expect(callArgs.messages).toEqual(task.apiConversationHistory) + }) + }) + + // ── saveClineMessages ──────────────────────────────────────────────── + + describe("saveClineMessages", () => { + it("returns true on success", async () => { + mockSaveTaskMessages.mockResolvedValueOnce(undefined) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const result = await (task as Record).saveClineMessages() + expect(result).toBe(true) + }) + + it("returns false on failure", async () => { + mockSaveTaskMessages.mockRejectedValueOnce(new Error("write error")) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const result = await (task as Record).saveClineMessages() + expect(result).toBe(false) + }) + + it("snapshots the array before passing to saveTaskMessages", async () => { + mockSaveTaskMessages.mockResolvedValueOnce(undefined) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + task.clineMessages.push({ + type: "say", + say: "text", + text: "snapshot test", + ts: Date.now(), + }) + + await (task as Record).saveClineMessages() + + expect(mockSaveTaskMessages).toHaveBeenCalledTimes(1) + + const callArgs = mockSaveTaskMessages.mock.calls[0][0] + // The messages passed should be a COPY, not the live reference + expect(callArgs.messages).not.toBe(task.clineMessages) + // But the content should be the same + expect(callArgs.messages).toEqual(task.clineMessages) + }) + }) + + // ── flushPendingToolResultsToHistory — save failure/success ─────────── + + describe("flushPendingToolResultsToHistory persistence", () => { + it("retains userMessageContent on save failure", async () => { + mockSaveApiMessages.mockRejectedValueOnce(new Error("disk full")) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Skip waiting for assistant message + task.assistantMessageSavedToHistory = true + + task.userMessageContent = [ + { + type: "tool_result", + tool_use_id: "tool-fail", + content: "Result that should be retained", + }, + ] + + const saved = await task.flushPendingToolResultsToHistory() + + expect(saved).toBe(false) + // userMessageContent should NOT be cleared on failure + expect(task.userMessageContent.length).toBeGreaterThan(0) + expect(task.userMessageContent[0]).toMatchObject({ + type: "tool_result", + tool_use_id: "tool-fail", + }) + }) + + it("clears userMessageContent on save success", async () => { + mockSaveApiMessages.mockResolvedValueOnce(undefined) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Skip waiting for assistant message + task.assistantMessageSavedToHistory = true + + task.userMessageContent = [ + { + type: "tool_result", + tool_use_id: "tool-ok", + content: "Result that should be cleared", + }, + ] + + const saved = await task.flushPendingToolResultsToHistory() + + expect(saved).toBe(true) + // userMessageContent should be cleared on success + expect(task.userMessageContent).toEqual([]) + }) + }) +}) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 2a2c87151b..a065c11eaa 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1801,6 +1801,49 @@ describe("Cline", () => { }) }) }) + + describe("start()", () => { + it("should be a no-op if the task was already started in the constructor", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Manually trigger start + const startTaskSpy = vi.spyOn(task as any, "startTask").mockImplementation(async () => {}) + task.start() + + expect(startTaskSpy).toHaveBeenCalledTimes(1) + + // Calling start() again should be a no-op + task.start() + expect(startTaskSpy).toHaveBeenCalledTimes(1) + }) + + it("should not call startTask if already started via constructor", () => { + // Create a task that starts immediately (startTask defaults to true) + // but mock startTask to prevent actual execution + const startTaskSpy = vi.spyOn(Task.prototype as any, "startTask").mockImplementation(async () => {}) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: true, + }) + + // startTask was called by the constructor + expect(startTaskSpy).toHaveBeenCalledTimes(1) + + // Calling start() should be a no-op since _started is already true + task.start() + expect(startTaskSpy).toHaveBeenCalledTimes(1) + + startTaskSpy.mockRestore() + }) + }) }) describe("Queued message processing after condense", () => { diff --git a/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts b/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts index f4d78802d2..f19645d969 100644 --- a/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts +++ b/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts @@ -21,6 +21,10 @@ vi.mock("execa", () => ({ execa: vi.fn(), })) +vi.mock("../../../utils/safeWriteJson", () => ({ + safeWriteJson: vi.fn().mockResolvedValue(undefined), +})) + vi.mock("fs/promises", async (importOriginal) => { const actual = (await importOriginal()) as Record const mockFunctions = { diff --git a/src/core/task/__tests__/grounding-sources.test.ts b/src/core/task/__tests__/grounding-sources.test.ts index 764e1ea37f..f6874a581e 100644 --- a/src/core/task/__tests__/grounding-sources.test.ts +++ b/src/core/task/__tests__/grounding-sources.test.ts @@ -183,7 +183,6 @@ describe("Task grounding sources handling", () => { mockApiConfiguration = { apiProvider: "gemini", geminiApiKey: "test-key", - enableGrounding: true, } as ProviderSettings }) diff --git a/src/core/task/__tests__/reasoning-preservation.test.ts b/src/core/task/__tests__/reasoning-preservation.test.ts index 3bf2dec298..2a3978e911 100644 --- a/src/core/task/__tests__/reasoning-preservation.test.ts +++ b/src/core/task/__tests__/reasoning-preservation.test.ts @@ -219,41 +219,33 @@ describe("Task reasoning preservation", () => { // Spy on addToApiConversationHistory const addToApiHistorySpy = vi.spyOn(task as any, "addToApiConversationHistory") - // Simulate what happens in the streaming loop when preserveReasoning is true - let finalAssistantMessage = assistantMessage - if (reasoningMessage && task.api.getModel().info.preserveReasoning) { - finalAssistantMessage = `${reasoningMessage}\n${assistantMessage}` - } + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [{ type: "text", text: assistantMessage }], + }, + reasoningMessage, + ) - await (task as any).addToApiConversationHistory({ - role: "assistant", - content: [{ type: "text", text: finalAssistantMessage }], - }) + // Verify that reasoning was stored as a separate reasoning block + expect(addToApiHistorySpy).toHaveBeenCalledWith( + { + role: "assistant", + content: [{ type: "text", text: assistantMessage }], + }, + reasoningMessage, + ) - // Verify that reasoning was prepended in tags to the assistant message - expect(addToApiHistorySpy).toHaveBeenCalledWith({ - role: "assistant", - content: [ - { - type: "text", - text: "Let me think about this step by step. First, I need to...\nHere is my response to your question.", - }, - ], - }) - - // Verify the API conversation history contains the message with reasoning + // Verify the API conversation history contains the message with reasoning block expect(task.apiConversationHistory).toHaveLength(1) - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toContain("") - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toContain("") - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toContain( - "Here is my response to your question.", - ) - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toContain( - "Let me think about this step by step. First, I need to...", - ) + expect(task.apiConversationHistory[0].role).toBe("assistant") + expect(task.apiConversationHistory[0].content).toEqual([ + { type: "reasoning", text: reasoningMessage, summary: [] }, + { type: "text", text: assistantMessage }, + ]) }) - it("should NOT append reasoning to assistant message when preserveReasoning is false", async () => { + it("should store reasoning blocks even when preserveReasoning is false", async () => { // Create a task instance const task = new Task({ provider: mockProvider as ClineProvider, @@ -279,36 +271,25 @@ describe("Task reasoning preservation", () => { // Mock the API conversation history task.apiConversationHistory = [] - // Simulate adding an assistant message with reasoning + // Add an assistant message while passing reasoning separately (Task does this in normal streaming). const assistantMessage = "Here is my response to your question." const reasoningMessage = "Let me think about this step by step. First, I need to..." - // Spy on addToApiConversationHistory - const addToApiHistorySpy = vi.spyOn(task as any, "addToApiConversationHistory") - - // Simulate what happens in the streaming loop when preserveReasoning is false - let finalAssistantMessage = assistantMessage - if (reasoningMessage && task.api.getModel().info.preserveReasoning) { - finalAssistantMessage = `${reasoningMessage}\n${assistantMessage}` - } - - await (task as any).addToApiConversationHistory({ - role: "assistant", - content: [{ type: "text", text: finalAssistantMessage }], - }) - - // Verify that reasoning was NOT appended to the assistant message - expect(addToApiHistorySpy).toHaveBeenCalledWith({ - role: "assistant", - content: [{ type: "text", text: "Here is my response to your question." }], - }) - - // Verify the API conversation history does NOT contain reasoning - expect(task.apiConversationHistory).toHaveLength(1) - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toBe( - "Here is my response to your question.", + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [{ type: "text", text: assistantMessage }], + }, + reasoningMessage, ) - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).not.toContain("") + + // Verify the API conversation history contains a reasoning block (storage is unconditional) + expect(task.apiConversationHistory).toHaveLength(1) + expect(task.apiConversationHistory[0].role).toBe("assistant") + expect(task.apiConversationHistory[0].content).toEqual([ + { type: "reasoning", text: reasoningMessage, summary: [] }, + { type: "text", text: assistantMessage }, + ]) }) it("should handle empty reasoning message gracefully when preserveReasoning is true", async () => { @@ -340,29 +321,16 @@ describe("Task reasoning preservation", () => { const assistantMessage = "Here is my response." const reasoningMessage = "" // Empty reasoning - // Spy on addToApiConversationHistory - const addToApiHistorySpy = vi.spyOn(task as any, "addToApiConversationHistory") + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [{ type: "text", text: assistantMessage }], + }, + reasoningMessage || undefined, + ) - // Simulate what happens in the streaming loop - let finalAssistantMessage = assistantMessage - if (reasoningMessage && task.api.getModel().info.preserveReasoning) { - finalAssistantMessage = `${reasoningMessage}\n${assistantMessage}` - } - - await (task as any).addToApiConversationHistory({ - role: "assistant", - content: [{ type: "text", text: finalAssistantMessage }], - }) - - // Verify that no reasoning tags were added when reasoning is empty - expect(addToApiHistorySpy).toHaveBeenCalledWith({ - role: "assistant", - content: [{ type: "text", text: "Here is my response." }], - }) - - // Verify the message doesn't contain reasoning tags - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toBe("Here is my response.") - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).not.toContain("") + // Verify no reasoning blocks were added when reasoning is empty + expect(task.apiConversationHistory[0].content).toEqual([{ type: "text", text: "Here is my response." }]) }) it("should handle undefined preserveReasoning (defaults to false)", async () => { @@ -394,20 +362,19 @@ describe("Task reasoning preservation", () => { const assistantMessage = "Here is my response." const reasoningMessage = "Some reasoning here." - // Simulate what happens in the streaming loop - let finalAssistantMessage = assistantMessage - if (reasoningMessage && task.api.getModel().info.preserveReasoning) { - finalAssistantMessage = `${reasoningMessage}\n${assistantMessage}` - } + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [{ type: "text", text: assistantMessage }], + }, + reasoningMessage, + ) - await (task as any).addToApiConversationHistory({ - role: "assistant", - content: [{ type: "text", text: finalAssistantMessage }], - }) - - // Verify reasoning was NOT prepended (undefined defaults to false) - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toBe("Here is my response.") - expect((task.apiConversationHistory[0].content[0] as { text: string }).text).not.toContain("") + // Verify reasoning is stored even when preserveReasoning is undefined + expect(task.apiConversationHistory[0].content).toEqual([ + { type: "reasoning", text: reasoningMessage, summary: [] }, + { type: "text", text: assistantMessage }, + ]) }) it("should embed encrypted reasoning as first assistant content block", async () => { diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index 0206df71c4..ab74f9443c 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -23,6 +23,7 @@ interface BuildToolsOptions { experiments: Record | undefined apiConfiguration: ProviderSettings | undefined browserToolEnabled: boolean + disabledTools?: string[] modelInfo?: ModelInfo /** * If true, returns all tools without mode filtering, but also includes @@ -88,6 +89,7 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO experiments, apiConfiguration, browserToolEnabled, + disabledTools, modelInfo, includeAllToolsWithRestrictions, } = options @@ -102,6 +104,7 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO const filterSettings = { todoListEnabled: apiConfiguration?.todoListEnabled ?? true, browserToolEnabled: browserToolEnabled ?? true, + disabledTools, modelInfo, } diff --git a/src/core/tools/UseMcpToolTool.ts b/src/core/tools/UseMcpToolTool.ts index b42b734cc5..7cbc09bfd7 100644 --- a/src/core/tools/UseMcpToolTool.ts +++ b/src/core/tools/UseMcpToolTool.ts @@ -255,12 +255,14 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { }) } - private processToolContent(toolResult: any): string { + private processToolContent(toolResult: any): { text: string; images: string[] } { if (!toolResult?.content || toolResult.content.length === 0) { - return "" + return { text: "", images: [] } } - return toolResult.content + const images: string[] = [] + + const textContent = toolResult.content .map((item: any) => { if (item.type === "text") { return item.text @@ -269,10 +271,23 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { const { blob: _, ...rest } = item.resource return JSON.stringify(rest, null, 2) } + if (item.type === "image") { + // Handle image content (MCP image content has mimeType and data properties) + if (item.mimeType && item.data) { + if (item.data.startsWith("data:")) { + images.push(item.data) + } else { + images.push(`data:${item.mimeType};base64,${item.data}`) + } + } + return "" + } return "" }) .filter(Boolean) .join("\n\n") + + return { text: textContent, images } } private async executeToolAndProcessResult( @@ -296,18 +311,22 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { const toolResult = await task.providerRef.deref()?.getMcpHub()?.callTool(serverName, toolName, parsedArguments) let toolResultPretty = "(No response)" + let images: string[] = [] if (toolResult) { - const outputText = this.processToolContent(toolResult) + const { text: outputText, images: extractedImages } = this.processToolContent(toolResult) + images = extractedImages - if (outputText) { + if (outputText || images.length > 0) { await this.sendExecutionStatus(task, { executionId, status: "output", - response: outputText, + response: outputText || (images.length > 0 ? `[${images.length} image(s)]` : ""), }) - toolResultPretty = (toolResult.isError ? "Error:\n" : "") + outputText + toolResultPretty = + (toolResult.isError ? "Error:\n" : "") + + (outputText || (images.length > 0 ? `[${images.length} image(s) received]` : "")) } // Send completion status @@ -326,8 +345,8 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { }) } - await task.say("mcp_server_response", toolResultPretty) - pushToolResult(formatResponse.toolResult(toolResultPretty)) + await task.say("mcp_server_response", toolResultPretty, images) + pushToolResult(formatResponse.toolResult(toolResultPretty, images)) } } diff --git a/src/core/tools/__tests__/useMcpToolTool.spec.ts b/src/core/tools/__tests__/useMcpToolTool.spec.ts index 9f41b3cde9..5ee826774f 100644 --- a/src/core/tools/__tests__/useMcpToolTool.spec.ts +++ b/src/core/tools/__tests__/useMcpToolTool.spec.ts @@ -7,7 +7,12 @@ import { ToolUse } from "../../../shared/tools" // Mock dependencies vi.mock("../../prompts/responses", () => ({ formatResponse: { - toolResult: vi.fn((result: string) => `Tool result: ${result}`), + toolResult: vi.fn((result: string, images?: string[]) => { + if (images && images.length > 0) { + return `Tool result: ${result} [with ${images.length} image(s)]` + } + return `Tool result: ${result}` + }), toolError: vi.fn((error: string) => `Tool error: ${error}`), invalidMcpToolArgumentError: vi.fn((server: string, tool: string) => `Invalid args for ${server}:${tool}`), unknownMcpToolError: vi.fn((server: string, tool: string, availableTools: string[]) => { @@ -245,7 +250,7 @@ describe("useMcpToolTool", () => { expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockAskApproval).toHaveBeenCalled() expect(mockTask.say).toHaveBeenCalledWith("mcp_server_request_started") - expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Tool executed successfully") + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Tool executed successfully", []) expect(mockPushToolResult).toHaveBeenCalledWith("Tool result: Tool executed successfully") }) @@ -483,7 +488,7 @@ describe("useMcpToolTool", () => { expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockTask.recordToolError).not.toHaveBeenCalled() expect(mockTask.say).toHaveBeenCalledWith("mcp_server_request_started") - expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Tool executed successfully") + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Tool executed successfully", []) }) it("should reject unknown server names with available servers listed", async () => { @@ -636,4 +641,234 @@ describe("useMcpToolTool", () => { expect(callToolMock).toHaveBeenCalledWith("test-server", "get-user-profile", {}) }) }) + + describe("image handling", () => { + it("should handle tool response with image content", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "figma-server", + tool_name: "get_screenshot", + arguments: '{"nodeId": "123"}', + }, + nativeArgs: { + server_name: "figma-server", + tool_name: "get_screenshot", + arguments: { nodeId: "123" }, + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { + type: "image", + mimeType: "image/png", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ", + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + getAllServers: vi.fn().mockReturnValue([ + { + name: "figma-server", + tools: [{ name: "get_screenshot", description: "Get screenshot" }], + }, + ]), + }), + postMessageToWebview: vi.fn(), + }) + + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_request_started") + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "[1 image(s) received]", [ + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ", + ]) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("with 1 image(s)")) + }) + + it("should handle tool response with both text and image content", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "figma-server", + tool_name: "get_node_info", + arguments: '{"nodeId": "123"}', + }, + nativeArgs: { + server_name: "figma-server", + tool_name: "get_node_info", + arguments: { nodeId: "123" }, + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { type: "text", text: "Node name: Button" }, + { + type: "image", + mimeType: "image/png", + data: "base64imagedata", + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + getAllServers: vi + .fn() + .mockReturnValue([ + { name: "figma-server", tools: [{ name: "get_node_info", description: "Get node info" }] }, + ]), + }), + postMessageToWebview: vi.fn(), + }) + + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_request_started") + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Node name: Button", [ + "data:image/png;base64,base64imagedata", + ]) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("with 1 image(s)")) + }) + + it("should handle image with data URL already formatted", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "figma-server", + tool_name: "get_screenshot", + arguments: '{"nodeId": "123"}', + }, + nativeArgs: { + server_name: "figma-server", + tool_name: "get_screenshot", + arguments: { nodeId: "123" }, + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { + type: "image", + mimeType: "image/jpeg", + data: "data:image/jpeg;base64,/9j/4AAQSkZJRg==", + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + getAllServers: vi.fn().mockReturnValue([ + { + name: "figma-server", + tools: [{ name: "get_screenshot", description: "Get screenshot" }], + }, + ]), + }), + postMessageToWebview: vi.fn(), + }) + + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // Should not double-prefix the data URL + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "[1 image(s) received]", [ + "data:image/jpeg;base64,/9j/4AAQSkZJRg==", + ]) + }) + + it("should handle multiple images in response", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "figma-server", + tool_name: "get_screenshots", + arguments: '{"nodeIds": ["1", "2"]}', + }, + nativeArgs: { + server_name: "figma-server", + tool_name: "get_screenshots", + arguments: { nodeIds: ["1", "2"] }, + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { + type: "image", + mimeType: "image/png", + data: "image1data", + }, + { + type: "image", + mimeType: "image/png", + data: "image2data", + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + getAllServers: vi.fn().mockReturnValue([ + { + name: "figma-server", + tools: [{ name: "get_screenshots", description: "Get screenshots" }], + }, + ]), + }), + postMessageToWebview: vi.fn(), + }) + + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "[2 image(s) received]", [ + "data:image/png;base64,image1data", + "data:image/png;base64,image2data", + ]) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("with 2 image(s)")) + }) + }) }) diff --git a/src/core/tools/__tests__/validateToolUse.spec.ts b/src/core/tools/__tests__/validateToolUse.spec.ts index 87aa159420..b4622096ab 100644 --- a/src/core/tools/__tests__/validateToolUse.spec.ts +++ b/src/core/tools/__tests__/validateToolUse.spec.ts @@ -163,6 +163,15 @@ describe("mode-validator", () => { // Even in code mode which allows all tools, disabled requirement should take precedence expect(isToolAllowedForMode("apply_diff", codeMode, [], requirements)).toBe(false) }) + + it("prioritizes requirements over ALWAYS_AVAILABLE_TOOLS", () => { + // Tools in ALWAYS_AVAILABLE_TOOLS (switch_mode, new_task, etc.) should still + // be blockable via toolRequirements / disabledTools + const requirements = { switch_mode: false, new_task: false, attempt_completion: false } + expect(isToolAllowedForMode("switch_mode", codeMode, [], requirements)).toBe(false) + expect(isToolAllowedForMode("new_task", codeMode, [], requirements)).toBe(false) + expect(isToolAllowedForMode("attempt_completion", codeMode, [], requirements)).toBe(false) + }) }) }) @@ -200,5 +209,50 @@ describe("mode-validator", () => { it("handles undefined requirements gracefully", () => { expect(() => validateToolUse("apply_diff", codeMode, [], undefined)).not.toThrow() }) + + it("blocks tool when disabledTools is converted to toolRequirements", () => { + const disabledTools = ["execute_command", "browser_action"] + const toolRequirements = disabledTools.reduce( + (acc: Record, tool: string) => { + acc[tool] = false + return acc + }, + {} as Record, + ) + + expect(() => validateToolUse("execute_command", codeMode, [], toolRequirements)).toThrow( + 'Tool "execute_command" is not allowed in code mode.', + ) + expect(() => validateToolUse("browser_action", codeMode, [], toolRequirements)).toThrow( + 'Tool "browser_action" is not allowed in code mode.', + ) + }) + + it("allows non-disabled tools when disabledTools is converted to toolRequirements", () => { + const disabledTools = ["execute_command"] + const toolRequirements = disabledTools.reduce( + (acc: Record, tool: string) => { + acc[tool] = false + return acc + }, + {} as Record, + ) + + expect(() => validateToolUse("read_file", codeMode, [], toolRequirements)).not.toThrow() + expect(() => validateToolUse("write_to_file", codeMode, [], toolRequirements)).not.toThrow() + }) + + it("handles empty disabledTools array converted to toolRequirements", () => { + const disabledTools: string[] = [] + const toolRequirements = disabledTools.reduce( + (acc: Record, tool: string) => { + acc[tool] = false + return acc + }, + {} as Record, + ) + + expect(() => validateToolUse("execute_command", codeMode, [], toolRequirements)).not.toThrow() + }) }) }) diff --git a/src/core/tools/validateToolUse.ts b/src/core/tools/validateToolUse.ts index 3579fde32c..ab261af722 100644 --- a/src/core/tools/validateToolUse.ts +++ b/src/core/tools/validateToolUse.ts @@ -126,7 +126,19 @@ export function isToolAllowedForMode( experiments?: Record, includedTools?: string[], // Opt-in tools explicitly included (e.g., from modelInfo) ): boolean { - // Always allow these tools + // Check tool requirements first — explicit disabling takes priority over everything, + // including ALWAYS_AVAILABLE_TOOLS. This ensures disabledTools works consistently + // at both the filtering layer and the execution-time validation layer. + if (toolRequirements && typeof toolRequirements === "object") { + if (tool in toolRequirements && !toolRequirements[tool]) { + return false + } + } else if (toolRequirements === false) { + // If toolRequirements is a boolean false, all tools are disabled + return false + } + + // Always allow these tools (unless explicitly disabled above) if (ALWAYS_AVAILABLE_TOOLS.includes(tool as any)) { return true } @@ -147,16 +159,6 @@ export function isToolAllowedForMode( } } - // Check tool requirements if any exist - if (toolRequirements && typeof toolRequirements === "object") { - if (tool in toolRequirements && !toolRequirements[tool]) { - return false - } - } else if (toolRequirements === false) { - // If toolRequirements is a boolean false, all tools are disabled - return false - } - const mode = getModeBySlug(modeSlug, customModes) if (!mode) { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index bc3f6bd6ef..94b3122eed 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -45,6 +45,7 @@ import { DEFAULT_MODES, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, getModelId, + isRetiredProvider, } from "@roo-code/types" import { aggregateTaskCostsRecursive, type AggregatedCosts } from "./aggregateTaskCosts" import { TelemetryService } from "@roo-code/telemetry" @@ -147,8 +148,10 @@ export class ClineProvider private taskCreationCallback: (task: Task) => void private taskEventListeners: WeakMap void>> = new WeakMap() private currentWorkspacePath: string | undefined + private _disposed = false private recentTasksCache?: string[] + private taskHistoryWriteLock: Promise = Promise.resolve() private pendingOperations: Map = new Map() private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds @@ -158,7 +161,7 @@ export class ClineProvider public isViewLaunched = false public settingsImportedAt?: number - public readonly latestAnnouncementId = "jan-2026-v3.46.0-parallel-tools" // v3.46.0 Parallel Tools & Smarter Reading + public readonly latestAnnouncementId = "feb-2026-v3.47.0-opus-4.6-gpt-5.3-codex" // v3.47.0 Claude Opus 4.6 & GPT-5.3-Codex public readonly providerSettingsManager: ProviderSettingsManager public readonly customModesManager: CustomModesManager @@ -577,6 +580,11 @@ export class ClineProvider } async dispose() { + if (this._disposed) { + return + } + + this._disposed = true this.log("Disposing ClineProvider...") // Clear all tasks from the stack. @@ -757,6 +765,8 @@ export class ClineProvider terminalZshP10k = false, terminalPowershellCounter = false, terminalZdotdir = false, + ttsEnabled, + ttsSpeed, }) => { Terminal.setShellIntegrationTimeout(terminalShellIntegrationTimeout) Terminal.setShellIntegrationDisabled(terminalShellIntegrationDisabled) @@ -766,17 +776,11 @@ export class ClineProvider Terminal.setTerminalZshP10k(terminalZshP10k) Terminal.setPowershellCounter(terminalPowershellCounter) Terminal.setTerminalZdotdir(terminalZdotdir) + setTtsEnabled(ttsEnabled ?? false) + setTtsSpeed(ttsSpeed ?? 1) }, ) - this.getState().then(({ ttsEnabled }) => { - setTtsEnabled(ttsEnabled ?? false) - }) - - this.getState().then(({ ttsSpeed }) => { - setTtsSpeed(ttsSpeed ?? 1) - }) - // Set up webview options with proper resource roots const resourceRoots = [this.contextProxy.extensionUri] @@ -899,7 +903,8 @@ export class ClineProvider // Load the saved API config for the restored mode if it exists. // Skip mode-based profile activation if historyItem.apiConfigName exists, // since the task's specific provider profile will override it anyway. - if (!historyItem.apiConfigName) { + const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) + if (!historyItem.apiConfigName && !lockApiConfigAcrossModes) { const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode) const listApiConfig = await this.providerSettingsManager.listConfig() @@ -1079,7 +1084,15 @@ export class ClineProvider } public async postMessageToWebview(message: ExtensionMessage) { - await this.view?.webview.postMessage(message) + if (this._disposed) { + return + } + + try { + await this.view?.webview.postMessage(message) + } catch { + // View disposed, drop message silently + } } private async getHMRHtmlContent(webview: vscode.Webview): Promise { @@ -1316,6 +1329,13 @@ export class ClineProvider this.emit(RooCodeEventName.ModeChanged, newMode) + // If workspace lock is on, keep the current API config — don't load mode-specific config + const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) + if (lockApiConfigAcrossModes) { + await this.postStateToWebview() + return + } + // Load the saved API config for the new mode if it exists. const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) const listApiConfig = await this.providerSettingsManager.listConfig() @@ -1665,31 +1685,40 @@ export class ClineProvider const history = this.getGlobalState("taskHistory") ?? [] const historyItem = history.find((item) => item.id === id) - if (historyItem) { - const { getTaskDirectoryPath } = await import("../../utils/storage") - const globalStoragePath = this.contextProxy.globalStorageUri.fsPath - const taskDirPath = await getTaskDirectoryPath(globalStoragePath, id) - const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory) - const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages) - const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath) - - if (fileExists) { - const apiConversationHistory = JSON.parse(await fs.readFile(apiConversationHistoryFilePath, "utf8")) - - return { - historyItem, - taskDirPath, - apiConversationHistoryFilePath, - uiMessagesFilePath, - apiConversationHistory, - } - } + if (!historyItem) { + throw new Error("Task not found") } - // if we tried to get a task that doesn't exist, remove it from state - // FIXME: this seems to happen sometimes when the json file doesnt save to disk for some reason - await this.deleteTaskFromState(id) - throw new Error("Task not found") + const { getTaskDirectoryPath } = await import("../../utils/storage") + const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + const taskDirPath = await getTaskDirectoryPath(globalStoragePath, id) + const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory) + const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages) + const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath) + + let apiConversationHistory: Anthropic.MessageParam[] = [] + + if (fileExists) { + try { + apiConversationHistory = JSON.parse(await fs.readFile(apiConversationHistoryFilePath, "utf8")) + } catch (error) { + console.warn( + `[getTaskWithId] api_conversation_history.json corrupted for task ${id}, returning empty history: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } else { + console.warn( + `[getTaskWithId] api_conversation_history.json missing for task ${id}, returning empty history`, + ) + } + + return { + historyItem, + taskDirPath, + apiConversationHistoryFilePath, + uiMessagesFilePath, + apiConversationHistory, + } } async getTaskWithAggregatedCosts(taskId: string): Promise<{ @@ -1786,10 +1815,12 @@ export class ClineProvider } // Delete all tasks from state in one batch - const taskHistory = this.getGlobalState("taskHistory") ?? [] - const updatedTaskHistory = taskHistory.filter((task) => !allIdsToDelete.includes(task.id)) - await this.updateGlobalState("taskHistory", updatedTaskHistory) - this.recentTasksCache = undefined + await this.withTaskHistoryLock(async () => { + const taskHistory = this.getGlobalState("taskHistory") ?? [] + const updatedTaskHistory = taskHistory.filter((task) => !allIdsToDelete.includes(task.id)) + await this.updateGlobalState("taskHistory", updatedTaskHistory) + this.recentTasksCache = undefined + }) // Delete associated shadow repositories or branches and task directories const globalStorageDir = this.contextProxy.globalStorageUri.fsPath @@ -1830,10 +1861,12 @@ export class ClineProvider } async deleteTaskFromState(id: string) { - const taskHistory = this.getGlobalState("taskHistory") ?? [] - const updatedTaskHistory = taskHistory.filter((task) => task.id !== id) - await this.updateGlobalState("taskHistory", updatedTaskHistory) - this.recentTasksCache = undefined + await this.withTaskHistoryLock(async () => { + const taskHistory = this.getGlobalState("taskHistory") ?? [] + const updatedTaskHistory = taskHistory.filter((task) => task.id !== id) + await this.updateGlobalState("taskHistory", updatedTaskHistory) + this.recentTasksCache = undefined + }) await this.postStateToWebview() } @@ -2037,6 +2070,7 @@ export class ClineProvider maxOpenTabsContext, maxWorkspaceFiles, browserToolEnabled, + disabledTools, telemetrySetting, showRooIgnoredFiles, enableSubfolderRules, @@ -2046,6 +2080,7 @@ export class ClineProvider historyPreviewCollapsed, reasoningBlockCollapsed, enterBehavior, + taskHeaderHighlightEnabled, cloudUserInfo, cloudIsAuthenticated, sharingEnabled, @@ -2071,6 +2106,7 @@ export class ClineProvider openRouterImageGenerationSelectedModel, featureRoomoteControlEnabled, isBrowserSessionActive, + lockApiConfigAcrossModes, } = await this.getState() let cloudOrganizations: CloudOrganizationMembership[] = [] @@ -2174,6 +2210,7 @@ export class ClineProvider maxWorkspaceFiles: maxWorkspaceFiles ?? 200, cwd, browserToolEnabled: browserToolEnabled ?? true, + disabledTools, telemetrySetting, telemetryKey, machineId, @@ -2188,6 +2225,7 @@ export class ClineProvider historyPreviewCollapsed: historyPreviewCollapsed ?? false, reasoningBlockCollapsed: reasoningBlockCollapsed ?? true, enterBehavior: enterBehavior ?? "send", + taskHeaderHighlightEnabled: taskHeaderHighlightEnabled ?? false, cloudUserInfo, cloudIsAuthenticated: cloudIsAuthenticated ?? false, cloudAuthSkipModel: this.context.globalState.get("roo-auth-skip-model") ?? false, @@ -2218,6 +2256,7 @@ export class ClineProvider profileThresholds: profileThresholds ?? {}, cloudApiUrl: getRooCodeApiUrl(), hasOpenedModeSelector: this.getGlobalState("hasOpenedModeSelector") ?? false, + lockApiConfigAcrossModes: lockApiConfigAcrossModes ?? false, alwaysAllowFollowupQuestions: alwaysAllowFollowupQuestions ?? false, followupAutoApproveTimeoutMs: followupAutoApproveTimeoutMs ?? 60000, includeDiagnosticMessages: includeDiagnosticMessages ?? true, @@ -2264,8 +2303,11 @@ export class ClineProvider const stateValues = this.contextProxy.getValues() const customModes = await this.customModesManager.getCustomModes() - // Determine apiProvider with the same logic as before. - const apiProvider: ProviderName = stateValues.apiProvider ? stateValues.apiProvider : "anthropic" + // Determine apiProvider with the same logic as before, while filtering retired providers. + const apiProvider: ProviderName = + stateValues.apiProvider && !isRetiredProvider(stateValues.apiProvider) + ? stateValues.apiProvider + : "anthropic" // Build the apiConfiguration object combining state values and secrets. const providerSettings = this.contextProxy.getProviderSettings() @@ -2416,6 +2458,7 @@ export class ClineProvider maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20, maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200, browserToolEnabled: stateValues.browserToolEnabled ?? true, + disabledTools: stateValues.disabledTools, telemetrySetting: stateValues.telemetrySetting || "unset", showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, enableSubfolderRules: stateValues.enableSubfolderRules ?? false, @@ -2424,6 +2467,7 @@ export class ClineProvider historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true, enterBehavior: stateValues.enterBehavior ?? "send", + taskHeaderHighlightEnabled: stateValues.taskHeaderHighlightEnabled ?? false, cloudUserInfo, cloudIsAuthenticated, sharingEnabled, @@ -2452,6 +2496,7 @@ export class ClineProvider stateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, }, profileThresholds: stateValues.profileThresholds ?? {}, + lockApiConfigAcrossModes: this.context.workspaceState.get("lockApiConfigAcrossModes", false), includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true, maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50, includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true, @@ -2488,6 +2533,19 @@ export class ClineProvider } } + /** + * Serializes all read-modify-write operations on taskHistory to prevent + * concurrent interleaving that can cause entries to vanish. + */ + private withTaskHistoryLock(fn: () => Promise): Promise { + const result = this.taskHistoryWriteLock.then(fn, fn) // run even if previous write errored + this.taskHistoryWriteLock = result.then( + () => {}, + () => {}, + ) // swallow for chain continuity + return result + } + /** * Updates a task in the task history and optionally broadcasts the updated history to the webview. * @param item The history item to update or add @@ -2495,34 +2553,36 @@ export class ClineProvider * @returns The updated task history array */ async updateTaskHistory(item: HistoryItem, options: { broadcast?: boolean } = {}): Promise { - const { broadcast = true } = options - const history = (this.getGlobalState("taskHistory") as HistoryItem[] | undefined) || [] - const existingItemIndex = history.findIndex((h) => h.id === item.id) - const wasExisting = existingItemIndex !== -1 + return this.withTaskHistoryLock(async () => { + const { broadcast = true } = options + const history = (this.getGlobalState("taskHistory") as HistoryItem[] | undefined) || [] + const existingItemIndex = history.findIndex((h) => h.id === item.id) + const wasExisting = existingItemIndex !== -1 - if (wasExisting) { - // Preserve existing metadata (e.g., delegation fields) unless explicitly overwritten. - // This prevents loss of status/awaitingChildId/delegatedToId when tasks are reopened, - // terminated, or when routine message persistence occurs. - history[existingItemIndex] = { - ...history[existingItemIndex], - ...item, + if (wasExisting) { + // Preserve existing metadata (e.g., delegation fields) unless explicitly overwritten. + // This prevents loss of status/awaitingChildId/delegatedToId when tasks are reopened, + // terminated, or when routine message persistence occurs. + history[existingItemIndex] = { + ...history[existingItemIndex], + ...item, + } + } else { + history.push(item) } - } else { - history.push(item) - } - await this.updateGlobalState("taskHistory", history) - this.recentTasksCache = undefined + await this.updateGlobalState("taskHistory", history) + this.recentTasksCache = undefined - // Broadcast the updated history to the webview if requested. - // Prefer per-item updates to avoid repeatedly cloning/sending the full history. - if (broadcast && this.isViewLaunched) { - const updatedItem = wasExisting ? history[existingItemIndex] : item - await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem }) - } + // Broadcast the updated history to the webview if requested. + // Prefer per-item updates to avoid repeatedly cloning/sending the full history. + if (broadcast && this.isViewLaunched) { + const updatedItem = wasExisting ? history[existingItemIndex] : item + await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem }) + } - return history + return history + }) } /** @@ -3102,12 +3162,14 @@ export class ClineProvider } } + const apiProvider = apiConfiguration?.apiProvider + return { language, mode, taskId: task?.taskId, parentTaskId: task?.parentTaskId, - apiProvider: apiConfiguration?.apiProvider, + apiProvider: apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined, modelId: task?.api?.getModel().id, diffStrategy: task?.diffStrategy?.getName(), isSubtask: task ? !!task.parentTaskId : undefined, @@ -3179,7 +3241,21 @@ export class ClineProvider // recursivelyMakeClineRequests BEFORE tools start executing. We only need to // flush the pending user message with tool_results. try { - await parent.flushPendingToolResultsToHistory() + const flushSuccess = await parent.flushPendingToolResultsToHistory() + + if (!flushSuccess) { + console.warn(`[delegateParentAndOpenChild] Flush failed for parent ${parentTaskId}, retrying...`) + const retrySuccess = await parent.retrySaveApiConversationHistory() + + if (!retrySuccess) { + console.error( + `[delegateParentAndOpenChild] CRITICAL: Parent ${parentTaskId} API history not persisted to disk. Child return may produce stale state.`, + ) + vscode.window.showWarningMessage( + "Warning: Parent task state could not be saved. The parent task may lose recent context when resumed.", + ) + } + } } catch (error) { this.log( `[delegateParentAndOpenChild] Error flushing pending tool results (non-fatal): ${ @@ -3220,12 +3296,20 @@ export class ClineProvider // Pass initialStatus: "active" to ensure the child task's historyItem is created // with status from the start, avoiding race conditions where the task might // call attempt_completion before status is persisted separately. + // + // Pass startTask: false to prevent the child from beginning its task loop + // (and writing to globalState via saveClineMessages → updateTaskHistory) + // before we persist the parent's delegation metadata in step 5. + // Without this, the child's fire-and-forget startTask() races with step 5, + // and the last writer to globalState overwrites the other's changes— + // causing the parent's delegation fields to be lost. const child = await this.createTask(message, undefined, parent as any, { initialTodos, initialStatus: "active", + startTask: false, }) - // 5) Persist parent delegation metadata + // 5) Persist parent delegation metadata BEFORE the child starts writing. try { const { historyItem } = await this.getTaskWithId(parentTaskId) const childIds = Array.from(new Set([...(historyItem.childIds ?? []), child.taskId])) @@ -3245,7 +3329,10 @@ export class ClineProvider ) } - // 6) Emit TaskDelegated (provider-level) + // 6) Start the child task now that parent metadata is safely persisted. + child.start() + + // 7) Emit TaskDelegated (provider-level) try { this.emit(RooCodeEventName.TaskDelegated, parentTaskId, child.taskId) } catch { @@ -3379,7 +3466,19 @@ export class ClineProvider await saveApiMessages({ messages: parentApiMessages as any, taskId: parentTaskId, globalStoragePath }) - // 3) Update child metadata to "completed" status + // 3) Close child instance if still open (single-open-task invariant). + // This MUST happen BEFORE updating the child's status to "completed" because + // removeClineFromStack() → abortTask(true) → saveClineMessages() writes + // the historyItem with initialStatus (typically "active"), which would + // overwrite a "completed" status set earlier. + const current = this.getCurrentTask() + if (current?.taskId === childTaskId) { + await this.removeClineFromStack() + } + + // 4) Update child metadata to "completed" status. + // This runs after the abort so it overwrites the stale "active" status + // that saveClineMessages() may have written during step 3. try { const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) await this.updateTaskHistory({ @@ -3394,7 +3493,7 @@ export class ClineProvider ) } - // 4) Update parent metadata and persist BEFORE emitting completion event + // 5) Update parent metadata and persist BEFORE emitting completion event const childIds = Array.from(new Set([...(historyItem.childIds ?? []), childTaskId])) const updatedHistory: typeof historyItem = { ...historyItem, @@ -3406,19 +3505,13 @@ export class ClineProvider } await this.updateTaskHistory(updatedHistory) - // 5) Emit TaskDelegationCompleted (provider-level) + // 6) Emit TaskDelegationCompleted (provider-level) try { this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary) } catch { // non-fatal } - // 6) Close child instance if still open (single-open-task invariant) - const current = this.getCurrentTask() - if (current?.taskId === childTaskId) { - await this.removeClineFromStack() - } - // 7) Reopen the parent from history as the sole active task (restores saved mode) // IMPORTANT: startTask=false to suppress resume-from-history ask scheduling const parentInstance = await this.createTaskWithHistoryItem(updatedHistory, { startTask: false }) diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 04f5d57792..9e57ae94b8 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -171,6 +171,11 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, diff --git a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts new file mode 100644 index 0000000000..9b5e3b16ee --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts @@ -0,0 +1,372 @@ +// npx vitest run core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts + +import * as vscode from "vscode" +import { TelemetryService } from "@roo-code/telemetry" +import { ClineProvider } from "../ClineProvider" +import { ContextProxy } from "../../config/ContextProxy" + +vi.mock("vscode", () => ({ + ExtensionContext: vi.fn(), + OutputChannel: vi.fn(), + WebviewView: vi.fn(), + Uri: { + joinPath: vi.fn(), + file: vi.fn(), + }, + CodeActionKind: { + QuickFix: { value: "quickfix" }, + RefactorRewrite: { value: "refactor.rewrite" }, + }, + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined), + }, + window: { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + }, + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue([]), + update: vi.fn(), + }), + onDidChangeConfiguration: vi.fn().mockImplementation(() => ({ + dispose: vi.fn(), + })), + onDidSaveTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidChangeTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidCloseTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + }, + env: { + uriScheme: "vscode", + language: "en", + appName: "Visual Studio Code", + }, + ExtensionMode: { + Production: 1, + Development: 2, + Test: 3, + }, + version: "1.85.0", +})) + +vi.mock("../../task/Task", () => ({ + Task: vi.fn().mockImplementation((options) => ({ + taskId: options.taskId || "test-task-id", + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + overwriteClineMessages: vi.fn(), + overwriteApiConversationHistory: vi.fn(), + abortTask: vi.fn(), + handleWebviewAskResponse: vi.fn(), + getTaskNumber: vi.fn().mockReturnValue(0), + setTaskNumber: vi.fn(), + setParentTask: vi.fn(), + setRootTask: vi.fn(), + emit: vi.fn(), + parentTask: options.parentTask, + updateApiConfiguration: vi.fn(), + setTaskApiConfigName: vi.fn(), + _taskApiConfigName: options.historyItem?.apiConfigName, + taskApiConfigName: options.historyItem?.apiConfigName, + })), +})) + +vi.mock("../../prompts/sections/custom-instructions") + +vi.mock("../../../utils/safeWriteJson") + +vi.mock("../../../api", () => ({ + buildApiHandler: vi.fn().mockReturnValue({ + getModel: vi.fn().mockReturnValue({ + id: "claude-3-sonnet", + }), + }), +})) + +vi.mock("../../../integrations/workspace/WorkspaceTracker", () => ({ + default: vi.fn().mockImplementation(() => ({ + initializeFilePaths: vi.fn(), + dispose: vi.fn(), + })), +})) + +vi.mock("../../diff/strategies/multi-search-replace", () => ({ + MultiSearchReplaceDiffStrategy: vi.fn().mockImplementation(() => ({ + getName: () => "test-strategy", + applyDiff: vi.fn(), + })), +})) + +vi.mock("@roo-code/cloud", () => ({ + CloudService: { + hasInstance: vi.fn().mockReturnValue(true), + get instance() { + return { + isAuthenticated: vi.fn().mockReturnValue(false), + } + }, + }, + BridgeOrchestrator: { + isEnabled: vi.fn().mockReturnValue(false), + }, + getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), +})) + +vi.mock("../../../shared/modes", () => { + const mockModes = [ + { + slug: "code", + name: "Code Mode", + roleDefinition: "You are a code assistant", + groups: ["read", "edit", "browser"], + }, + { + slug: "architect", + name: "Architect Mode", + roleDefinition: "You are an architect", + groups: ["read", "edit"], + }, + { + slug: "ask", + name: "Ask Mode", + roleDefinition: "You are an assistant", + groups: ["read"], + }, + { + slug: "debug", + name: "Debug Mode", + roleDefinition: "You are a debugger", + groups: ["read", "edit"], + }, + { + slug: "orchestrator", + name: "Orchestrator Mode", + roleDefinition: "You are an orchestrator", + groups: [], + }, + ] + + return { + modes: mockModes, + getAllModes: vi.fn((customModes?: Array<{ slug: string }>) => { + if (!customModes?.length) { + return [...mockModes] + } + const allModes = [...mockModes] + customModes.forEach((cm) => { + const idx = allModes.findIndex((m) => m.slug === cm.slug) + if (idx !== -1) { + allModes[idx] = cm as (typeof mockModes)[number] + } else { + allModes.push(cm as (typeof mockModes)[number]) + } + }) + return allModes + }), + getModeBySlug: vi.fn().mockReturnValue({ + slug: "code", + name: "Code Mode", + roleDefinition: "You are a code assistant", + groups: ["read", "edit", "browser"], + }), + defaultModeSlug: "code", + } +}) + +vi.mock("../../prompts/system", () => ({ + SYSTEM_PROMPT: vi.fn().mockResolvedValue("mocked system prompt"), + codeMode: "code", +})) + +vi.mock("../../../api/providers/fetchers/modelCache", () => ({ + getModels: vi.fn().mockResolvedValue({}), + flushModels: vi.fn(), +})) + +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"), +})) + +vi.mock("p-wait-for", () => ({ + default: vi.fn().mockImplementation(async () => Promise.resolve()), +})) + +vi.mock("fs/promises", () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + hasInstance: vi.fn().mockReturnValue(true), + createInstance: vi.fn(), + get instance() { + return { + trackEvent: vi.fn(), + trackError: vi.fn(), + setProvider: vi.fn(), + captureModeSwitch: vi.fn(), + } + }, + }, +})) + +describe("ClineProvider - Lock API Config Across Modes", () => { + let provider: ClineProvider + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + let mockWebviewView: vscode.WebviewView + + beforeEach(() => { + vi.clearAllMocks() + + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + const globalState: Record = { + mode: "code", + currentApiConfigName: "default-profile", + } + + const workspaceState: Record = {} + + const secrets: Record = {} + + mockContext = { + extensionPath: "/test/path", + extensionUri: {} as vscode.Uri, + globalState: { + get: vi.fn().mockImplementation((key: string) => globalState[key]), + update: vi.fn().mockImplementation((key: string, value: unknown) => { + globalState[key] = value + return Promise.resolve() + }), + keys: vi.fn().mockImplementation(() => Object.keys(globalState)), + }, + secrets: { + get: vi.fn().mockImplementation((key: string) => secrets[key]), + store: vi.fn().mockImplementation((key: string, value: string | undefined) => { + secrets[key] = value + return Promise.resolve() + }), + delete: vi.fn().mockImplementation((key: string) => { + delete secrets[key] + return Promise.resolve() + }), + }, + workspaceState: { + get: vi.fn().mockImplementation((key: string, defaultValue?: unknown) => { + return key in workspaceState ? workspaceState[key] : defaultValue + }), + update: vi.fn().mockImplementation((key: string, value: unknown) => { + workspaceState[key] = value + return Promise.resolve() + }), + keys: vi.fn().mockImplementation(() => Object.keys(workspaceState)), + }, + subscriptions: [], + extension: { + packageJSON: { version: "1.0.0" }, + }, + globalStorageUri: { + fsPath: "/test/storage/path", + }, + } as unknown as vscode.ExtensionContext + + mockOutputChannel = { + appendLine: vi.fn(), + clear: vi.fn(), + dispose: vi.fn(), + } as unknown as vscode.OutputChannel + + const mockPostMessage = vi.fn() + + mockWebviewView = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidDispose: vi.fn().mockImplementation((callback) => { + callback() + return { dispose: vi.fn() } + }), + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } as unknown as vscode.WebviewView + + provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // Mock getMcpHub method + provider.getMcpHub = vi.fn().mockReturnValue({ + listTools: vi.fn().mockResolvedValue([]), + callTool: vi.fn().mockResolvedValue({ content: [] }), + listResources: vi.fn().mockResolvedValue([]), + readResource: vi.fn().mockResolvedValue({ contents: [] }), + getAllServers: vi.fn().mockReturnValue([]), + }) + }) + + describe("handleModeSwitch honors lockApiConfigAcrossModes as a read-time override", () => { + beforeEach(async () => { + await provider.resolveWebviewView(mockWebviewView) + }) + + it("skips mode-specific config lookup/load when lockApiConfigAcrossModes is true", async () => { + await mockContext.workspaceState.update("lockApiConfigAcrossModes", true) + + const getModeConfigIdSpy = vi + .spyOn(provider.providerSettingsManager, "getModeConfigId") + .mockResolvedValue("architect-profile-id") + const listConfigSpy = vi + .spyOn(provider.providerSettingsManager, "listConfig") + .mockResolvedValue([ + { name: "architect-profile", id: "architect-profile-id", apiProvider: "anthropic" }, + ]) + const activateProviderProfileSpy = vi + .spyOn(provider, "activateProviderProfile") + .mockResolvedValue(undefined) + + await provider.handleModeSwitch("architect") + + expect(getModeConfigIdSpy).not.toHaveBeenCalled() + expect(listConfigSpy).not.toHaveBeenCalled() + expect(activateProviderProfileSpy).not.toHaveBeenCalled() + }) + + it("keeps normal mode-specific lookup/load behavior when lockApiConfigAcrossModes is false", async () => { + await mockContext.workspaceState.update("lockApiConfigAcrossModes", false) + + const getModeConfigIdSpy = vi + .spyOn(provider.providerSettingsManager, "getModeConfigId") + .mockResolvedValue("architect-profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "architect-profile", id: "architect-profile-id", apiProvider: "anthropic" }, + ]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "architect-profile", + apiProvider: "anthropic", + }) + + const activateProviderProfileSpy = vi + .spyOn(provider, "activateProviderProfile") + .mockResolvedValue(undefined) + + await provider.handleModeSwitch("architect") + + expect(getModeConfigIdSpy).toHaveBeenCalledWith("architect") + expect(activateProviderProfileSpy).toHaveBeenCalledWith({ name: "architect-profile" }) + }) + }) +}) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index b65b137597..5a57fa9678 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -327,6 +327,7 @@ vi.mock("@roo-code/cloud", () => ({ get instance() { return { isAuthenticated: vi.fn().mockReturnValue(false), + off: vi.fn(), } }, }, @@ -405,6 +406,11 @@ describe("ClineProvider", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, @@ -597,6 +603,43 @@ describe("ClineProvider", () => { expect(mockPostMessage).toHaveBeenCalledWith(message) }) + test("postMessageToWebview does not throw when webview is disposed", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Simulate postMessage throwing after webview disposal + mockPostMessage.mockRejectedValueOnce(new Error("Webview is disposed")) + + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + + // Should not throw + await expect(provider.postMessageToWebview(message)).resolves.toBeUndefined() + }) + + test("postMessageToWebview skips postMessage after dispose", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await provider.dispose() + mockPostMessage.mockClear() + + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + await provider.postMessageToWebview(message) + + expect(mockPostMessage).not.toHaveBeenCalled() + }) + + test("dispose is idempotent — second call is a no-op", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await provider.dispose() + await provider.dispose() + + // dispose body runs only once: log "Disposing ClineProvider..." appears once + const disposeCalls = (mockOutputChannel.appendLine as ReturnType).mock.calls.filter( + ([msg]) => typeof msg === "string" && msg.includes("Disposing ClineProvider..."), + ) + expect(disposeCalls).toHaveLength(1) + }) + test("handles webviewDidLaunch message", async () => { await provider.resolveWebviewView(mockWebviewView) @@ -2147,6 +2190,11 @@ describe("Project MCP Settings", () => { store: vi.fn(), delete: vi.fn(), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, @@ -2277,6 +2325,11 @@ describe.skip("ContextProxy integration", () => { update: vi.fn(), keys: vi.fn().mockReturnValue([]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, secrets: { get: vi.fn(), store: vi.fn(), delete: vi.fn() }, extensionUri: {} as vscode.Uri, globalStorageUri: { fsPath: "/test/path" }, @@ -2342,6 +2395,11 @@ describe("getTelemetryProperties", () => { update: vi.fn(), keys: vi.fn().mockReturnValue([]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, secrets: { get: vi.fn(), store: vi.fn(), delete: vi.fn() }, extensionUri: {} as vscode.Uri, globalStorageUri: { fsPath: "/test/path" }, @@ -2504,6 +2562,11 @@ describe("ClineProvider - Router Models", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, @@ -2552,7 +2615,6 @@ describe("ClineProvider - Router Models", () => { apiConfiguration: { openRouterApiKey: "openrouter-key", requestyApiKey: "requesty-key", - unboundApiKey: "unbound-key", litellmApiKey: "litellm-key", litellmBaseUrl: "http://localhost:4000", }, @@ -2581,9 +2643,7 @@ describe("ClineProvider - Router Models", () => { // Verify getModels was called for each provider with correct options expect(getModels).toHaveBeenCalledWith({ provider: "openrouter" }) expect(getModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" }) - expect(getModels).toHaveBeenCalledWith({ provider: "unbound", apiKey: "unbound-key" }) expect(getModels).toHaveBeenCalledWith({ provider: "vercel-ai-gateway" }) - expect(getModels).toHaveBeenCalledWith({ provider: "deepinfra" }) expect(getModels).toHaveBeenCalledWith( expect.objectContaining({ provider: "roo", @@ -2595,24 +2655,18 @@ describe("ClineProvider - Router Models", () => { apiKey: "litellm-key", baseUrl: "http://localhost:4000", }) - expect(getModels).toHaveBeenCalledWith({ provider: "chutes" }) // Verify response was sent expect(mockPostMessage).toHaveBeenCalledWith({ type: "routerModels", routerModels: { - deepinfra: mockModels, openrouter: mockModels, requesty: mockModels, - unbound: mockModels, roo: mockModels, - chutes: mockModels, litellm: mockModels, ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, - huggingface: {}, - "io-intelligence": {}, }, values: undefined, }) @@ -2626,7 +2680,6 @@ describe("ClineProvider - Router Models", () => { apiConfiguration: { openRouterApiKey: "openrouter-key", requestyApiKey: "requesty-key", - unboundApiKey: "unbound-key", litellmApiKey: "litellm-key", litellmBaseUrl: "http://localhost:4000", }, @@ -2641,11 +2694,8 @@ describe("ClineProvider - Router Models", () => { vi.mocked(getModels) .mockResolvedValueOnce(mockModels) // openrouter success .mockRejectedValueOnce(new Error("Requesty API error")) // requesty fail - .mockRejectedValueOnce(new Error("Unbound API error")) // unbound fail .mockResolvedValueOnce(mockModels) // vercel-ai-gateway success - .mockResolvedValueOnce(mockModels) // deepinfra success .mockResolvedValueOnce(mockModels) // roo success - .mockRejectedValueOnce(new Error("Chutes API error")) // chutes fail .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm fail await messageHandler({ type: "requestRouterModels" }) @@ -2654,18 +2704,13 @@ describe("ClineProvider - Router Models", () => { expect(mockPostMessage).toHaveBeenCalledWith({ type: "routerModels", routerModels: { - deepinfra: mockModels, openrouter: mockModels, requesty: {}, - unbound: {}, roo: mockModels, - chutes: {}, ollama: {}, lmstudio: {}, litellm: {}, "vercel-ai-gateway": mockModels, - huggingface: {}, - "io-intelligence": {}, }, values: undefined, }) @@ -2678,27 +2723,6 @@ describe("ClineProvider - Router Models", () => { values: { provider: "requesty" }, }) - expect(mockPostMessage).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Unbound API error", - values: { provider: "unbound" }, - }) - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Unbound API error", - values: { provider: "unbound" }, - }) - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Chutes API error", - values: { provider: "chutes" }, - }) - expect(mockPostMessage).toHaveBeenCalledWith({ type: "singleRouterModelFetchResponse", success: false, @@ -2716,7 +2740,6 @@ describe("ClineProvider - Router Models", () => { apiConfiguration: { openRouterApiKey: "openrouter-key", requestyApiKey: "requesty-key", - unboundApiKey: "unbound-key", // No litellm config }, } as any) @@ -2751,7 +2774,6 @@ describe("ClineProvider - Router Models", () => { apiConfiguration: { openRouterApiKey: "openrouter-key", requestyApiKey: "requesty-key", - unboundApiKey: "unbound-key", // No litellm config }, } as any) @@ -2775,18 +2797,13 @@ describe("ClineProvider - Router Models", () => { expect(mockPostMessage).toHaveBeenCalledWith({ type: "routerModels", routerModels: { - deepinfra: mockModels, openrouter: mockModels, requesty: mockModels, - unbound: mockModels, roo: mockModels, - chutes: mockModels, litellm: {}, ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, - huggingface: {}, - "io-intelligence": {}, }, values: undefined, }) @@ -2857,6 +2874,11 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, @@ -3770,4 +3792,53 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) }) }) + + describe("getTaskWithId", () => { + it("returns empty apiConversationHistory when file is missing", async () => { + const historyItem = { id: "missing-api-file-task", task: "test task", ts: Date.now() } + vi.mocked(mockContext.globalState.get).mockImplementation((key: string) => { + if (key === "taskHistory") { + return [historyItem] + } + return undefined + }) + + const deleteTaskSpy = vi.spyOn(provider, "deleteTaskFromState") + + const result = await (provider as any).getTaskWithId("missing-api-file-task") + + expect(result.historyItem).toEqual(historyItem) + expect(result.apiConversationHistory).toEqual([]) + expect(deleteTaskSpy).not.toHaveBeenCalled() + }) + + it("returns empty apiConversationHistory when file contains invalid JSON", async () => { + const historyItem = { id: "corrupt-api-task", task: "test task", ts: Date.now() } + vi.mocked(mockContext.globalState.get).mockImplementation((key: string) => { + if (key === "taskHistory") { + return [historyItem] + } + return undefined + }) + + // Make fileExistsAtPath return true so the read path is exercised + const fsUtils = await import("../../../utils/fs") + vi.spyOn(fsUtils, "fileExistsAtPath").mockResolvedValue(true) + + // Make readFile return corrupted JSON + const fsp = await import("fs/promises") + vi.mocked(fsp.readFile).mockResolvedValueOnce("{not valid json!!!" as never) + + const deleteTaskSpy = vi.spyOn(provider, "deleteTaskFromState") + + const result = await (provider as any).getTaskWithId("corrupt-api-task") + + expect(result.historyItem).toEqual(historyItem) + expect(result.apiConversationHistory).toEqual([]) + expect(deleteTaskSpy).not.toHaveBeenCalled() + + // Restore the spy + vi.mocked(fsUtils.fileExistsAtPath).mockRestore() + }) + }) }) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 27aab0b7da..af674d7a5e 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -227,6 +227,11 @@ describe("ClineProvider - Sticky Mode", () => { return Promise.resolve() }), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts index 80b14746a7..ee63b45b25 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -229,6 +229,11 @@ describe("ClineProvider - Sticky Provider Profile", () => { return Promise.resolve() }), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index f5e6afa7f0..aefed79744 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -287,6 +287,11 @@ describe("ClineProvider Task History Synchronization", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, @@ -415,6 +420,74 @@ describe("ClineProvider Task History Synchronization", () => { expect(taskHistoryItemUpdatedCalls.length).toBe(0) }) + it("preserves delegated metadata on partial update unless explicitly overwritten (UTH-02)", async () => { + await provider.resolveWebviewView(mockWebviewView) + provider.isViewLaunched = true + + const initial = createHistoryItem({ + id: "task-delegated-metadata", + task: "Delegated task", + status: "delegated", + delegatedToId: "child-1", + awaitingChildId: "child-1", + childIds: ["child-1"], + }) + + await provider.updateTaskHistory(initial, { broadcast: false }) + + // Partial update intentionally omits delegated metadata fields. + const partialUpdate: HistoryItem = { + ...createHistoryItem({ id: "task-delegated-metadata", task: "Delegated task (updated)" }), + status: "active", + } + + const updatedHistory = await provider.updateTaskHistory(partialUpdate, { broadcast: false }) + const updatedItem = updatedHistory.find((item) => item.id === "task-delegated-metadata") + + expect(updatedItem).toBeDefined() + expect(updatedItem?.status).toBe("active") + expect(updatedItem?.delegatedToId).toBe("child-1") + expect(updatedItem?.awaitingChildId).toBe("child-1") + expect(updatedItem?.childIds).toEqual(["child-1"]) + }) + + it("invalidates recentTasksCache on updateTaskHistory (UTH-04)", async () => { + const workspace = provider.cwd + const tsBase = Date.now() + + await provider.updateTaskHistory( + createHistoryItem({ + id: "cache-seed", + task: "Cache seed", + workspace, + ts: tsBase, + }), + { broadcast: false }, + ) + + const initialRecent = provider.getRecentTasks() + expect(initialRecent).toContain("cache-seed") + + // Prime cache and verify internal cache is set. + expect((provider as unknown as { recentTasksCache?: string[] }).recentTasksCache).toEqual(initialRecent) + + await provider.updateTaskHistory( + createHistoryItem({ + id: "cache-new", + task: "Cache new", + workspace, + ts: tsBase + 1, + }), + { broadcast: false }, + ) + + // Direct assertion for invalidation side-effect. + expect((provider as unknown as { recentTasksCache?: string[] }).recentTasksCache).toBeUndefined() + + const recomputedRecent = provider.getRecentTasks() + expect(recomputedRecent).toContain("cache-new") + }) + it("updates existing task in history", async () => { await provider.resolveWebviewView(mockWebviewView) provider.isViewLaunched = true @@ -592,4 +665,97 @@ describe("ClineProvider Task History Synchronization", () => { expect(state.taskHistory.some((item: HistoryItem) => item.workspace === "/different/workspace")).toBe(true) }) }) + + describe("taskHistory write lock (mutex)", () => { + it("serializes concurrent updateTaskHistory calls so no entries are lost", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Fire 5 concurrent updateTaskHistory calls + const items = Array.from({ length: 5 }, (_, i) => + createHistoryItem({ id: `concurrent-${i}`, task: `Task ${i}` }), + ) + + await Promise.all(items.map((item) => provider.updateTaskHistory(item, { broadcast: false }))) + + // All 5 entries must survive + const history = (provider as any).contextProxy.getGlobalState("taskHistory") as HistoryItem[] + const ids = history.map((h: HistoryItem) => h.id) + for (const item of items) { + expect(ids).toContain(item.id) + } + expect(history.length).toBe(5) + }) + + it("serializes concurrent update and deleteTaskFromState so they don't corrupt each other", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Seed with two items + const keep = createHistoryItem({ id: "keep-me", task: "Keep" }) + const remove = createHistoryItem({ id: "remove-me", task: "Remove" }) + await provider.updateTaskHistory(keep, { broadcast: false }) + await provider.updateTaskHistory(remove, { broadcast: false }) + + // Concurrently: add a new item AND delete "remove-me" + const newItem = createHistoryItem({ id: "new-item", task: "New" }) + await Promise.all([ + provider.updateTaskHistory(newItem, { broadcast: false }), + provider.deleteTaskFromState("remove-me"), + ]) + + const history = (provider as any).contextProxy.getGlobalState("taskHistory") as HistoryItem[] + const ids = history.map((h: HistoryItem) => h.id) + expect(ids).toContain("keep-me") + expect(ids).toContain("new-item") + expect(ids).not.toContain("remove-me") + }) + + it("does not block subsequent writes when a previous write errors", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Temporarily make updateGlobalState throw + const origUpdateGlobalState = (provider as any).updateGlobalState.bind(provider) + let callCount = 0 + ;(provider as any).updateGlobalState = vi.fn().mockImplementation((...args: unknown[]) => { + callCount++ + if (callCount === 1) { + return Promise.reject(new Error("simulated write failure")) + } + return origUpdateGlobalState(...args) + }) + + // First call should fail + const item1 = createHistoryItem({ id: "fail-item", task: "Fail" }) + await expect(provider.updateTaskHistory(item1, { broadcast: false })).rejects.toThrow( + "simulated write failure", + ) + + // Second call should still succeed (lock not stuck) + const item2 = createHistoryItem({ id: "ok-item", task: "OK" }) + const result = await provider.updateTaskHistory(item2, { broadcast: false }) + expect(result.some((h) => h.id === "ok-item")).toBe(true) + }) + + it("serializes concurrent updates to the same item preserving the last write", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const base = createHistoryItem({ id: "race-item", task: "Original" }) + await provider.updateTaskHistory(base, { broadcast: false }) + + // Fire two concurrent updates to the same item + await Promise.all([ + provider.updateTaskHistory(createHistoryItem({ id: "race-item", task: "Original", tokensIn: 111 }), { + broadcast: false, + }), + provider.updateTaskHistory(createHistoryItem({ id: "race-item", task: "Original", tokensIn: 222 }), { + broadcast: false, + }), + ]) + + const history = (provider as any).contextProxy.getGlobalState("taskHistory") as HistoryItem[] + const item = history.find((h: HistoryItem) => h.id === "race-item") + expect(item).toBeDefined() + // The second write (tokensIn: 222) should be the last one since writes are serialized + expect(item!.tokensIn).toBe(222) + }) + }) }) diff --git a/src/core/webview/__tests__/skillsMessageHandler.spec.ts b/src/core/webview/__tests__/skillsMessageHandler.spec.ts index f26194ee81..cdc571282f 100644 --- a/src/core/webview/__tests__/skillsMessageHandler.spec.ts +++ b/src/core/webview/__tests__/skillsMessageHandler.spec.ts @@ -52,6 +52,7 @@ describe("skillsMessageHandler", () => { const mockDeleteSkill = vi.fn() const mockMoveSkill = vi.fn() const mockGetSkill = vi.fn() + const mockFindSkillByNameAndSource = vi.fn() const createMockProvider = (hasSkillsManager: boolean = true): ClineProvider => { const skillsManager = hasSkillsManager @@ -61,6 +62,7 @@ describe("skillsMessageHandler", () => { deleteSkill: mockDeleteSkill, moveSkill: mockMoveSkill, getSkill: mockGetSkill, + findSkillByNameAndSource: mockFindSkillByNameAndSource, } : undefined @@ -158,7 +160,7 @@ describe("skillsMessageHandler", () => { } as WebviewMessage) expect(result).toEqual(mockSkills) - expect(mockCreateSkill).toHaveBeenCalledWith("new-skill", "project", "New skill description", "code") + expect(mockCreateSkill).toHaveBeenCalledWith("new-skill", "project", "New skill description", ["code"]) }) it("returns undefined when required fields are missing", async () => { @@ -355,7 +357,7 @@ describe("skillsMessageHandler", () => { describe("handleOpenSkillFile", () => { it("opens a skill file successfully", async () => { const provider = createMockProvider(true) - mockGetSkill.mockReturnValue(mockSkills[0]) + mockFindSkillByNameAndSource.mockReturnValue(mockSkills[0]) await handleOpenSkillFile(provider, { type: "openSkillFile", @@ -363,13 +365,13 @@ describe("skillsMessageHandler", () => { source: "global", } as WebviewMessage) - expect(mockGetSkill).toHaveBeenCalledWith("test-skill", "global", undefined) + expect(mockFindSkillByNameAndSource).toHaveBeenCalledWith("test-skill", "global") expect(openFile).toHaveBeenCalledWith("/path/to/test-skill/SKILL.md") }) it("opens a skill file with mode restriction", async () => { const provider = createMockProvider(true) - mockGetSkill.mockReturnValue(mockSkills[1]) + mockFindSkillByNameAndSource.mockReturnValue(mockSkills[1]) await handleOpenSkillFile(provider, { type: "openSkillFile", @@ -378,7 +380,7 @@ describe("skillsMessageHandler", () => { skillMode: "code", } as WebviewMessage) - expect(mockGetSkill).toHaveBeenCalledWith("project-skill", "project", "code") + expect(mockFindSkillByNameAndSource).toHaveBeenCalledWith("project-skill", "project") expect(openFile).toHaveBeenCalledWith("/project/.roo/skills/project-skill/SKILL.md") }) @@ -416,7 +418,7 @@ describe("skillsMessageHandler", () => { it("shows error when skill is not found", async () => { const provider = createMockProvider(true) - mockGetSkill.mockReturnValue(undefined) + mockFindSkillByNameAndSource.mockReturnValue(undefined) await handleOpenSkillFile(provider, { type: "openSkillFile", diff --git a/src/core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts new file mode 100644 index 0000000000..fd9b4a7740 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts @@ -0,0 +1,68 @@ +// npx vitest run core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts + +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +describe("webviewMessageHandler - lockApiConfigAcrossModes", () => { + let mockProvider: { + context: { + workspaceState: { + get: ReturnType + update: ReturnType + } + } + getState: ReturnType + postStateToWebview: ReturnType + providerSettingsManager: { + setModeConfig: ReturnType + } + postMessageToWebview: ReturnType + getCurrentTask: ReturnType + } + + beforeEach(() => { + vi.clearAllMocks() + + mockProvider = { + context: { + workspaceState: { + get: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + }, + }, + getState: vi.fn().mockResolvedValue({ + currentApiConfigName: "test-config", + listApiConfigMeta: [{ name: "test-config", id: "config-123" }], + customModes: [], + }), + postStateToWebview: vi.fn(), + providerSettingsManager: { + setModeConfig: vi.fn(), + }, + postMessageToWebview: vi.fn(), + getCurrentTask: vi.fn(), + } + }) + + it("sets lockApiConfigAcrossModes to true and posts state without mode config fan-out", async () => { + await webviewMessageHandler(mockProvider as unknown as ClineProvider, { + type: "lockApiConfigAcrossModes", + bool: true, + }) + + expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("lockApiConfigAcrossModes", true) + expect(mockProvider.providerSettingsManager.setModeConfig).not.toHaveBeenCalled() + expect(mockProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("sets lockApiConfigAcrossModes to false without applying to all modes", async () => { + await webviewMessageHandler(mockProvider as unknown as ClineProvider, { + type: "lockApiConfigAcrossModes", + bool: false, + }) + + expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("lockApiConfigAcrossModes", false) + expect(mockProvider.providerSettingsManager.setModeConfig).not.toHaveBeenCalled() + expect(mockProvider.postStateToWebview).toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index df2616a842..111b6c745d 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -74,14 +74,8 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } case "requesty": return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } - case "deepinfra": - return { "deepinfra/model": { contextWindow: 8192, supportsPromptCache: false } } - case "unbound": - return { "unbound/model": { contextWindow: 8192, supportsPromptCache: false } } case "vercel-ai-gateway": return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } - case "io-intelligence": - return { "io/model": { contextWindow: 8192, supportsPromptCache: false } } case "litellm": return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index faa8e92682..420d309fb7 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -265,7 +265,6 @@ describe("webviewMessageHandler - requestRouterModels", () => { apiConfiguration: { openRouterApiKey: "openrouter-key", requestyApiKey: "requesty-key", - unboundApiKey: "unbound-key", litellmApiKey: "litellm-key", litellmBaseUrl: "http://localhost:4000", }, @@ -297,9 +296,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { // Verify getModels was called for each provider expect(mockGetModels).toHaveBeenCalledWith({ provider: "openrouter" }) expect(mockGetModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" }) - expect(mockGetModels).toHaveBeenCalledWith({ provider: "unbound", apiKey: "unbound-key" }) expect(mockGetModels).toHaveBeenCalledWith({ provider: "vercel-ai-gateway" }) - expect(mockGetModels).toHaveBeenCalledWith({ provider: "deepinfra" }) expect(mockGetModels).toHaveBeenCalledWith( expect.objectContaining({ provider: "roo", @@ -311,25 +308,18 @@ describe("webviewMessageHandler - requestRouterModels", () => { apiKey: "litellm-key", baseUrl: "http://localhost:4000", }) - // Note: huggingface is not fetched in requestRouterModels - it has its own handler - // Note: io-intelligence is not fetched because no API key is provided in the mock state // Verify response was sent expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "routerModels", routerModels: { - deepinfra: mockModels, openrouter: mockModels, requesty: mockModels, - unbound: mockModels, litellm: mockModels, roo: mockModels, - chutes: mockModels, ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, - huggingface: {}, - "io-intelligence": {}, }, values: undefined, }) @@ -340,7 +330,6 @@ describe("webviewMessageHandler - requestRouterModels", () => { apiConfiguration: { openRouterApiKey: "openrouter-key", requestyApiKey: "requesty-key", - unboundApiKey: "unbound-key", // Missing litellm config }, }) @@ -377,7 +366,6 @@ describe("webviewMessageHandler - requestRouterModels", () => { apiConfiguration: { openRouterApiKey: "openrouter-key", requestyApiKey: "requesty-key", - unboundApiKey: "unbound-key", // Missing litellm config }, }) @@ -409,18 +397,13 @@ describe("webviewMessageHandler - requestRouterModels", () => { expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "routerModels", routerModels: { - deepinfra: mockModels, openrouter: mockModels, requesty: mockModels, - unbound: mockModels, roo: mockModels, - chutes: mockModels, litellm: {}, ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, - huggingface: {}, - "io-intelligence": {}, }, values: undefined, }) @@ -440,11 +423,8 @@ describe("webviewMessageHandler - requestRouterModels", () => { mockGetModels .mockResolvedValueOnce(mockModels) // openrouter .mockRejectedValueOnce(new Error("Requesty API error")) // requesty - .mockRejectedValueOnce(new Error("Unbound API error")) // unbound .mockResolvedValueOnce(mockModels) // vercel-ai-gateway - .mockResolvedValueOnce(mockModels) // deepinfra .mockResolvedValueOnce(mockModels) // roo - .mockRejectedValueOnce(new Error("Chutes API error")) // chutes .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm await webviewMessageHandler(mockClineProvider, { @@ -459,20 +439,6 @@ describe("webviewMessageHandler - requestRouterModels", () => { values: { provider: "requesty" }, }) - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Unbound API error", - values: { provider: "unbound" }, - }) - - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Chutes API error", - values: { provider: "chutes" }, - }) - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "singleRouterModelFetchResponse", success: false, @@ -484,18 +450,13 @@ describe("webviewMessageHandler - requestRouterModels", () => { expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "routerModels", routerModels: { - deepinfra: mockModels, openrouter: mockModels, requesty: {}, - unbound: {}, roo: mockModels, - chutes: {}, litellm: {}, ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, - huggingface: {}, - "io-intelligence": {}, }, values: undefined, }) @@ -506,11 +467,8 @@ describe("webviewMessageHandler - requestRouterModels", () => { mockGetModels .mockRejectedValueOnce(new Error("Structured error message")) // openrouter .mockRejectedValueOnce(new Error("Requesty API error")) // requesty - .mockRejectedValueOnce(new Error("Unbound API error")) // unbound .mockRejectedValueOnce(new Error("Vercel AI Gateway error")) // vercel-ai-gateway - .mockRejectedValueOnce(new Error("DeepInfra API error")) // deepinfra .mockRejectedValueOnce(new Error("Roo API error")) // roo - .mockRejectedValueOnce(new Error("Chutes API error")) // chutes .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm await webviewMessageHandler(mockClineProvider, { @@ -532,20 +490,6 @@ describe("webviewMessageHandler - requestRouterModels", () => { values: { provider: "requesty" }, }) - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Unbound API error", - values: { provider: "unbound" }, - }) - - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "DeepInfra API error", - values: { provider: "deepinfra" }, - }) - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "singleRouterModelFetchResponse", success: false, @@ -560,13 +504,6 @@ describe("webviewMessageHandler - requestRouterModels", () => { values: { provider: "roo" }, }) - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Chutes API error", - values: { provider: "chutes" }, - }) - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "singleRouterModelFetchResponse", success: false, diff --git a/src/core/webview/skillsMessageHandler.ts b/src/core/webview/skillsMessageHandler.ts index f09f22f58c..f5db0473fb 100644 --- a/src/core/webview/skillsMessageHandler.ts +++ b/src/core/webview/skillsMessageHandler.ts @@ -38,7 +38,8 @@ export async function handleCreateSkill( const skillName = message.skillName const source = message.source const skillDescription = message.skillDescription - const skillMode = message.skillMode + // Support new modeSlugs array or fall back to legacy skillMode + const modeSlugs = message.skillModeSlugs ?? (message.skillMode ? [message.skillMode] : undefined) if (!skillName || !source || !skillDescription) { throw new Error(t("skills:errors.missing_create_fields")) @@ -54,7 +55,7 @@ export async function handleCreateSkill( throw new Error(t("skills:errors.manager_unavailable")) } - const createdPath = await skillsManager.createSkill(skillName, source, skillDescription, skillMode) + const createdPath = await skillsManager.createSkill(skillName, source, skillDescription, modeSlugs) // Open the created file in the editor openFile(createdPath) @@ -81,7 +82,8 @@ export async function handleDeleteSkill( try { const skillName = message.skillName const source = message.source - const skillMode = message.skillMode + // Support new skillModeSlugs array or fall back to legacy skillMode + const skillMode = message.skillModeSlugs?.[0] ?? message.skillMode if (!skillName || !source) { throw new Error(t("skills:errors.missing_delete_fields")) @@ -152,6 +154,46 @@ export async function handleMoveSkill( } } +/** + * Handles the updateSkillModes message - updates the mode associations for a skill + */ +export async function handleUpdateSkillModes( + provider: ClineProvider, + message: WebviewMessage, +): Promise { + try { + const skillName = message.skillName + const source = message.source + const newModeSlugs = message.newSkillModeSlugs + + if (!skillName || !source) { + throw new Error(t("skills:errors.missing_update_modes_fields")) + } + + // Built-in skills cannot be modified + if (source === "built-in") { + throw new Error(t("skills:errors.cannot_modify_builtin")) + } + + const skillsManager = provider.getSkillsManager() + if (!skillsManager) { + throw new Error(t("skills:errors.manager_unavailable")) + } + + await skillsManager.updateSkillModes(skillName, source, newModeSlugs) + + // Send updated skills list + const skills = skillsManager.getSkillsMetadata() + await provider.postMessageToWebview({ type: "skills", skills }) + return skills + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Error updating skill modes: ${errorMessage}`) + vscode.window.showErrorMessage(`Failed to update skill modes: ${errorMessage}`) + return undefined + } +} + /** * Handles the openSkillFile message - opens a skill file in the editor */ @@ -159,7 +201,6 @@ export async function handleOpenSkillFile(provider: ClineProvider, message: Webv try { const skillName = message.skillName const source = message.source - const skillMode = message.skillMode if (!skillName || !source) { throw new Error(t("skills:errors.missing_delete_fields")) @@ -175,7 +216,8 @@ export async function handleOpenSkillFile(provider: ClineProvider, message: Webv throw new Error(t("skills:errors.manager_unavailable")) } - const skill = skillsManager.getSkill(skillName, source, skillMode) + // Find skill by name and source (skills may have modeSlugs arrays now) + const skill = skillsManager.findSkillByNameAndSource(skillName, source) if (!skill) { throw new Error(t("skills:errors.skill_not_found", { name: skillName })) } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 73ca3c60bf..14a5646d48 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -37,6 +37,7 @@ import { handleCreateSkill, handleDeleteSkill, handleMoveSkill, + handleUpdateSkillModes, handleOpenSkillFile, } from "./skillsMessageHandler" import { changeLanguage, t } from "../../i18n" @@ -497,12 +498,18 @@ export const webviewMessageHandler = async ( if (!checkExistKey(listApiConfig[0])) { const { apiConfiguration } = await provider.getState() - await provider.providerSettingsManager.saveConfig( - listApiConfig[0].name ?? "default", - apiConfiguration, - ) + // Only save if the current configuration has meaningful settings + // (e.g., API keys). This prevents saving a default "anthropic" + // fallback when no real config exists, which can happen during + // CLI initialization before provider settings are applied. + if (checkExistKey(apiConfiguration)) { + await provider.providerSettingsManager.saveConfig( + listApiConfig[0].name ?? "default", + apiConfiguration, + ) - listApiConfig[0].apiProvider = apiConfiguration.apiProvider + listApiConfig[0].apiProvider = apiConfiguration.apiProvider + } } } @@ -867,16 +874,11 @@ export const webviewMessageHandler = async ( : { openrouter: {}, "vercel-ai-gateway": {}, - huggingface: {}, litellm: {}, - deepinfra: {}, - "io-intelligence": {}, requesty: {}, - unbound: {}, ollama: {}, lmstudio: {}, roo: {}, - chutes: {}, } const safeGetModels = async (options: GetModelsOptions): Promise => { @@ -894,7 +896,7 @@ export const webviewMessageHandler = async ( // Base candidates (only those handled by this aggregate fetcher) const candidates: { key: RouterName; options: GetModelsOptions }[] = [ - { key: "openrouter", options: { provider: "openrouter" } }, + { key: "openrouter", options: { provider: "openrouter", baseUrl: apiConfiguration.openRouterBaseUrl } }, { key: "requesty", options: { @@ -903,16 +905,7 @@ export const webviewMessageHandler = async ( baseUrl: apiConfiguration.requestyBaseUrl, }, }, - { key: "unbound", options: { provider: "unbound", apiKey: apiConfiguration.unboundApiKey } }, { key: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } }, - { - key: "deepinfra", - options: { - provider: "deepinfra", - apiKey: apiConfiguration.deepInfraApiKey, - baseUrl: apiConfiguration.deepInfraBaseUrl, - }, - }, { key: "roo", options: { @@ -923,20 +916,8 @@ export const webviewMessageHandler = async ( : undefined, }, }, - { - key: "chutes", - options: { provider: "chutes", apiKey: apiConfiguration.chutesApiKey }, - }, ] - // IO Intelligence is conditional on api key - if (apiConfiguration.ioIntelligenceApiKey) { - candidates.push({ - key: "io-intelligence", - options: { provider: "io-intelligence", apiKey: apiConfiguration.ioIntelligenceApiKey }, - }) - } - // LiteLLM is conditional on baseUrl+apiKey const litellmApiKey = apiConfiguration.litellmApiKey || message?.values?.litellmApiKey const litellmBaseUrl = apiConfiguration.litellmBaseUrl || message?.values?.litellmBaseUrl @@ -1124,21 +1105,6 @@ export const webviewMessageHandler = async ( // TODO: Cache like we do for OpenRouter, etc? provider.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels }) break - case "requestHuggingFaceModels": - // TODO: Why isn't this handled by `requestRouterModels` above? - try { - const { getHuggingFaceModelsWithMetadata } = await import("../../api/providers/fetchers/huggingface") - const huggingFaceModelsResponse = await getHuggingFaceModelsWithMetadata() - - provider.postMessageToWebview({ - type: "huggingFaceModels", - huggingFaceModels: huggingFaceModelsResponse.models, - }) - } catch (error) { - console.error("Failed to fetch Hugging Face models:", error) - provider.postMessageToWebview({ type: "huggingFaceModels", huggingFaceModels: [] }) - } - break case "openImage": openImage(message.text!, { values: message.values }) break @@ -1654,6 +1620,14 @@ export const webviewMessageHandler = async ( await provider.postStateToWebview() break + case "lockApiConfigAcrossModes": { + const enabled = message.bool ?? false + await provider.context.workspaceState.update("lockApiConfigAcrossModes", enabled) + + await provider.postStateToWebview() + break + } + case "toggleApiConfigPin": if (message.text) { const currentPinned = getGlobalState("pinnedApiConfigs") ?? {} @@ -2992,6 +2966,10 @@ export const webviewMessageHandler = async ( await handleMoveSkill(provider, message) break } + case "updateSkillModes": { + await handleUpdateSkillModes(provider, message) + break + } case "openSkillFile": { await handleOpenSkillFile(provider, message) break diff --git a/src/esbuild.mjs b/src/esbuild.mjs index aabacfcee9..fb7b186679 100644 --- a/src/esbuild.mjs +++ b/src/esbuild.mjs @@ -43,6 +43,22 @@ async function main() { * @type {import('esbuild').Plugin[]} */ const plugins = [ + { + // Stub out @basetenlabs/performance-client which contains native .node + // binaries that esbuild cannot bundle. This module is only used by + // @ai-sdk/baseten for embedding models, not for chat completions. + name: "stub-baseten-native", + setup(build) { + build.onResolve({ filter: /^@basetenlabs\/performance-client/ }, (args) => ({ + path: args.path, + namespace: "stub-baseten-native", + })) + build.onLoad({ filter: /.*/, namespace: "stub-baseten-native" }, () => ({ + contents: "module.exports = { PerformanceClient: class PerformanceClient {} };", + loader: "js", + })) + }, + }, { name: "copyFiles", setup(build) { diff --git a/src/extension/api.ts b/src/extension/api.ts index e9c35861c5..a2b389abdc 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -20,17 +20,19 @@ import { IpcMessageType, } from "@roo-code/types" import { IpcServer } from "@roo-code/ipc" +import { CloudService } from "@roo-code/cloud" import { Package } from "../shared/package" import { ClineProvider } from "../core/webview/ClineProvider" import { openClineInNewTab } from "../activate/registerCommands" +import { getCommands } from "../services/command/commands" +import { getModels } from "../api/providers/fetchers/modelCache" export class API extends EventEmitter implements RooCodeAPI { private readonly outputChannel: vscode.OutputChannel private readonly sidebarProvider: ClineProvider private readonly context: vscode.ExtensionContext private readonly ipc?: IpcServer - private readonly taskMap = new Map() private readonly log: (...args: unknown[]) => void private logfile?: string @@ -65,35 +67,88 @@ export class API extends EventEmitter implements RooCodeAPI { ipc.listen() this.log(`[API] ipc server started: socketPath=${socketPath}, pid=${process.pid}, ppid=${process.ppid}`) - ipc.on(IpcMessageType.TaskCommand, async (_clientId, { commandName, data }) => { - switch (commandName) { + ipc.on(IpcMessageType.TaskCommand, async (clientId, command) => { + const sendResponse = (eventName: RooCodeEventName, payload: unknown[]) => { + ipc.send(clientId, { + type: IpcMessageType.TaskEvent, + origin: IpcOrigin.Server, + data: { eventName, payload } as TaskEvent, + }) + } + + switch (command.commandName) { case TaskCommandName.StartNewTask: - this.log(`[API] StartNewTask -> ${data.text}, ${JSON.stringify(data.configuration)}`) - await this.startNewTask(data) + this.log( + `[API] StartNewTask -> ${command.data.text}, ${JSON.stringify(command.data.configuration)}`, + ) + await this.startNewTask(command.data) break case TaskCommandName.CancelTask: - this.log(`[API] CancelTask -> ${data}`) - await this.cancelTask(data) + this.log(`[API] CancelTask`) + await this.cancelCurrentTask() break case TaskCommandName.CloseTask: - this.log(`[API] CloseTask -> ${data}`) + this.log(`[API] CloseTask`) await vscode.commands.executeCommand("workbench.action.files.saveFiles") await vscode.commands.executeCommand("workbench.action.closeWindow") break case TaskCommandName.ResumeTask: - this.log(`[API] ResumeTask -> ${data}`) + this.log(`[API] ResumeTask -> ${command.data}`) try { - await this.resumeTask(data) + await this.resumeTask(command.data) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) - this.log(`[API] ResumeTask failed for taskId ${data}: ${errorMessage}`) - // Don't rethrow - we want to prevent IPC server crashes - // The error is logged for debugging purposes + this.log(`[API] ResumeTask failed for taskId ${command.data}: ${errorMessage}`) + // Don't rethrow - we want to prevent IPC server crashes. + // The error is logged for debugging purposes. } break case TaskCommandName.SendMessage: - this.log(`[API] SendMessage -> ${data.text}`) - await this.sendMessage(data.text, data.images) + this.log(`[API] SendMessage -> ${command.data.text}`) + await this.sendMessage(command.data.text, command.data.images) + break + case TaskCommandName.GetCommands: + try { + const commands = await getCommands(this.sidebarProvider.cwd) + + sendResponse(RooCodeEventName.CommandsResponse, [ + commands.map((cmd) => ({ + name: cmd.name, + source: cmd.source, + filePath: cmd.filePath, + description: cmd.description, + argumentHint: cmd.argumentHint, + })), + ]) + } catch (error) { + sendResponse(RooCodeEventName.CommandsResponse, [[]]) + } + + break + case TaskCommandName.GetModes: + try { + const modes = await this.sidebarProvider.getModes() + sendResponse(RooCodeEventName.ModesResponse, [modes]) + } catch (error) { + sendResponse(RooCodeEventName.ModesResponse, [[]]) + } + + break + case TaskCommandName.GetModels: + try { + const models = await getModels({ + provider: "roo" as const, + baseUrl: process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy", + apiKey: CloudService.hasInstance() + ? CloudService.instance.authService?.getSessionToken() + : undefined, + }) + + sendResponse(RooCodeEventName.ModelsResponse, [models]) + } catch (error) { + sendResponse(RooCodeEventName.ModelsResponse, [{}]) + } + break } }) @@ -181,15 +236,6 @@ export class API extends EventEmitter implements RooCodeAPI { await this.sidebarProvider.cancelTask() } - public async cancelTask(taskId: string) { - const provider = this.taskMap.get(taskId) - - if (provider) { - await provider.cancelTask() - this.taskMap.delete(taskId) - } - } - public async sendMessage(text?: string, images?: string[]) { await this.sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images }) } @@ -212,7 +258,6 @@ export class API extends EventEmitter implements RooCodeAPI { task.on(RooCodeEventName.TaskStarted, async () => { this.emit(RooCodeEventName.TaskStarted, task.taskId) - this.taskMap.set(task.taskId, provider) await this.fileLog(`[${new Date().toISOString()}] taskStarted -> ${task.taskId}\n`) }) @@ -221,8 +266,6 @@ export class API extends EventEmitter implements RooCodeAPI { isSubtask: !!task.parentTaskId, }) - this.taskMap.delete(task.taskId) - await this.fileLog( `[${new Date().toISOString()}] taskCompleted -> ${task.taskId} | ${JSON.stringify(tokenUsage, null, 2)} | ${JSON.stringify(toolUsage, null, 2)}\n`, ) @@ -230,7 +273,6 @@ export class API extends EventEmitter implements RooCodeAPI { task.on(RooCodeEventName.TaskAborted, () => { this.emit(RooCodeEventName.TaskAborted, task.taskId) - this.taskMap.delete(task.taskId) }) task.on(RooCodeEventName.TaskFocused, () => { @@ -301,6 +343,10 @@ export class API extends EventEmitter implements RooCodeAPI { this.emit(RooCodeEventName.TaskAskResponded, task.taskId) }) + task.on(RooCodeEventName.QueuedMessagesUpdated, (taskId, messages) => { + this.emit(RooCodeEventName.QueuedMessagesUpdated, taskId, messages) + }) + // Task Analytics task.on(RooCodeEventName.TaskToolFailed, (taskId, tool, error) => { diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 9f8f961e73..33188fce19 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -114,15 +114,6 @@ "thinking_complete_safety": "(Pensament completat, però la sortida s'ha bloquejat a causa de la configuració de seguretat.)", "thinking_complete_recitation": "(Pensament completat, però la sortida s'ha bloquejat a causa de la comprovació de recitació.)" }, - "cerebras": { - "authenticationFailed": "Ha fallat l'autenticació de l'API de Cerebras. Comproveu que la vostra clau d'API sigui vàlida i no hagi caducat.", - "accessForbidden": "Accés denegat a l'API de Cerebras. La vostra clau d'API pot no tenir accés al model o funcionalitat sol·licitats.", - "rateLimitExceeded": "S'ha superat el límit de velocitat de l'API de Cerebras. Espereu abans de fer una altra sol·licitud.", - "serverError": "Error del servidor de l'API de Cerebras ({{status}}). Torneu-ho a provar més tard.", - "genericError": "Error de l'API de Cerebras ({{status}}): {{message}}", - "noResponseBody": "Error de l'API de Cerebras: No hi ha cos de resposta", - "completionError": "Error de finalització de Cerebras: {{error}}" - }, "roo": { "authenticationRequired": "El proveïdor Roo requereix autenticació al núvol. Si us plau, inicieu sessió a Roo Code Cloud." }, @@ -205,10 +196,7 @@ "enter_valid_path": "Introdueix una ruta vàlida" }, "settings": { - "providers": { - "groqApiKey": "Clau API de Groq", - "getGroqApiKey": "Obté la clau API de Groq" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/ca/skills.json b/src/i18n/locales/ca/skills.json index 74d8cba039..47b5993889 100644 --- a/src/i18n/locales/ca/skills.json +++ b/src/i18n/locales/ca/skills.json @@ -8,6 +8,7 @@ "not_found": "No s'ha trobat l'habilitat \"{{name}}\" a {{source}}{{modeInfo}}", "missing_create_fields": "Falten camps obligatoris: skillName, source o skillDescription", "missing_move_fields": "Falten camps obligatoris: skillName o source", + "missing_update_modes_fields": "Falten camps obligatoris: skillName o source", "manager_unavailable": "El gestor d'habilitats no està disponible", "missing_delete_fields": "Falten camps obligatoris: skillName o source", "skill_not_found": "No s'ha trobat l'habilitat \"{{name}}\"", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 086372dda8..861d9da576 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Denken abgeschlossen, aber die Ausgabe wurde aufgrund von Sicherheitseinstellungen blockiert.)", "thinking_complete_recitation": "(Denken abgeschlossen, aber die Ausgabe wurde aufgrund der Rezitationsprüfung blockiert.)" }, - "cerebras": { - "authenticationFailed": "Cerebras API-Authentifizierung fehlgeschlagen. Bitte überprüfe, ob dein API-Schlüssel gültig und nicht abgelaufen ist.", - "accessForbidden": "Cerebras API-Zugriff verweigert. Dein API-Schlüssel hat möglicherweise keinen Zugriff auf das angeforderte Modell oder die Funktion.", - "rateLimitExceeded": "Cerebras API-Ratenlimit überschritten. Bitte warte, bevor du eine weitere Anfrage stellst.", - "serverError": "Cerebras API-Serverfehler ({{status}}). Bitte versuche es später erneut.", - "genericError": "Cerebras API-Fehler ({{status}}): {{message}}", - "noResponseBody": "Cerebras API-Fehler: Kein Antworttext vorhanden", - "completionError": "Cerebras-Vervollständigungsfehler: {{error}}" - }, "roo": { "authenticationRequired": "Roo-Anbieter erfordert Cloud-Authentifizierung. Bitte melde dich bei Roo Code Cloud an." }, @@ -205,10 +196,7 @@ "task_placeholder": "Gib deine Aufgabe hier ein" }, "settings": { - "providers": { - "groqApiKey": "Groq API-Schlüssel", - "getGroqApiKey": "Groq API-Schlüssel erhalten" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/de/skills.json b/src/i18n/locales/de/skills.json index 5aad37950f..fe05128895 100644 --- a/src/i18n/locales/de/skills.json +++ b/src/i18n/locales/de/skills.json @@ -8,6 +8,7 @@ "not_found": "Skill \"{{name}}\" nicht gefunden in {{source}}{{modeInfo}}", "missing_create_fields": "Erforderliche Felder fehlen: skillName, source oder skillDescription", "missing_move_fields": "Erforderliche Felder fehlen: skillName oder source", + "missing_update_modes_fields": "Erforderliche Felder fehlen: skillName oder source", "manager_unavailable": "Skill-Manager nicht verfügbar", "missing_delete_fields": "Erforderliche Felder fehlen: skillName oder source", "skill_not_found": "Skill \"{{name}}\" nicht gefunden", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 636d26f76c..d65fe18367 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Thinking complete, but output was blocked due to safety settings.)", "thinking_complete_recitation": "(Thinking complete, but output was blocked due to recitation check.)" }, - "cerebras": { - "authenticationFailed": "Cerebras API authentication failed. Please check your API key is valid and not expired.", - "accessForbidden": "Cerebras API access forbidden. Your API key may not have access to the requested model or feature.", - "rateLimitExceeded": "Cerebras API rate limit exceeded. Please wait before making another request.", - "serverError": "Cerebras API server error ({{status}}). Please try again later.", - "genericError": "Cerebras API Error ({{status}}): {{message}}", - "noResponseBody": "Cerebras API Error: No response body", - "completionError": "Cerebras completion error: {{error}}" - }, "roo": { "authenticationRequired": "Roo provider requires cloud authentication. Please sign in to Roo Code Cloud." }, diff --git a/src/i18n/locales/en/skills.json b/src/i18n/locales/en/skills.json index ef4d7e68e3..5b6dde45b9 100644 --- a/src/i18n/locales/en/skills.json +++ b/src/i18n/locales/en/skills.json @@ -8,6 +8,7 @@ "not_found": "Skill \"{{name}}\" not found in {{source}}{{modeInfo}}", "missing_create_fields": "Missing required fields: skillName, source, or skillDescription", "missing_move_fields": "Missing required fields: skillName or source", + "missing_update_modes_fields": "Missing required fields: skillName or source", "manager_unavailable": "Skills manager not available", "missing_delete_fields": "Missing required fields: skillName or source", "skill_not_found": "Skill \"{{name}}\" not found", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index bc22040c6a..82be83956b 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Pensamiento completado, pero la salida fue bloqueada debido a la configuración de seguridad.)", "thinking_complete_recitation": "(Pensamiento completado, pero la salida fue bloqueada debido a la comprobación de recitación.)" }, - "cerebras": { - "authenticationFailed": "Falló la autenticación de la API de Cerebras. Verifica que tu clave de API sea válida y no haya expirado.", - "accessForbidden": "Acceso prohibido a la API de Cerebras. Tu clave de API puede no tener acceso al modelo o función solicitada.", - "rateLimitExceeded": "Se excedió el límite de velocidad de la API de Cerebras. Espera antes de hacer otra solicitud.", - "serverError": "Error del servidor de la API de Cerebras ({{status}}). Inténtalo de nuevo más tarde.", - "genericError": "Error de la API de Cerebras ({{status}}): {{message}}", - "noResponseBody": "Error de la API de Cerebras: Sin cuerpo de respuesta", - "completionError": "Error de finalización de Cerebras: {{error}}" - }, "roo": { "authenticationRequired": "El proveedor Roo requiere autenticación en la nube. Por favor, inicia sesión en Roo Code Cloud." }, @@ -205,10 +196,7 @@ "task_placeholder": "Escribe tu tarea aquí" }, "settings": { - "providers": { - "groqApiKey": "Clave API de Groq", - "getGroqApiKey": "Obtener clave API de Groq" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/es/skills.json b/src/i18n/locales/es/skills.json index 6534581518..84ab35b6d1 100644 --- a/src/i18n/locales/es/skills.json +++ b/src/i18n/locales/es/skills.json @@ -8,6 +8,7 @@ "not_found": "No se encontró la habilidad \"{{name}}\" en {{source}}{{modeInfo}}", "missing_create_fields": "Faltan campos obligatorios: skillName, source o skillDescription", "missing_move_fields": "Faltan campos obligatorios: skillName o source", + "missing_update_modes_fields": "Faltan campos obligatorios: skillName o source", "manager_unavailable": "El gestor de habilidades no está disponible", "missing_delete_fields": "Faltan campos obligatorios: skillName o source", "skill_not_found": "No se encontró la habilidad \"{{name}}\"", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index f7a76a53c1..6fc05ff94a 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Réflexion terminée, mais la sortie a été bloquée en raison des paramètres de sécurité.)", "thinking_complete_recitation": "(Réflexion terminée, mais la sortie a été bloquée en raison de la vérification de récitation.)" }, - "cerebras": { - "authenticationFailed": "Échec de l'authentification de l'API Cerebras. Vérifiez que votre clé API est valide et n'a pas expiré.", - "accessForbidden": "Accès interdit à l'API Cerebras. Votre clé API peut ne pas avoir accès au modèle ou à la fonction demandée.", - "rateLimitExceeded": "Limite de débit de l'API Cerebras dépassée. Veuillez attendre avant de faire une autre demande.", - "serverError": "Erreur du serveur de l'API Cerebras ({{status}}). Veuillez réessayer plus tard.", - "genericError": "Erreur de l'API Cerebras ({{status}}) : {{message}}", - "noResponseBody": "Erreur de l'API Cerebras : Aucun corps de réponse", - "completionError": "Erreur d'achèvement de Cerebras : {{error}}" - }, "roo": { "authenticationRequired": "Le fournisseur Roo nécessite une authentification cloud. Veuillez vous connecter à Roo Code Cloud." }, @@ -205,10 +196,7 @@ "task_placeholder": "Écris ta tâche ici" }, "settings": { - "providers": { - "groqApiKey": "Clé API Groq", - "getGroqApiKey": "Obtenir la clé API Groq" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/fr/skills.json b/src/i18n/locales/fr/skills.json index 5c4cb1f5ae..6320a4f55d 100644 --- a/src/i18n/locales/fr/skills.json +++ b/src/i18n/locales/fr/skills.json @@ -8,6 +8,7 @@ "not_found": "Compétence \"{{name}}\" introuvable dans {{source}}{{modeInfo}}", "missing_create_fields": "Champs obligatoires manquants : skillName, source ou skillDescription", "missing_move_fields": "Champs obligatoires manquants : skillName ou source", + "missing_update_modes_fields": "Champs obligatoires manquants : skillName ou source", "manager_unavailable": "Le gestionnaire de compétences n'est pas disponible", "missing_delete_fields": "Champs obligatoires manquants : skillName ou source", "skill_not_found": "Compétence \"{{name}}\" introuvable", diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index e51d177d94..528ed6d45f 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(सोचना पूरा हुआ, लेकिन सुरक्षा सेटिंग्स के कारण आउटपुट अवरुद्ध कर दिया गया।)", "thinking_complete_recitation": "(सोचना पूरा हुआ, लेकिन पाठ जाँच के कारण आउटपुट अवरुद्ध कर दिया गया।)" }, - "cerebras": { - "authenticationFailed": "Cerebras API प्रमाणीकरण विफल हुआ। कृपया जांचें कि आपकी API कुंजी वैध है और समाप्त नहीं हुई है।", - "accessForbidden": "Cerebras API पहुंच निषेध। आपकी API कुंजी का अनुरोधित मॉडल या सुविधा तक पहुंच नहीं हो सकती है।", - "rateLimitExceeded": "Cerebras API दर सीमा पार हो गई। कृपया दूसरा अनुरोध करने से पहले प्रतीक्षा करें।", - "serverError": "Cerebras API सर्वर त्रुटि ({{status}})। कृपया बाद में पुनः प्रयास करें।", - "genericError": "Cerebras API त्रुटि ({{status}}): {{message}}", - "noResponseBody": "Cerebras API त्रुटि: कोई प्रतिक्रिया मुख्य भाग नहीं", - "completionError": "Cerebras पूर्णता त्रुटि: {{error}}" - }, "roo": { "authenticationRequired": "Roo प्रदाता को क्लाउड प्रमाणीकरण की आवश्यकता है। कृपया Roo Code Cloud में साइन इन करें।" }, @@ -205,10 +196,7 @@ "task_placeholder": "अपना कार्य यहाँ लिखें" }, "settings": { - "providers": { - "groqApiKey": "ग्रोक एपीआई कुंजी", - "getGroqApiKey": "ग्रोक एपीआई कुंजी प्राप्त करें" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/hi/skills.json b/src/i18n/locales/hi/skills.json index 50929b4845..9b79cdb30f 100644 --- a/src/i18n/locales/hi/skills.json +++ b/src/i18n/locales/hi/skills.json @@ -8,6 +8,7 @@ "not_found": "स्किल \"{{name}}\" {{source}}{{modeInfo}} में नहीं मिला", "missing_create_fields": "आवश्यक फ़ील्ड गायब हैं: skillName, source, या skillDescription", "missing_move_fields": "आवश्यक फ़ील्ड गायब हैं: skillName या source", + "missing_update_modes_fields": "आवश्यक फ़ील्ड गायब हैं: skillName या source", "manager_unavailable": "स्किल मैनेजर उपलब्ध नहीं है", "missing_delete_fields": "आवश्यक फ़ील्ड गायब हैं: skillName या source", "skill_not_found": "स्किल \"{{name}}\" नहीं मिला", diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index cfb165979d..cb1c3231fb 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Berpikir selesai, tetapi output diblokir karena pengaturan keamanan.)", "thinking_complete_recitation": "(Berpikir selesai, tetapi output diblokir karena pemeriksaan resitasi.)" }, - "cerebras": { - "authenticationFailed": "Autentikasi API Cerebras gagal. Silakan periksa apakah kunci API Anda valid dan belum kedaluwarsa.", - "accessForbidden": "Akses API Cerebras ditolak. Kunci API Anda mungkin tidak memiliki akses ke model atau fitur yang diminta.", - "rateLimitExceeded": "Batas kecepatan API Cerebras terlampaui. Silakan tunggu sebelum membuat permintaan lain.", - "serverError": "Kesalahan server API Cerebras ({{status}}). Silakan coba lagi nanti.", - "genericError": "Kesalahan API Cerebras ({{status}}): {{message}}", - "noResponseBody": "Kesalahan API Cerebras: Tidak ada isi respons", - "completionError": "Kesalahan penyelesaian Cerebras: {{error}}" - }, "roo": { "authenticationRequired": "Penyedia Roo memerlukan autentikasi cloud. Silakan masuk ke Roo Code Cloud." }, @@ -205,10 +196,7 @@ "task_placeholder": "Ketik tugas kamu di sini" }, "settings": { - "providers": { - "groqApiKey": "Kunci API Groq", - "getGroqApiKey": "Dapatkan Kunci API Groq" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/id/skills.json b/src/i18n/locales/id/skills.json index cfa01b3323..6559a9d6b1 100644 --- a/src/i18n/locales/id/skills.json +++ b/src/i18n/locales/id/skills.json @@ -8,6 +8,7 @@ "not_found": "Skill \"{{name}}\" tidak ditemukan di {{source}}{{modeInfo}}", "missing_create_fields": "Bidang wajib tidak ada: skillName, source, atau skillDescription", "missing_move_fields": "Bidang wajib tidak ada: skillName atau source", + "missing_update_modes_fields": "Bidang wajib tidak ada: skillName atau source", "manager_unavailable": "Manajer skill tidak tersedia", "missing_delete_fields": "Bidang wajib tidak ada: skillName atau source", "skill_not_found": "Skill \"{{name}}\" tidak ditemukan", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index e5fa6d68db..b4e522cb73 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Pensiero completato, ma l'output è stato bloccato a causa delle impostazioni di sicurezza.)", "thinking_complete_recitation": "(Pensiero completato, ma l'output è stato bloccato a causa del controllo di recitazione.)" }, - "cerebras": { - "authenticationFailed": "Autenticazione API Cerebras fallita. Verifica che la tua chiave API sia valida e non scaduta.", - "accessForbidden": "Accesso API Cerebras negato. La tua chiave API potrebbe non avere accesso al modello o alla funzione richiesta.", - "rateLimitExceeded": "Limite di velocità API Cerebras superato. Attendi prima di fare un'altra richiesta.", - "serverError": "Errore del server API Cerebras ({{status}}). Riprova più tardi.", - "genericError": "Errore API Cerebras ({{status}}): {{message}}", - "noResponseBody": "Errore API Cerebras: Nessun corpo di risposta", - "completionError": "Errore di completamento Cerebras: {{error}}" - }, "roo": { "authenticationRequired": "Il provider Roo richiede l'autenticazione cloud. Accedi a Roo Code Cloud." }, @@ -205,10 +196,7 @@ "task_placeholder": "Scrivi il tuo compito qui" }, "settings": { - "providers": { - "groqApiKey": "Chiave API Groq", - "getGroqApiKey": "Ottieni chiave API Groq" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/it/skills.json b/src/i18n/locales/it/skills.json index 0ddcf0f70c..fdfd82e261 100644 --- a/src/i18n/locales/it/skills.json +++ b/src/i18n/locales/it/skills.json @@ -8,6 +8,7 @@ "not_found": "Skill \"{{name}}\" non trovata in {{source}}{{modeInfo}}", "missing_create_fields": "Campi obbligatori mancanti: skillName, source o skillDescription", "missing_move_fields": "Campi obbligatori mancanti: skillName o source", + "missing_update_modes_fields": "Campi obbligatori mancanti: skillName o source", "manager_unavailable": "Il gestore delle skill non è disponibile", "missing_delete_fields": "Campi obbligatori mancanti: skillName o source", "skill_not_found": "Skill \"{{name}}\" non trovata", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 7ebe0de597..7b63b6f729 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(思考完了、安全設定により出力ブロック)", "thinking_complete_recitation": "(思考完了、引用チェックにより出力ブロック)" }, - "cerebras": { - "authenticationFailed": "Cerebras API認証が失敗しました。APIキーが有効で期限切れではないことを確認してください。", - "accessForbidden": "Cerebras APIアクセスが禁止されています。あなたのAPIキーは要求されたモデルや機能にアクセスできない可能性があります。", - "rateLimitExceeded": "Cerebras APIレート制限を超過しました。別のリクエストを行う前にお待ちください。", - "serverError": "Cerebras APIサーバーエラー ({{status}})。しばらくしてからもう一度お試しください。", - "genericError": "Cerebras APIエラー ({{status}}): {{message}}", - "noResponseBody": "Cerebras APIエラー: レスポンスボディなし", - "completionError": "Cerebras完了エラー: {{error}}" - }, "roo": { "authenticationRequired": "Rooプロバイダーはクラウド認証が必要です。Roo Code Cloudにサインインしてください。" }, @@ -205,10 +196,7 @@ "task_placeholder": "タスクをここに入力してください" }, "settings": { - "providers": { - "groqApiKey": "Groq APIキー", - "getGroqApiKey": "Groq APIキーを取得" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/ja/skills.json b/src/i18n/locales/ja/skills.json index 16576b2be3..074baacdfd 100644 --- a/src/i18n/locales/ja/skills.json +++ b/src/i18n/locales/ja/skills.json @@ -8,6 +8,7 @@ "not_found": "スキル「{{name}}」が{{source}}{{modeInfo}}に見つかりません", "missing_create_fields": "必須フィールドが不足しています:skillName、source、またはskillDescription", "missing_move_fields": "必須フィールドが不足しています:skillNameまたはsource", + "missing_update_modes_fields": "必須フィールドが不足しています:skillNameまたはsource", "manager_unavailable": "スキルマネージャーが利用できません", "missing_delete_fields": "必須フィールドが不足しています:skillNameまたはsource", "skill_not_found": "スキル「{{name}}」が見つかりません", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 0c1ed5ba51..fbde3225bb 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(생각 완료, 안전 설정으로 출력 차단됨)", "thinking_complete_recitation": "(생각 완료, 암송 확인으로 출력 차단됨)" }, - "cerebras": { - "authenticationFailed": "Cerebras API 인증에 실패했습니다. API 키가 유효하고 만료되지 않았는지 확인하세요.", - "accessForbidden": "Cerebras API 액세스가 금지되었습니다. API 키가 요청된 모델이나 기능에 액세스할 수 없을 수 있습니다.", - "rateLimitExceeded": "Cerebras API 속도 제한을 초과했습니다. 다른 요청을 하기 전에 기다리세요.", - "serverError": "Cerebras API 서버 오류 ({{status}}). 나중에 다시 시도하세요.", - "genericError": "Cerebras API 오류 ({{status}}): {{message}}", - "noResponseBody": "Cerebras API 오류: 응답 본문 없음", - "completionError": "Cerebras 완료 오류: {{error}}" - }, "roo": { "authenticationRequired": "Roo 제공업체는 클라우드 인증이 필요합니다. Roo Code Cloud에 로그인하세요." }, @@ -205,10 +196,7 @@ "task_placeholder": "여기에 작업을 입력하세요" }, "settings": { - "providers": { - "groqApiKey": "Groq API 키", - "getGroqApiKey": "Groq API 키 받기" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/ko/skills.json b/src/i18n/locales/ko/skills.json index c5808f3630..5386675ea6 100644 --- a/src/i18n/locales/ko/skills.json +++ b/src/i18n/locales/ko/skills.json @@ -8,6 +8,7 @@ "not_found": "{{source}}{{modeInfo}}에서 스킬 \"{{name}}\"을(를) 찾을 수 없습니다", "missing_create_fields": "필수 필드 누락: skillName, source 또는 skillDescription", "missing_move_fields": "필수 필드 누락: skillName 또는 source", + "missing_update_modes_fields": "필수 필드 누락: skillName 또는 source", "manager_unavailable": "스킬 관리자를 사용할 수 없습니다", "missing_delete_fields": "필수 필드 누락: skillName 또는 source", "skill_not_found": "스킬 \"{{name}}\"을(를) 찾을 수 없습니다", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 0bbf569536..eba274c96e 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Nadenken voltooid, maar uitvoer is geblokkeerd vanwege veiligheidsinstellingen.)", "thinking_complete_recitation": "(Nadenken voltooid, maar uitvoer is geblokkeerd vanwege recitatiecontrole.)" }, - "cerebras": { - "authenticationFailed": "Cerebras API-authenticatie mislukt. Controleer of je API-sleutel geldig is en niet verlopen.", - "accessForbidden": "Cerebras API-toegang geweigerd. Je API-sleutel heeft mogelijk geen toegang tot het gevraagde model of de functie.", - "rateLimitExceeded": "Cerebras API-snelheidslimiet overschreden. Wacht voordat je een ander verzoek doet.", - "serverError": "Cerebras API-serverfout ({{status}}). Probeer het later opnieuw.", - "genericError": "Cerebras API-fout ({{status}}): {{message}}", - "noResponseBody": "Cerebras API-fout: Geen responslichaam", - "completionError": "Cerebras-voltooiingsfout: {{error}}" - }, "roo": { "authenticationRequired": "Roo provider vereist cloud authenticatie. Log in bij Roo Code Cloud." }, @@ -205,10 +196,7 @@ "task_placeholder": "Typ hier je taak" }, "settings": { - "providers": { - "groqApiKey": "Groq API-sleutel", - "getGroqApiKey": "Groq API-sleutel ophalen" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/nl/skills.json b/src/i18n/locales/nl/skills.json index 6c6e7e0e83..ed9caab43b 100644 --- a/src/i18n/locales/nl/skills.json +++ b/src/i18n/locales/nl/skills.json @@ -8,6 +8,7 @@ "not_found": "Vaardigheid \"{{name}}\" niet gevonden in {{source}}{{modeInfo}}", "missing_create_fields": "Vereiste velden ontbreken: skillName, source of skillDescription", "missing_move_fields": "Vereiste velden ontbreken: skillName of source", + "missing_update_modes_fields": "Vereiste velden ontbreken: skillName of source", "manager_unavailable": "Vaardigheidenbeheerder niet beschikbaar", "missing_delete_fields": "Vereiste velden ontbreken: skillName of source", "skill_not_found": "Vaardigheid \"{{name}}\" niet gevonden", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 23bc09e4d7..20b568281b 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Myślenie zakończone, ale dane wyjściowe zostały zablokowane przez ustawienia bezpieczeństwa.)", "thinking_complete_recitation": "(Myślenie zakończone, ale dane wyjściowe zostały zablokowane przez kontrolę recytacji.)" }, - "cerebras": { - "authenticationFailed": "Uwierzytelnianie API Cerebras nie powiodło się. Sprawdź, czy twój klucz API jest ważny i nie wygasł.", - "accessForbidden": "Dostęp do API Cerebras zabroniony. Twój klucz API może nie mieć dostępu do żądanego modelu lub funkcji.", - "rateLimitExceeded": "Przekroczono limit szybkości API Cerebras. Poczekaj przed wykonaniem kolejnego żądania.", - "serverError": "Błąd serwera API Cerebras ({{status}}). Spróbuj ponownie później.", - "genericError": "Błąd API Cerebras ({{status}}): {{message}}", - "noResponseBody": "Błąd API Cerebras: Brak treści odpowiedzi", - "completionError": "Błąd uzupełniania Cerebras: {{error}}" - }, "roo": { "authenticationRequired": "Dostawca Roo wymaga uwierzytelnienia w chmurze. Zaloguj się do Roo Code Cloud." }, @@ -205,10 +196,7 @@ "task_placeholder": "Wpisz swoje zadanie tutaj" }, "settings": { - "providers": { - "groqApiKey": "Klucz API Groq", - "getGroqApiKey": "Uzyskaj klucz API Groq" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/pl/skills.json b/src/i18n/locales/pl/skills.json index f9363e42d0..7a5e5f0ac1 100644 --- a/src/i18n/locales/pl/skills.json +++ b/src/i18n/locales/pl/skills.json @@ -8,6 +8,7 @@ "not_found": "Nie znaleziono umiejętności \"{{name}}\" w {{source}}{{modeInfo}}", "missing_create_fields": "Brakuje wymaganych pól: skillName, source lub skillDescription", "missing_move_fields": "Brakuje wymaganych pól: skillName lub source", + "missing_update_modes_fields": "Brakuje wymaganych pól: skillName lub source", "manager_unavailable": "Menedżer umiejętności niedostępny", "missing_delete_fields": "Brakuje wymaganych pól: skillName lub source", "skill_not_found": "Nie znaleziono umiejętności \"{{name}}\"", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 737b322f78..38abc8c804 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -115,15 +115,6 @@ "thinking_complete_safety": "(Pensamento concluído, mas a saída foi bloqueada devido às configurações de segurança.)", "thinking_complete_recitation": "(Pensamento concluído, mas a saída foi bloqueada devido à verificação de recitação.)" }, - "cerebras": { - "authenticationFailed": "Falha na autenticação da API Cerebras. Verifique se sua chave de API é válida e não expirou.", - "accessForbidden": "Acesso à API Cerebras negado. Sua chave de API pode não ter acesso ao modelo ou recurso solicitado.", - "rateLimitExceeded": "Limite de taxa da API Cerebras excedido. Aguarde antes de fazer outra solicitação.", - "serverError": "Erro do servidor da API Cerebras ({{status}}). Tente novamente mais tarde.", - "genericError": "Erro da API Cerebras ({{status}}): {{message}}", - "noResponseBody": "Erro da API Cerebras: Sem corpo de resposta", - "completionError": "Erro de conclusão do Cerebras: {{error}}" - }, "roo": { "authenticationRequired": "O provedor Roo requer autenticação na nuvem. Faça login no Roo Code Cloud." }, @@ -205,10 +196,7 @@ "enter_valid_path": "Por favor, digite um caminho válido" }, "settings": { - "providers": { - "groqApiKey": "Chave de API Groq", - "getGroqApiKey": "Obter chave de API Groq" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/pt-BR/skills.json b/src/i18n/locales/pt-BR/skills.json index 8058e9f6a3..eac683e7fe 100644 --- a/src/i18n/locales/pt-BR/skills.json +++ b/src/i18n/locales/pt-BR/skills.json @@ -8,6 +8,7 @@ "not_found": "Habilidade \"{{name}}\" não encontrada em {{source}}{{modeInfo}}", "missing_create_fields": "Campos obrigatórios ausentes: skillName, source ou skillDescription", "missing_move_fields": "Campos obrigatórios ausentes: skillName ou source", + "missing_update_modes_fields": "Campos obrigatórios ausentes: skillName ou source", "manager_unavailable": "Gerenciador de habilidades não disponível", "missing_delete_fields": "Campos obrigatórios ausentes: skillName ou source", "skill_not_found": "Habilidade \"{{name}}\" não encontrada", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 7ac53199ba..d124f59731 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Размышление завершено, но вывод заблокирован настройками безопасности.)", "thinking_complete_recitation": "(Размышление завершено, но вывод заблокирован проверкой цитирования.)" }, - "cerebras": { - "authenticationFailed": "Ошибка аутентификации Cerebras API. Убедитесь, что ваш API-ключ действителен и не истек.", - "accessForbidden": "Доступ к Cerebras API запрещен. Ваш API-ключ может не иметь доступа к запрашиваемой модели или функции.", - "rateLimitExceeded": "Превышен лимит скорости Cerebras API. Подождите перед отправкой следующего запроса.", - "serverError": "Ошибка сервера Cerebras API ({{status}}). Попробуйте позже.", - "genericError": "Ошибка Cerebras API ({{status}}): {{message}}", - "noResponseBody": "Ошибка Cerebras API: Нет тела ответа", - "completionError": "Ошибка завершения Cerebras: {{error}}" - }, "roo": { "authenticationRequired": "Провайдер Roo требует облачной аутентификации. Войдите в Roo Code Cloud." }, @@ -205,10 +196,7 @@ "task_placeholder": "Введите вашу задачу здесь" }, "settings": { - "providers": { - "groqApiKey": "Ключ API Groq", - "getGroqApiKey": "Получить ключ API Groq" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/ru/skills.json b/src/i18n/locales/ru/skills.json index 627a8fd4d6..740d813873 100644 --- a/src/i18n/locales/ru/skills.json +++ b/src/i18n/locales/ru/skills.json @@ -8,6 +8,7 @@ "not_found": "Навык \"{{name}}\" не найден в {{source}}{{modeInfo}}", "missing_create_fields": "Отсутствуют обязательные поля: skillName, source или skillDescription", "missing_move_fields": "Отсутствуют обязательные поля: skillName или source", + "missing_update_modes_fields": "Отсутствуют обязательные поля: skillName или source", "manager_unavailable": "Менеджер навыков недоступен", "missing_delete_fields": "Отсутствуют обязательные поля: skillName или source", "skill_not_found": "Навык \"{{name}}\" не найден", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index fca268c0ff..00dcf6fc33 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Düşünme tamamlandı, ancak çıktı güvenlik ayarları nedeniyle engellendi.)", "thinking_complete_recitation": "(Düşünme tamamlandı, ancak çıktı okuma kontrolü nedeniyle engellendi.)" }, - "cerebras": { - "authenticationFailed": "Cerebras API kimlik doğrulama başarısız oldu. API anahtarınızın geçerli olduğunu ve süresi dolmadığını kontrol edin.", - "accessForbidden": "Cerebras API erişimi yasak. API anahtarınız istenen modele veya özelliğe erişimi olmayabilir.", - "rateLimitExceeded": "Cerebras API hız sınırı aşıldı. Başka bir istek yapmadan önce bekleyin.", - "serverError": "Cerebras API sunucu hatası ({{status}}). Lütfen daha sonra tekrar deneyin.", - "genericError": "Cerebras API Hatası ({{status}}): {{message}}", - "noResponseBody": "Cerebras API Hatası: Yanıt gövdesi yok", - "completionError": "Cerebras tamamlama hatası: {{error}}" - }, "roo": { "authenticationRequired": "Roo sağlayıcısı bulut kimlik doğrulaması gerektirir. Lütfen Roo Code Cloud'a giriş yapın." }, @@ -205,10 +196,7 @@ "task_placeholder": "Görevini buraya yaz" }, "settings": { - "providers": { - "groqApiKey": "Groq API Anahtarı", - "getGroqApiKey": "Groq API Anahtarı Al" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/tr/skills.json b/src/i18n/locales/tr/skills.json index e7781aa696..235b9d55fc 100644 --- a/src/i18n/locales/tr/skills.json +++ b/src/i18n/locales/tr/skills.json @@ -8,6 +8,7 @@ "not_found": "\"{{name}}\" becerisi {{source}}{{modeInfo}} içinde bulunamadı", "missing_create_fields": "Gerekli alanlar eksik: skillName, source veya skillDescription", "missing_move_fields": "Gerekli alanlar eksik: skillName veya source", + "missing_update_modes_fields": "Gerekli alanlar eksik: skillName veya source", "manager_unavailable": "Beceri yöneticisi kullanılamıyor", "missing_delete_fields": "Gerekli alanlar eksik: skillName veya source", "skill_not_found": "\"{{name}}\" becerisi bulunamadı", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index bd9bb72b47..decd4ff53e 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Đã suy nghĩ xong nhưng kết quả bị chặn do cài đặt an toàn.)", "thinking_complete_recitation": "(Đã suy nghĩ xong nhưng kết quả bị chặn do kiểm tra trích dẫn.)" }, - "cerebras": { - "authenticationFailed": "Xác thực API Cerebras thất bại. Vui lòng kiểm tra khóa API của bạn có hợp lệ và chưa hết hạn.", - "accessForbidden": "Truy cập API Cerebras bị từ chối. Khóa API của bạn có thể không có quyền truy cập vào mô hình hoặc tính năng được yêu cầu.", - "rateLimitExceeded": "Vượt quá giới hạn tốc độ API Cerebras. Vui lòng chờ trước khi thực hiện yêu cầu khác.", - "serverError": "Lỗi máy chủ API Cerebras ({{status}}). Vui lòng thử lại sau.", - "genericError": "Lỗi API Cerebras ({{status}}): {{message}}", - "noResponseBody": "Lỗi API Cerebras: Không có nội dung phản hồi", - "completionError": "Lỗi hoàn thành Cerebras: {{error}}" - }, "roo": { "authenticationRequired": "Nhà cung cấp Roo yêu cầu xác thực đám mây. Vui lòng đăng nhập vào Roo Code Cloud." }, @@ -205,10 +196,7 @@ "task_placeholder": "Nhập nhiệm vụ của bạn ở đây" }, "settings": { - "providers": { - "groqApiKey": "Khóa API Groq", - "getGroqApiKey": "Lấy khóa API Groq" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/vi/skills.json b/src/i18n/locales/vi/skills.json index f97b7ed2b0..47e433ee57 100644 --- a/src/i18n/locales/vi/skills.json +++ b/src/i18n/locales/vi/skills.json @@ -8,6 +8,7 @@ "not_found": "Không tìm thấy kỹ năng \"{{name}}\" trong {{source}}{{modeInfo}}", "missing_create_fields": "Thiếu các trường bắt buộc: skillName, source hoặc skillDescription", "missing_move_fields": "Thiếu các trường bắt buộc: skillName hoặc source", + "missing_update_modes_fields": "Thiếu các trường bắt buộc: skillName hoặc source", "manager_unavailable": "Trình quản lý kỹ năng không khả dụng", "missing_delete_fields": "Thiếu các trường bắt buộc: skillName hoặc source", "skill_not_found": "Không tìm thấy kỹ năng \"{{name}}\"", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 494c246d65..6df1f78b16 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -116,15 +116,6 @@ "thinking_complete_safety": "(思考完成,但由于安全设置输出被阻止。)", "thinking_complete_recitation": "(思考完成,但由于引用检查输出被阻止。)" }, - "cerebras": { - "authenticationFailed": "Cerebras API 身份验证失败。请检查你的 API 密钥是否有效且未过期。", - "accessForbidden": "Cerebras API 访问被禁止。你的 API 密钥可能无法访问请求的模型或功能。", - "rateLimitExceeded": "Cerebras API 速率限制已超出。请稍等后再发起另一个请求。", - "serverError": "Cerebras API 服务器错误 ({{status}})。请稍后重试。", - "genericError": "Cerebras API 错误 ({{status}}):{{message}}", - "noResponseBody": "Cerebras API 错误:无响应主体", - "completionError": "Cerebras 完成错误:{{error}}" - }, "roo": { "authenticationRequired": "Roo 提供商需要云认证。请登录 Roo Code Cloud。" }, @@ -210,10 +201,7 @@ "task_placeholder": "在这里输入任务" }, "settings": { - "providers": { - "groqApiKey": "Groq API 密钥", - "getGroqApiKey": "获取 Groq API 密钥" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/zh-CN/skills.json b/src/i18n/locales/zh-CN/skills.json index 566f583fee..719bc722a5 100644 --- a/src/i18n/locales/zh-CN/skills.json +++ b/src/i18n/locales/zh-CN/skills.json @@ -8,6 +8,7 @@ "not_found": "在 {{source}}{{modeInfo}} 中未找到技能 \"{{name}}\"", "missing_create_fields": "缺少必填字段:skillName、source 或 skillDescription", "missing_move_fields": "缺少必填字段:skillName 或 source", + "missing_update_modes_fields": "缺少必填字段:skillName 或 source", "manager_unavailable": "技能管理器不可用", "missing_delete_fields": "缺少必填字段:skillName 或 source", "skill_not_found": "未找到技能 \"{{name}}\"", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 572cdb4651..be4a76fc5b 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -110,15 +110,6 @@ "thinking_complete_safety": "(思考完成,但由於安全設定輸出被阻止。)", "thinking_complete_recitation": "(思考完成,但由於引用檢查輸出被阻止。)" }, - "cerebras": { - "authenticationFailed": "Cerebras API 驗證失敗。請檢查您的 API 金鑰是否有效且未過期。", - "accessForbidden": "Cerebras API 存取被拒絕。您的 API 金鑰可能無法存取所請求的模型或功能。", - "rateLimitExceeded": "Cerebras API 速率限制已超出。請稍候再發出另一個請求。", - "serverError": "Cerebras API 伺服器錯誤 ({{status}})。請稍後重試。", - "genericError": "Cerebras API 錯誤 ({{status}}):{{message}}", - "noResponseBody": "Cerebras API 錯誤:無回應主體", - "completionError": "Cerebras 完成錯誤:{{error}}" - }, "roo": { "authenticationRequired": "Roo 提供者需要雲端認證。請登入 Roo Code Cloud。" }, @@ -205,10 +196,7 @@ "task_placeholder": "在這裡輸入工作" }, "settings": { - "providers": { - "groqApiKey": "Groq API 金鑰", - "getGroqApiKey": "取得 Groq API 金鑰" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/zh-TW/skills.json b/src/i18n/locales/zh-TW/skills.json index 633bb1a6b2..2d9a52be1e 100644 --- a/src/i18n/locales/zh-TW/skills.json +++ b/src/i18n/locales/zh-TW/skills.json @@ -8,6 +8,7 @@ "not_found": "在 {{source}}{{modeInfo}} 中找不到技能「{{name}}」", "missing_create_fields": "缺少必填欄位:skillName、source 或 skillDescription", "missing_move_fields": "缺少必填欄位:skillName 或 source", + "missing_update_modes_fields": "缺少必填欄位:skillName 或 source", "manager_unavailable": "技能管理器無法使用", "missing_delete_fields": "缺少必填欄位:skillName 或 source", "skill_not_found": "找不到技能「{{name}}」", diff --git a/src/integrations/terminal/README.md b/src/integrations/terminal/README.md deleted file mode 100644 index 4383167d97..0000000000 --- a/src/integrations/terminal/README.md +++ /dev/null @@ -1,66 +0,0 @@ -NOTICE TO DEVELOPERS: - -The Terminal classes are very sensitive to change, partially because of -the complicated way that shell integration works with VSCE, and -partially because of the way that Cline interacts with the Terminal\* -class abstractions that make VSCE shell integration easier to work with. - -At the point that PR #1365 is merged, it is unlikely that any Terminal\* -classes will need to be modified substantially. Generally speaking, we -should think of this as a stable interface and minimize changes. - -`TerminalProcess` class is particularly critical because it -provides all input handling and event notifications related to terminal -output to send it to the rest of the program. User interfaces for working -with data from terminals should only be as follows: - -1. By listening to the events: - - - this.on("completed", fullOutput) - provides full output upon completion - - this.on("line") - provides new lines, probably more than one - -2. By calling `this.getUnretrievedOutput()` - -This implementation intentionally returns all terminal output to the user -interfaces listed above. Any throttling or other stream modification _must_ -be implemented outside of this class. - -All other interfaces are private. - -Warning: Modifying the `TerminalProcess` class without fully understanding VSCE shell integration architecture may affect the reliability or performance of reading terminal output. - -`TerminalProcess` was carefully designed for performance and accuracy: - -Performance is obtained by: - Throttling event output on 100ms intervals - Using only indexes to access the output array - Maintaining a zero-copy implementation with a fullOutput string for storage - The fullOutput array is never split on carriage returns -as this was found to be very slow - Allowing multi-line chunks - Minimizing regular expression calls, as they have been tested to be -500x slower than the use of string parsing functions for large outputs -in this implementation - -Accuracy is obtained by: - Using only indexes against fullOutput - Paying close attention to off-by-one errors when indexing any content - Always returning exactly the content that was printed by the terminal, -including all carriage returns which may (or may not) have been in the -input stream - -Additional resources: - -- This implementation was rigorously tested using: - - - https://github.com/KJ7LNW/vsce-test-terminal-integration - -- There was a serious upstream bug that may not be fully solved, - or that may resurface in future VSCE releases, simply due to - the complexity of reliably handling terminal-provided escape - sequences across multiple shell implementations. This implementation - attempts to work around the problems and provide backwards - compatibility for VSCE releases that may not have the fix in - upstream bug #237208, but there still may be some unhandled - corner cases. See this ticket for more detail: - - - https://github.com/microsoft/vscode/issues/237208 - -- The original Cline PR has quite a bit of information: - - https://github.com/cline/cline/pull/1089 - -Contact me if you have any questions: - GitHub: KJ7LNW - Discord: kj7lnw - [roo-cline at z.ewheeler.org] - -Cheers, --Eric, KJ7LNW diff --git a/src/package.json b/src/package.json index 624b5b5b16..72fb3b5df9 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "RooVeterinaryInc", - "version": "3.46.1", + "version": "3.47.3", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91", @@ -450,13 +450,18 @@ "clean": "rimraf README.md CHANGELOG.md LICENSE dist logs mock .turbo" }, "dependencies": { - "@ai-sdk/cerebras": "^1.0.0", - "@ai-sdk/deepseek": "^2.0.14", - "@ai-sdk/fireworks": "^2.0.26", - "@ai-sdk/groq": "^3.0.19", - "@anthropic-ai/bedrock-sdk": "^0.10.2", + "@ai-sdk/amazon-bedrock": "^4.0.51", + "@ai-sdk/anthropic": "^3.0.38", + "@ai-sdk/azure": "^2.0.6", + "@ai-sdk/baseten": "^1.0.31", + "@ai-sdk/deepseek": "^2.0.18", + "@ai-sdk/fireworks": "^2.0.32", + "@ai-sdk/google": "^3.0.22", + "@ai-sdk/google-vertex": "^4.0.45", + "@ai-sdk/mistral": "^3.0.19", + "@ai-sdk/openai": "^3.0.26", + "@ai-sdk/xai": "^3.0.48", "@anthropic-ai/sdk": "^0.37.0", - "@anthropic-ai/vertex-sdk": "^0.7.0", "@aws-sdk/client-bedrock-runtime": "^3.922.0", "@aws-sdk/credential-providers": "^3.922.0", "@google/genai": "^1.29.1", @@ -486,7 +491,6 @@ "fzf": "^0.5.2", "get-folder-size": "^5.0.0", "global-agent": "^3.0.0", - "google-auth-library": "^9.15.1", "gray-matter": "^4.0.3", "i18next": "^25.0.0", "ignore": "^7.0.3", @@ -513,6 +517,7 @@ "puppeteer-core": "^23.4.0", "reconnecting-eventsource": "^1.6.4", "safe-stable-stringify": "^2.5.0", + "sambanova-ai-provider": "^1.2.2", "sanitize-filename": "^1.6.3", "say": "^0.16.0", "semver-compare": "^1.0.0", @@ -535,11 +540,12 @@ "web-tree-sitter": "^0.25.6", "workerpool": "^9.2.0", "yaml": "^2.8.0", + "zhipu-ai-provider": "^0.2.2", "zod": "3.25.76" }, "devDependencies": { - "@ai-sdk/openai-compatible": "^1.0.0", - "@openrouter/ai-sdk-provider": "^2.0.4", + "@ai-sdk/openai-compatible": "^2.0.28", + "@openrouter/ai-sdk-provider": "^2.1.1", "@roo-code/build": "workspace:^", "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", @@ -564,7 +570,7 @@ "@types/vscode": "^1.84.0", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "3.3.2", - "ai": "^6.0.0", + "ai": "^6.0.75", "esbuild-wasm": "^0.25.0", "execa": "^9.5.2", "glob": "^11.1.0", diff --git a/src/services/code-index/__tests__/service-factory.spec.ts b/src/services/code-index/__tests__/service-factory.spec.ts index 1d8f7ba478..3e943ebd82 100644 --- a/src/services/code-index/__tests__/service-factory.spec.ts +++ b/src/services/code-index/__tests__/service-factory.spec.ts @@ -286,7 +286,7 @@ describe("CodeIndexServiceFactory", () => { // Arrange const testConfig = { embedderProvider: "gemini", - modelId: "text-embedding-004", + modelId: "gemini-embedding-001", geminiOptions: { apiKey: "test-gemini-api-key", }, @@ -297,6 +297,25 @@ describe("CodeIndexServiceFactory", () => { factory.createEmbedder() // Assert + expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", "gemini-embedding-001") + }) + + it("should pass deprecated text-embedding-004 modelId to GeminiEmbedder (migration happens inside GeminiEmbedder)", () => { + // Arrange - service-factory passes the config modelId directly; + // GeminiEmbedder handles the migration internally + const testConfig = { + embedderProvider: "gemini", + modelId: "text-embedding-004", + geminiOptions: { + apiKey: "test-gemini-api-key", + }, + } + mockConfigManager.getConfig.mockReturnValue(testConfig as any) + + // Act + factory.createEmbedder() + + // Assert - factory passes the original modelId; GeminiEmbedder migrates it internally expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", "text-embedding-004") }) diff --git a/src/services/code-index/embedders/__tests__/gemini.spec.ts b/src/services/code-index/embedders/__tests__/gemini.spec.ts index d41a4dc1e9..d84dcd8abc 100644 --- a/src/services/code-index/embedders/__tests__/gemini.spec.ts +++ b/src/services/code-index/embedders/__tests__/gemini.spec.ts @@ -44,7 +44,7 @@ describe("GeminiEmbedder", () => { it("should create an instance with specified model", () => { // Arrange const apiKey = "test-gemini-api-key" - const modelId = "text-embedding-004" + const modelId = "gemini-embedding-001" // Act embedder = new GeminiEmbedder(apiKey, modelId) @@ -53,7 +53,24 @@ describe("GeminiEmbedder", () => { expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith( "https://generativelanguage.googleapis.com/v1beta/openai/", apiKey, - "text-embedding-004", + "gemini-embedding-001", + 2048, + ) + }) + + it("should migrate deprecated text-embedding-004 to gemini-embedding-001", () => { + // Arrange + const apiKey = "test-gemini-api-key" + const deprecatedModelId = "text-embedding-004" + + // Act + embedder = new GeminiEmbedder(apiKey, deprecatedModelId) + + // Assert - should be migrated to gemini-embedding-001 + expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith( + "https://generativelanguage.googleapis.com/v1beta/openai/", + apiKey, + "gemini-embedding-001", 2048, ) }) @@ -109,8 +126,8 @@ describe("GeminiEmbedder", () => { }) it("should use provided model parameter when specified", async () => { - // Arrange - embedder = new GeminiEmbedder("test-api-key", "text-embedding-004") + // Arrange - even with deprecated model in constructor, the runtime parameter takes precedence + embedder = new GeminiEmbedder("test-api-key", "gemini-embedding-001") const texts = ["test text 1", "test text 2"] const mockResponse = { embeddings: [ @@ -120,7 +137,7 @@ describe("GeminiEmbedder", () => { } mockCreateEmbeddings.mockResolvedValue(mockResponse) - // Act + // Act - specify a different model at runtime const result = await embedder.createEmbeddings(texts, "gemini-embedding-001") // Assert diff --git a/src/services/code-index/embedders/gemini.ts b/src/services/code-index/embedders/gemini.ts index 7e795875c9..03bfc35aae 100644 --- a/src/services/code-index/embedders/gemini.ts +++ b/src/services/code-index/embedders/gemini.ts @@ -10,15 +10,33 @@ import { TelemetryService } from "@roo-code/telemetry" * with configuration for Google's Gemini embedding API. * * Supported models: - * - text-embedding-004 (dimension: 768) - * - gemini-embedding-001 (dimension: 2048) + * - gemini-embedding-001 (dimension: 3072) + * + * Note: text-embedding-004 has been deprecated and is automatically + * migrated to gemini-embedding-001 for backward compatibility. */ export class GeminiEmbedder implements IEmbedder { private readonly openAICompatibleEmbedder: OpenAICompatibleEmbedder private static readonly GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/" private static readonly DEFAULT_MODEL = "gemini-embedding-001" + /** + * Deprecated models that are automatically migrated to their replacements. + * Users with these models configured will be silently migrated without interruption. + */ + private static readonly DEPRECATED_MODEL_MIGRATIONS: Record = { + "text-embedding-004": "gemini-embedding-001", + } private readonly modelId: string + /** + * Migrates deprecated model IDs to their replacements. + * @param modelId The model ID to potentially migrate + * @returns The migrated model ID, or the original if no migration is needed + */ + private static migrateModelId(modelId: string): string { + return GeminiEmbedder.DEPRECATED_MODEL_MIGRATIONS[modelId] ?? modelId + } + /** * Creates a new Gemini embedder * @param apiKey The Gemini API key for authentication @@ -29,8 +47,11 @@ export class GeminiEmbedder implements IEmbedder { throw new Error(t("embeddings:validation.apiKeyRequired")) } - // Use provided model or default - this.modelId = modelId || GeminiEmbedder.DEFAULT_MODEL + // Migrate deprecated models to their replacements silently + const migratedModelId = modelId ? GeminiEmbedder.migrateModelId(modelId) : undefined + + // Use provided model (after migration) or default + this.modelId = migratedModelId || GeminiEmbedder.DEFAULT_MODEL // Create an OpenAI Compatible embedder with Gemini's configuration this.openAICompatibleEmbedder = new OpenAICompatibleEmbedder( diff --git a/src/services/roo-config/__tests__/index.spec.ts b/src/services/roo-config/__tests__/index.spec.ts index c060cdcb5a..1775d5502c 100644 --- a/src/services/roo-config/__tests__/index.spec.ts +++ b/src/services/roo-config/__tests__/index.spec.ts @@ -28,7 +28,9 @@ vi.mock("../../search/file-search", () => ({ import { getGlobalRooDirectory, + getGlobalAgentsDirectory, getProjectRooDirectoryForCwd, + getProjectAgentsDirectoryForCwd, directoryExists, fileExists, readFileIfExists, @@ -70,6 +72,27 @@ describe("RooConfigService", () => { }) }) + describe("getGlobalAgentsDirectory", () => { + it("should return correct path for global .agents directory", () => { + const result = getGlobalAgentsDirectory() + expect(result).toBe(path.join("/mock/home", ".agents")) + }) + + it("should handle different home directories", () => { + mockHomedir.mockReturnValue("/different/home") + const result = getGlobalAgentsDirectory() + expect(result).toBe(path.join("/different/home", ".agents")) + }) + }) + + describe("getProjectAgentsDirectoryForCwd", () => { + it("should return correct path for given cwd", () => { + const cwd = "/custom/project/path" + const result = getProjectAgentsDirectoryForCwd(cwd) + expect(result).toBe(path.join(cwd, ".agents")) + }) + }) + describe("directoryExists", () => { it("should return true for existing directory", async () => { mockStat.mockResolvedValue({ isDirectory: () => true } as any) diff --git a/src/services/roo-config/index.ts b/src/services/roo-config/index.ts index 166617834d..b97e01f5b5 100644 --- a/src/services/roo-config/index.ts +++ b/src/services/roo-config/index.ts @@ -28,6 +28,50 @@ export function getGlobalRooDirectory(): string { return path.join(homeDir, ".roo") } +/** + * Gets the global .agents directory path based on the current platform. + * This is a shared directory for agent skills across different AI coding tools. + * + * @returns The absolute path to the global .agents directory + * + * @example Platform-specific paths: + * ``` + * // macOS/Linux: ~/.agents/ + * // Example: /Users/john/.agents + * + * // Windows: %USERPROFILE%\.agents\ + * // Example: C:\Users\john\.agents + * ``` + * + * @example Usage: + * ```typescript + * const globalAgentsDir = getGlobalAgentsDirectory() + * // Returns: "/Users/john/.agents" (on macOS/Linux) + * // Returns: "C:\\Users\\john\\.agents" (on Windows) + * ``` + */ +export function getGlobalAgentsDirectory(): string { + const homeDir = os.homedir() + return path.join(homeDir, ".agents") +} + +/** + * Gets the project-local .agents directory path for a given cwd. + * This is a shared directory for agent skills across different AI coding tools. + * + * @param cwd - Current working directory (project path) + * @returns The absolute path to the project-local .agents directory + * + * @example + * ```typescript + * const projectAgentsDir = getProjectAgentsDirectoryForCwd('/Users/john/my-project') + * // Returns: "/Users/john/my-project/.agents" + * ``` + */ +export function getProjectAgentsDirectoryForCwd(cwd: string): string { + return path.join(cwd, ".agents") +} + /** * Gets the project-local .roo directory path for a given cwd * diff --git a/src/services/skills/SkillsManager.ts b/src/services/skills/SkillsManager.ts index 05d879d975..f6f86e8573 100644 --- a/src/services/skills/SkillsManager.ts +++ b/src/services/skills/SkillsManager.ts @@ -5,7 +5,7 @@ import * as vscode from "vscode" import matter from "gray-matter" import type { ClineProvider } from "../../core/webview/ClineProvider" -import { getGlobalRooDirectory } from "../roo-config" +import { getGlobalRooDirectory, getGlobalAgentsDirectory, getProjectAgentsDirectoryForCwd } from "../roo-config" import { directoryExists, fileExists } from "../roo-config" import { SkillMetadata, SkillContent } from "../../shared/skills" import { modes, getAllModes } from "../../shared/modes" @@ -143,15 +143,34 @@ export class SkillsManager { return } - // Create unique key combining name, source, and mode for override resolution - const skillKey = this.getSkillKey(effectiveSkillName, source, mode) + // Parse modeSlugs from frontmatter (new format) or fall back to directory-based mode + // Priority: frontmatter.modeSlugs > frontmatter.mode > directory mode + let modeSlugs: string[] | undefined + if (Array.isArray(frontmatter.modeSlugs)) { + modeSlugs = frontmatter.modeSlugs.filter((s: unknown) => typeof s === "string" && s.length > 0) + if (modeSlugs.length === 0) { + modeSlugs = undefined // Empty array means "any mode" + } + } else if (typeof frontmatter.mode === "string" && frontmatter.mode.length > 0) { + // Legacy single mode in frontmatter + modeSlugs = [frontmatter.mode] + } else if (mode) { + // Fall back to directory-based mode (skills-{mode}/) + modeSlugs = [mode] + } + + // Create unique key combining name, source, and modeSlugs for override resolution + // For backward compatibility, use first mode slug or undefined for the key + const primaryMode = modeSlugs?.[0] + const skillKey = this.getSkillKey(effectiveSkillName, source, primaryMode) this.skills.set(skillKey, { name: effectiveSkillName, description, path: skillMdPath, source, - mode, // undefined for generic skills, string for mode-specific + mode: primaryMode, // Deprecated: kept for backward compatibility + modeSlugs, // New: array of mode slugs, undefined = any mode }) } catch (error) { console.error(`Failed to load skill at ${skillDir}:`, error) @@ -174,8 +193,11 @@ export class SkillsManager { // Then, add discovered skills (will override built-in skills with same name) for (const skill of this.skills.values()) { - // Skip mode-specific skills that don't match current mode - if (skill.mode && skill.mode !== currentMode) continue + // Check if skill is available in current mode: + // - modeSlugs undefined or empty = available in all modes ("Any mode") + // - modeSlugs array with values = available only if currentMode is in the array + const isAvailableInMode = this.isSkillAvailableInMode(skill, currentMode) + if (!isAvailableInMode) continue const existingSkill = resolvedSkills.get(skill.name) @@ -194,6 +216,20 @@ export class SkillsManager { return Array.from(resolvedSkills.values()) } + /** + * Check if a skill is available in the given mode. + * - modeSlugs undefined or empty = available in all modes ("Any mode") + * - modeSlugs with values = available only if mode is in the array + */ + private isSkillAvailableInMode(skill: SkillMetadata, currentMode: string): boolean { + // No mode restrictions = available in all modes + if (!skill.modeSlugs || skill.modeSlugs.length === 0) { + return true + } + // Check if current mode is in the allowed modes + return skill.modeSlugs.includes(currentMode) + } + /** * Determine if newSkill should override existingSkill based on priority rules. * Priority: project > global > built-in, mode-specific > generic @@ -214,8 +250,11 @@ export class SkillsManager { if (newPriority < existingPriority) return false // Same source: mode-specific overrides generic - if (newSkill.mode && !existing.mode) return true - if (!newSkill.mode && existing.mode) return false + // A skill with modeSlugs (restricted) is more specific than one without (any mode) + const existingHasModes = existing.modeSlugs && existing.modeSlugs.length > 0 + const newHasModes = newSkill.modeSlugs && newSkill.modeSlugs.length > 0 + if (newHasModes && !existingHasModes) return true + if (!newHasModes && existingHasModes) return false // Same source and same mode-specificity: keep existing (first wins) return false @@ -276,6 +315,19 @@ export class SkillsManager { return this.skills.get(skillKey) } + /** + * Find a skill by name and source (regardless of mode). + * Useful for opening/editing skills where the exact mode key may vary. + */ + findSkillByNameAndSource(name: string, source: "global" | "project"): SkillMetadata | undefined { + for (const skill of this.skills.values()) { + if (skill.name === name && skill.source === source) { + return skill + } + } + return undefined + } + /** * Validate skill name per agentskills.io spec using shared validation. * Converts error codes to user-friendly error messages. @@ -307,10 +359,15 @@ export class SkillsManager { * @param name - Skill name (must be valid per agentskills.io spec) * @param source - "global" or "project" * @param description - Skill description - * @param mode - Optional mode restriction (creates in skills-{mode}/ directory) + * @param modeSlugs - Optional mode restrictions (undefined/empty = any mode) * @returns Path to created SKILL.md file */ - async createSkill(name: string, source: "global" | "project", description: string, mode?: string): Promise { + async createSkill( + name: string, + source: "global" | "project", + description: string, + modeSlugs?: string[], + ): Promise { // Validate skill name const validation = this.validateSkillName(name) if (!validation.valid) { @@ -335,9 +392,8 @@ export class SkillsManager { baseDir = path.join(provider.cwd, ".roo") } - // Determine skills directory (with optional mode suffix) - const skillsDirName = mode ? `skills-${mode}` : "skills" - const skillsDir = path.join(baseDir, skillsDirName) + // Always use the generic skills directory (mode info stored in frontmatter now) + const skillsDir = path.join(baseDir, "skills") const skillDir = path.join(skillsDir, name) const skillMdPath = path.join(skillDir, "SKILL.md") @@ -355,9 +411,17 @@ export class SkillsManager { .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" ") + // Build frontmatter with optional modeSlugs + const frontmatterLines = [`name: ${name}`, `description: ${trimmedDescription}`] + if (modeSlugs && modeSlugs.length > 0) { + frontmatterLines.push(`modeSlugs:`) + for (const slug of modeSlugs) { + frontmatterLines.push(` - ${slug}`) + } + } + const skillContent = `--- -name: ${name} -description: ${trimmedDescription} +${frontmatterLines.join("\n")} --- # ${titleName} @@ -471,6 +535,49 @@ Add your skill instructions here. await this.discoverSkills() } + /** + * Update the mode associations for a skill by modifying its SKILL.md frontmatter. + * @param name - Skill name + * @param source - Where the skill is located ("global" or "project") + * @param newModeSlugs - New mode slugs (undefined/empty = any mode) + */ + async updateSkillModes(name: string, source: "global" | "project", newModeSlugs?: string[]): Promise { + // Find any skill with this name and source (regardless of current mode) + let skill: SkillMetadata | undefined + for (const s of this.skills.values()) { + if (s.name === name && s.source === source) { + skill = s + break + } + } + + if (!skill) { + throw new Error(t("skills:errors.not_found", { name, source, modeInfo: "" })) + } + + // Read the current SKILL.md file + const fileContent = await fs.readFile(skill.path, "utf-8") + const { data: frontmatter, content: body } = matter(fileContent) + + // Update the frontmatter with new modeSlugs + if (newModeSlugs && newModeSlugs.length > 0) { + frontmatter.modeSlugs = newModeSlugs + // Remove legacy mode field if present + delete frontmatter.mode + } else { + // Empty/undefined = any mode, remove mode restrictions + delete frontmatter.modeSlugs + delete frontmatter.mode + } + + // Serialize back to SKILL.md format + const newContent = matter.stringify(body, frontmatter) + await fs.writeFile(skill.path, newContent, "utf-8") + + // Refresh skills list + await this.discoverSkills() + } + /** * Get all skills directories to scan, including mode-specific directories. */ @@ -483,19 +590,44 @@ Add your skill instructions here. > { const dirs: Array<{ dir: string; source: "global" | "project"; mode?: string }> = [] const globalRooDir = getGlobalRooDirectory() + const globalAgentsDir = getGlobalAgentsDirectory() const provider = this.providerRef.deref() const projectRooDir = provider?.cwd ? path.join(provider.cwd, ".roo") : null + const projectAgentsDir = provider?.cwd ? getProjectAgentsDirectoryForCwd(provider.cwd) : null // Get list of modes to check for mode-specific skills const modesList = await this.getAvailableModes() - // Global directories + // Priority rules for skills with the same name: + // 1. Source level: project > global > built-in (handled by shouldOverrideSkill in getSkillsForMode) + // 2. Within the same source level: later-processed directories override earlier ones + // (via Map.set replacement during discovery - same source+mode+name key gets replaced) + // + // Processing order (later directories override earlier ones at the same source level): + // - Global: .agents/skills first, then .roo/skills (so .roo wins) + // - Project: .agents/skills first, then .roo/skills (so .roo wins) + + // Global .agents directories (lowest priority - shared across agents) + dirs.push({ dir: path.join(globalAgentsDir, "skills"), source: "global" }) + for (const mode of modesList) { + dirs.push({ dir: path.join(globalAgentsDir, `skills-${mode}`), source: "global", mode }) + } + + // Project .agents directories + if (projectAgentsDir) { + dirs.push({ dir: path.join(projectAgentsDir, "skills"), source: "project" }) + for (const mode of modesList) { + dirs.push({ dir: path.join(projectAgentsDir, `skills-${mode}`), source: "project", mode }) + } + } + + // Global .roo directories (Roo-specific, higher priority than .agents) dirs.push({ dir: path.join(globalRooDir, "skills"), source: "global" }) for (const mode of modesList) { dirs.push({ dir: path.join(globalRooDir, `skills-${mode}`), source: "global", mode }) } - // Project directories + // Project .roo directories (highest priority) if (projectRooDir) { dirs.push({ dir: path.join(projectRooDir, "skills"), source: "project" }) for (const mode of modesList) { @@ -540,20 +672,32 @@ Add your skill instructions here. if (!provider?.cwd) return // Watch for changes in skills directories - const globalSkillsDir = path.join(getGlobalRooDirectory(), "skills") - const projectSkillsDir = path.join(provider.cwd, ".roo", "skills") + const globalRooDir = getGlobalRooDirectory() + const globalAgentsDir = getGlobalAgentsDirectory() + const projectRooDir = path.join(provider.cwd, ".roo") + const projectAgentsDir = getProjectAgentsDirectoryForCwd(provider.cwd) - // Watch global skills directory - this.watchDirectory(globalSkillsDir) + // Watch global .roo skills directory + this.watchDirectory(path.join(globalRooDir, "skills")) - // Watch project skills directory - this.watchDirectory(projectSkillsDir) + // Watch global .agents skills directory + this.watchDirectory(path.join(globalAgentsDir, "skills")) + + // Watch project .roo skills directory + this.watchDirectory(path.join(projectRooDir, "skills")) + + // Watch project .agents skills directory + this.watchDirectory(path.join(projectAgentsDir, "skills")) // Watch mode-specific directories for all available modes const modesList = await this.getAvailableModes() for (const mode of modesList) { - this.watchDirectory(path.join(getGlobalRooDirectory(), `skills-${mode}`)) - this.watchDirectory(path.join(provider.cwd, ".roo", `skills-${mode}`)) + // .roo mode-specific + this.watchDirectory(path.join(globalRooDir, `skills-${mode}`)) + this.watchDirectory(path.join(projectRooDir, `skills-${mode}`)) + // .agents mode-specific + this.watchDirectory(path.join(globalAgentsDir, `skills-${mode}`)) + this.watchDirectory(path.join(projectAgentsDir, `skills-${mode}`)) } } diff --git a/src/services/skills/__tests__/SkillsManager.spec.ts b/src/services/skills/__tests__/SkillsManager.spec.ts index 9c02769ce8..b0fee079bb 100644 --- a/src/services/skills/__tests__/SkillsManager.spec.ts +++ b/src/services/skills/__tests__/SkillsManager.spec.ts @@ -82,10 +82,13 @@ vi.mock("vscode", () => ({ // Global roo directory - computed once const GLOBAL_ROO_DIR = p(HOME_DIR, ".roo") +const GLOBAL_AGENTS_DIR = p(HOME_DIR, ".agents") // Mock roo-config vi.mock("../../roo-config", () => ({ getGlobalRooDirectory: () => GLOBAL_ROO_DIR, + getGlobalAgentsDirectory: () => GLOBAL_AGENTS_DIR, + getProjectAgentsDirectoryForCwd: (cwd: string) => p(cwd, ".agents"), directoryExists: mockDirectoryExists, fileExists: mockFileExists, })) @@ -127,6 +130,11 @@ describe("SkillsManager", () => { const globalSkillsArchitectDir = p(GLOBAL_ROO_DIR, "skills-architect") const projectRooDir = p(PROJECT_DIR, ".roo") const projectSkillsDir = p(projectRooDir, "skills") + // .agents directory paths + const globalAgentsSkillsDir = p(GLOBAL_AGENTS_DIR, "skills") + const globalAgentsSkillsCodeDir = p(GLOBAL_AGENTS_DIR, "skills-code") + const projectAgentsDir = p(PROJECT_DIR, ".agents") + const projectAgentsSkillsDir = p(projectAgentsDir, "skills") beforeEach(() => { vi.clearAllMocks() @@ -615,6 +623,216 @@ Instructions here...` expect(skills[0].name).toBe("my-alias") expect(skills[0].source).toBe("global") }) + + it("should discover skills from global .agents directory", async () => { + const agentSkillDir = p(globalAgentsSkillsDir, "agent-skill") + const agentSkillMd = p(agentSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalAgentsSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalAgentsSkillsDir) { + return ["agent-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === agentSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === agentSkillMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === agentSkillMd) { + return `--- +name: agent-skill +description: A skill from .agents directory shared across AI coding tools +--- + +# Agent Skill + +Instructions here...` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(1) + expect(skills[0].name).toBe("agent-skill") + expect(skills[0].description).toBe("A skill from .agents directory shared across AI coding tools") + expect(skills[0].source).toBe("global") + }) + + it("should discover skills from project .agents directory", async () => { + const projectAgentSkillDir = p(projectAgentsSkillsDir, "project-agent-skill") + const projectAgentSkillMd = p(projectAgentSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === projectAgentsSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === projectAgentsSkillsDir) { + return ["project-agent-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === projectAgentSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === projectAgentSkillMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === projectAgentSkillMd) { + return `--- +name: project-agent-skill +description: A project-level skill from .agents directory +--- + +# Project Agent Skill + +Instructions here...` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(1) + expect(skills[0].name).toBe("project-agent-skill") + expect(skills[0].source).toBe("project") + }) + + it("should prioritize .roo skills over .agents skills with same name", async () => { + const agentSkillDir = p(globalAgentsSkillsDir, "common-skill") + const agentSkillMd = p(agentSkillDir, "SKILL.md") + const rooSkillDir = p(globalSkillsDir, "common-skill") + const rooSkillMd = p(rooSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalAgentsSkillsDir || dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalAgentsSkillsDir || dir === globalSkillsDir) { + return ["common-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === agentSkillDir || pathArg === rooSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === agentSkillMd || file === rooSkillMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === agentSkillMd) { + return `--- +name: common-skill +description: Agent version (should be overridden) +--- + +# Agent Common Skill` + } + if (file === rooSkillMd) { + return `--- +name: common-skill +description: Roo version (should take priority) +--- + +# Roo Common Skill` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getSkillsForMode("code") + const commonSkill = skills.find((s) => s.name === "common-skill") + expect(commonSkill).toBeDefined() + // .roo should override .agents + expect(commonSkill?.description).toBe("Roo version (should take priority)") + }) + + it("should discover mode-specific skills from .agents directory", async () => { + const agentCodeSkillDir = p(globalAgentsSkillsCodeDir, "agent-code-skill") + const agentCodeSkillMd = p(agentCodeSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalAgentsSkillsCodeDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalAgentsSkillsCodeDir) { + return ["agent-code-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === agentCodeSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === agentCodeSkillMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === agentCodeSkillMd) { + return `--- +name: agent-code-skill +description: A code mode skill from .agents directory +--- + +# Agent Code Skill + +Instructions here...` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(1) + expect(skills[0].name).toBe("agent-code-skill") + expect(skills[0].mode).toBe("code") + }) }) describe("getSkillsForMode", () => { @@ -1004,7 +1222,7 @@ Instructions`) expect(writeCall[1]).toContain("description: A new skill description") }) - it("should create a mode-specific skill", async () => { + it("should create a mode-specific skill with modeSlugs array", async () => { mockDirectoryExists.mockResolvedValue(false) mockRealpath.mockImplementation(async (p: string) => p) mockReaddir.mockResolvedValue([]) @@ -1012,9 +1230,15 @@ Instructions`) mockMkdir.mockResolvedValue(undefined) mockWriteFile.mockResolvedValue(undefined) - const createdPath = await skillsManager.createSkill("code-skill", "global", "A code skill", "code") + const createdPath = await skillsManager.createSkill("code-skill", "global", "A code skill", ["code"]) - expect(createdPath).toBe(p(GLOBAL_ROO_DIR, "skills-code", "code-skill", "SKILL.md")) + // Skills are always created in the generic skills directory now; mode info is in frontmatter + expect(createdPath).toBe(p(GLOBAL_ROO_DIR, "skills", "code-skill", "SKILL.md")) + + // Verify frontmatter contains modeSlugs + const writeCall = mockWriteFile.mock.calls[0] + expect(writeCall[1]).toContain("modeSlugs:") + expect(writeCall[1]).toContain("- code") }) it("should create a project skill", async () => { diff --git a/src/shared/ProfileValidator.ts b/src/shared/ProfileValidator.ts index 3ca5b5616d..ae58763d6a 100644 --- a/src/shared/ProfileValidator.ts +++ b/src/shared/ProfileValidator.ts @@ -61,16 +61,11 @@ export class ProfileValidator { case "mistral": case "deepseek": case "xai": - case "groq": case "sambanova": - case "chutes": case "fireworks": - case "featherless": return profile.apiModelId case "litellm": return profile.litellmModelId - case "unbound": - return profile.unboundModelId case "lmstudio": return profile.lmStudioModelId case "vscode-lm": @@ -82,10 +77,6 @@ export class ProfileValidator { return profile.ollamaModelId case "requesty": return profile.requestyModelId - case "io-intelligence": - return profile.ioIntelligenceModelId - case "deepinfra": - return profile.deepInfraModelId case "fake-ai": default: return undefined diff --git a/src/shared/__tests__/ProfileValidator.spec.ts b/src/shared/__tests__/ProfileValidator.spec.ts index 04bd171696..9bf913cdc2 100644 --- a/src/shared/__tests__/ProfileValidator.spec.ts +++ b/src/shared/__tests__/ProfileValidator.spec.ts @@ -176,11 +176,8 @@ describe("ProfileValidator", () => { "mistral", "deepseek", "xai", - "groq", - "chutes", "sambanova", "fireworks", - "featherless", ] apiModelProviders.forEach((provider) => { @@ -216,22 +213,6 @@ describe("ProfileValidator", () => { expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(true) }) - // Test for io-intelligence provider which uses ioIntelligenceModelId - it(`should extract ioIntelligenceModelId for io-intelligence provider`, () => { - const allowList: OrganizationAllowList = { - allowAll: false, - providers: { - "io-intelligence": { allowAll: false, models: ["test-model"] }, - }, - } - const profile: ProviderSettings = { - apiProvider: "io-intelligence" as any, - ioIntelligenceModelId: "test-model", - } - - expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(true) - }) - it("should extract vsCodeLmModelSelector.id for vscode-lm provider", () => { const allowList: OrganizationAllowList = { allowAll: false, @@ -247,21 +228,6 @@ describe("ProfileValidator", () => { expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(true) }) - it("should extract unboundModelId for unbound provider", () => { - const allowList: OrganizationAllowList = { - allowAll: false, - providers: { - unbound: { allowAll: false, models: ["unbound-model"] }, - }, - } - const profile: ProviderSettings = { - apiProvider: "unbound", - unboundModelId: "unbound-model", - } - - expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(true) - }) - it("should extract lmStudioModelId for lmstudio provider", () => { const allowList: OrganizationAllowList = { allowAll: false, diff --git a/src/shared/__tests__/checkExistApiConfig.spec.ts b/src/shared/__tests__/checkExistApiConfig.spec.ts index 55dae005f2..d6dd1db24f 100644 --- a/src/shared/__tests__/checkExistApiConfig.spec.ts +++ b/src/shared/__tests__/checkExistApiConfig.spec.ts @@ -55,7 +55,6 @@ describe("checkExistKey", () => { mistralApiKey: undefined, vsCodeLmModelSelector: undefined, requestyApiKey: undefined, - unboundApiKey: undefined, } expect(checkExistKey(config)).toBe(false) }) diff --git a/src/shared/__tests__/embeddingModels.spec.ts b/src/shared/__tests__/embeddingModels.spec.ts new file mode 100644 index 0000000000..16aa019c7f --- /dev/null +++ b/src/shared/__tests__/embeddingModels.spec.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest" +import { + getModelDimension, + getModelScoreThreshold, + getDefaultModelId, + EMBEDDING_MODEL_PROFILES, +} from "../embeddingModels" + +describe("embeddingModels", () => { + describe("EMBEDDING_MODEL_PROFILES", () => { + it("should have gemini provider with gemini-embedding-001 model", () => { + const geminiProfiles = EMBEDDING_MODEL_PROFILES.gemini + expect(geminiProfiles).toBeDefined() + expect(geminiProfiles!["gemini-embedding-001"]).toBeDefined() + expect(geminiProfiles!["gemini-embedding-001"].dimension).toBe(3072) + }) + + it("should have deprecated text-embedding-004 in gemini profiles for backward compatibility", () => { + // This is critical for backward compatibility: + // Users with text-embedding-004 configured need dimension lookup to work + // even though the model is migrated to gemini-embedding-001 in GeminiEmbedder + const geminiProfiles = EMBEDDING_MODEL_PROFILES.gemini + expect(geminiProfiles).toBeDefined() + expect(geminiProfiles!["text-embedding-004"]).toBeDefined() + expect(geminiProfiles!["text-embedding-004"].dimension).toBe(3072) + }) + }) + + describe("getModelDimension", () => { + it("should return dimension for gemini-embedding-001", () => { + const dimension = getModelDimension("gemini", "gemini-embedding-001") + expect(dimension).toBe(3072) + }) + + it("should return dimension for deprecated text-embedding-004", () => { + // This ensures createVectorStore() works for users with text-embedding-004 configured + // The dimension should be 3072 (matching gemini-embedding-001) because: + // 1. GeminiEmbedder migrates text-embedding-004 to gemini-embedding-001 + // 2. gemini-embedding-001 produces 3072-dimensional embeddings + // 3. Vector store dimension must match the actual embedding dimension + const dimension = getModelDimension("gemini", "text-embedding-004") + expect(dimension).toBe(3072) + }) + + it("should return undefined for unknown model", () => { + const dimension = getModelDimension("gemini", "unknown-model") + expect(dimension).toBeUndefined() + }) + + it("should return undefined for unknown provider", () => { + const dimension = getModelDimension("unknown-provider" as any, "some-model") + expect(dimension).toBeUndefined() + }) + + it("should return correct dimensions for openai models", () => { + expect(getModelDimension("openai", "text-embedding-3-small")).toBe(1536) + expect(getModelDimension("openai", "text-embedding-3-large")).toBe(3072) + expect(getModelDimension("openai", "text-embedding-ada-002")).toBe(1536) + }) + }) + + describe("getModelScoreThreshold", () => { + it("should return score threshold for gemini-embedding-001", () => { + const threshold = getModelScoreThreshold("gemini", "gemini-embedding-001") + expect(threshold).toBe(0.4) + }) + + it("should return score threshold for deprecated text-embedding-004", () => { + const threshold = getModelScoreThreshold("gemini", "text-embedding-004") + expect(threshold).toBe(0.4) + }) + + it("should return undefined for unknown model", () => { + const threshold = getModelScoreThreshold("gemini", "unknown-model") + expect(threshold).toBeUndefined() + }) + }) + + describe("getDefaultModelId", () => { + it("should return gemini-embedding-001 for gemini provider", () => { + const defaultModel = getDefaultModelId("gemini") + expect(defaultModel).toBe("gemini-embedding-001") + }) + + it("should return text-embedding-3-small for openai provider", () => { + const defaultModel = getDefaultModelId("openai") + expect(defaultModel).toBe("text-embedding-3-small") + }) + + it("should return codestral-embed-2505 for mistral provider", () => { + const defaultModel = getDefaultModelId("mistral") + expect(defaultModel).toBe("codestral-embed-2505") + }) + }) +}) diff --git a/src/shared/api.ts b/src/shared/api.ts index b2ba1e3542..7e999e1289 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -171,16 +171,11 @@ type CommonFetchParams = { const dynamicProviderExtras = { openrouter: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type "vercel-ai-gateway": {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type - huggingface: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type litellm: {} as { apiKey: string; baseUrl: string }, - deepinfra: {} as { apiKey?: string; baseUrl?: string }, - "io-intelligence": {} as { apiKey: string }, requesty: {} as { apiKey?: string; baseUrl?: string }, - unbound: {} as { apiKey?: string }, ollama: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type lmstudio: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type roo: {} as { apiKey?: string; baseUrl?: string }, - chutes: {} as { apiKey?: string }, } as const satisfies Record // Build the dynamic options union from the map, intersected with CommonFetchParams diff --git a/src/shared/checkExistApiConfig.ts b/src/shared/checkExistApiConfig.ts index ccbda63ae0..54e5bcc036 100644 --- a/src/shared/checkExistApiConfig.ts +++ b/src/shared/checkExistApiConfig.ts @@ -5,11 +5,17 @@ export function checkExistKey(config: ProviderSettings | undefined) { return false } - // Special case for fake-ai, openai-codex, qwen-code, and roo providers which don't need any configuration. + // Special case for providers which don't need standard API key configuration. if (config.apiProvider && ["fake-ai", "openai-codex", "qwen-code", "roo"].includes(config.apiProvider)) { return true } + // Azure supports managed identity / Entra ID auth (no API key needed). + // Consider it configured if resource name or deployment name is set. + if (config.apiProvider === "azure") { + return !!(config.azureResourceName || config.azureDeploymentName || config.azureApiKey) + } + // Check all secret keys from the centralized SECRET_STATE_KEYS array. // Filter out keys that are not part of ProviderSettings (global secrets are stored separately) const providerSecretKeys = SECRET_STATE_KEYS.filter((key) => !GLOBAL_SECRET_KEYS.includes(key as any)) diff --git a/src/shared/embeddingModels.ts b/src/shared/embeddingModels.ts index a4c5217a9d..0b59c5b4b2 100644 --- a/src/shared/embeddingModels.ts +++ b/src/shared/embeddingModels.ts @@ -34,8 +34,10 @@ export const EMBEDDING_MODEL_PROFILES: EmbeddingModelProfiles = { }, }, gemini: { - "text-embedding-004": { dimension: 768 }, "gemini-embedding-001": { dimension: 3072, scoreThreshold: 0.4 }, + // Deprecated: text-embedding-004 is migrated to gemini-embedding-001 in GeminiEmbedder + // Kept here for backward-compatible dimension lookup in createVectorStore() + "text-embedding-004": { dimension: 3072, scoreThreshold: 0.4 }, }, mistral: { "codestral-embed-2505": { dimension: 1536, scoreThreshold: 0.4 }, diff --git a/src/shared/skills.ts b/src/shared/skills.ts index ae35b8c387..cbcc71d7b7 100644 --- a/src/shared/skills.ts +++ b/src/shared/skills.ts @@ -7,7 +7,17 @@ export interface SkillMetadata { description: string // Required: when to use this skill path: string // Absolute path to SKILL.md (or "" for built-in skills) source: "global" | "project" | "built-in" // Where the skill was discovered - mode?: string // If set, skill is only available in this mode + /** + * @deprecated Use modeSlugs instead. Kept for backward compatibility. + * If set, skill is only available in this mode. + */ + mode?: string + /** + * Mode slugs where this skill is available. + * - undefined or empty array means the skill is available in all modes ("Any mode"). + * - An array with one or more mode slugs restricts the skill to those modes. + */ + modeSlugs?: string[] } /** diff --git a/webview-ui/src/components/chat/Announcement.tsx b/webview-ui/src/components/chat/Announcement.tsx index a4538630fd..b30645eac9 100644 --- a/webview-ui/src/components/chat/Announcement.tsx +++ b/webview-ui/src/components/chat/Announcement.tsx @@ -44,9 +44,8 @@ const Announcement = ({ hideAnnouncement }: AnnouncementProps) => {

{t("chat:announcement.release.heading")}

    -
  • {t("chat:announcement.release.parallelTools")}
  • -
  • {t("chat:announcement.release.readFileIndentation")}
  • -
  • {t("chat:announcement.release.readCommandOutput")}
  • +
  • {t("chat:announcement.release.claudeOpus")}
  • +
  • {t("chat:announcement.release.gptCodex")}
diff --git a/webview-ui/src/components/chat/ApiConfigSelector.tsx b/webview-ui/src/components/chat/ApiConfigSelector.tsx index 4396019a2d..e370296ec3 100644 --- a/webview-ui/src/components/chat/ApiConfigSelector.tsx +++ b/webview-ui/src/components/chat/ApiConfigSelector.tsx @@ -20,6 +20,8 @@ interface ApiConfigSelectorProps { listApiConfigMeta: Array<{ id: string; name: string; modelId?: string }> pinnedApiConfigs?: Record togglePinnedApiConfig: (id: string) => void + lockApiConfigAcrossModes: boolean + onToggleLockApiConfig: () => void } export const ApiConfigSelector = ({ @@ -32,6 +34,8 @@ export const ApiConfigSelector = ({ listApiConfigMeta, pinnedApiConfigs, togglePinnedApiConfig, + lockApiConfigAcrossModes, + onToggleLockApiConfig, }: ApiConfigSelectorProps) => { const { t } = useAppTranslation() const [open, setOpen] = useState(false) @@ -228,6 +232,16 @@ export const ApiConfigSelector = ({ onClick={handleEditClick} tooltip={false} /> + {/* Info icon and title on the right with matching spacing */} diff --git a/webview-ui/src/components/chat/BatchDiffApproval.tsx b/webview-ui/src/components/chat/BatchDiffApproval.tsx index a88914cd88..f128e4310d 100644 --- a/webview-ui/src/components/chat/BatchDiffApproval.tsx +++ b/webview-ui/src/components/chat/BatchDiffApproval.tsx @@ -35,12 +35,12 @@ export const BatchDiffApproval = memo(({ files = [], ts }: BatchDiffApprovalProp return (
- {files.map((file) => { + {files.map((file, index) => { // Use backend-provided unified diff only. Stats also provided by backend. const unified = file.content || "" return ( -
+
{/* Individual files */}
- {files.map((file) => { + {files.map((file, index) => { return ( -
+
vscode.postMessage({ type: "openFile", text: file.content })}> diff --git a/webview-ui/src/components/chat/BatchListFilesPermission.tsx b/webview-ui/src/components/chat/BatchListFilesPermission.tsx new file mode 100644 index 0000000000..a5d08c244b --- /dev/null +++ b/webview-ui/src/components/chat/BatchListFilesPermission.tsx @@ -0,0 +1,45 @@ +import { memo } from "react" + +import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock" +import { PathTooltip } from "../ui/PathTooltip" + +interface DirPermissionItem { + path: string + key: string +} + +interface BatchListFilesPermissionProps { + dirs: DirPermissionItem[] + ts: number +} + +export const BatchListFilesPermission = memo(({ dirs = [], ts }: BatchListFilesPermissionProps) => { + if (!dirs?.length) { + return null + } + + return ( +
+
+ {dirs.map((dir, index) => { + return ( +
+ + + + + {dir.path} + + +
+
+
+
+ ) + })} +
+
+ ) +}) + +BatchListFilesPermission.displayName = "BatchListFilesPermission" diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index aa63f04d88..cf1c69c8fc 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -41,6 +41,7 @@ import { CheckpointSaved } from "./checkpoints/CheckpointSaved" import { FollowUpSuggest } from "./FollowUpSuggest" import { MultiQuestionHandler } from "./MultiQuestionHandler" import { BatchFilePermission } from "./BatchFilePermission" +import { BatchListFilesPermission } from "./BatchListFilesPermission" import { BatchDiffApproval } from "./BatchDiffApproval" import { ProgressIndicator } from "./ProgressIndicator" import { Markdown } from "./Markdown" @@ -420,24 +421,22 @@ export const ChatRowContent = ({ style={{ color: "var(--vscode-foreground)", marginBottom: "-1.5px" }}> ) + // Handle batch diffs for any file-edit tool type + if (message.type === "ask" && tool.batchDiffs && Array.isArray(tool.batchDiffs)) { + return ( + <> +
+ + {t("chat:fileOperations.wantsToApplyBatchChanges")} +
+ + + ) + } + switch (tool.tool as string) { case "editedExistingFile": case "appliedDiff": - // Check if this is a batch diff request - if (message.type === "ask" && tool.batchDiffs && Array.isArray(tool.batchDiffs)) { - return ( - <> -
- - - {t("chat:fileOperations.wantsToApplyBatchChanges")} - -
- - - ) - } - // Regular single file diff return ( <> @@ -743,45 +742,57 @@ export const ChatRowContent = ({ ) } case "listFilesTopLevel": + case "listFilesRecursive": { + const isRecursive = tool.tool === "listFilesRecursive" + + // Check if this is a batch directory listing request + const isBatchDirRequest = message.type === "ask" && tool.batchDirs && Array.isArray(tool.batchDirs) + + // When batching, check if all dirs share the same recursive value + const allTopLevel = tool.batchDirs?.every((d: { recursive: boolean }) => !d.recursive) + const DirIcon = isBatchDirRequest && !allTopLevel ? FolderTree : isRecursive ? FolderTree : ListTree + const dirIconLabel = + isBatchDirRequest && !allTopLevel + ? "Folder tree icon" + : isRecursive + ? "Folder tree icon" + : "List files icon" + + if (isBatchDirRequest) { + return ( + <> +
+ + + {t("chat:directoryOperations.wantsToViewMultipleDirectories")} + +
+ + + ) + } + + const labelKey = isRecursive + ? message.type === "ask" + ? tool.isOutsideWorkspace + ? "chat:directoryOperations.wantsToViewRecursiveOutsideWorkspace" + : "chat:directoryOperations.wantsToViewRecursive" + : tool.isOutsideWorkspace + ? "chat:directoryOperations.didViewRecursiveOutsideWorkspace" + : "chat:directoryOperations.didViewRecursive" + : message.type === "ask" + ? tool.isOutsideWorkspace + ? "chat:directoryOperations.wantsToViewTopLevelOutsideWorkspace" + : "chat:directoryOperations.wantsToViewTopLevel" + : tool.isOutsideWorkspace + ? "chat:directoryOperations.didViewTopLevelOutsideWorkspace" + : "chat:directoryOperations.didViewTopLevel" + return ( <>
- - - {message.type === "ask" - ? tool.isOutsideWorkspace - ? t("chat:directoryOperations.wantsToViewTopLevelOutsideWorkspace") - : t("chat:directoryOperations.wantsToViewTopLevel") - : tool.isOutsideWorkspace - ? t("chat:directoryOperations.didViewTopLevelOutsideWorkspace") - : t("chat:directoryOperations.didViewTopLevel")} - -
-
- -
- - ) - case "listFilesRecursive": - return ( - <> -
- - - {message.type === "ask" - ? tool.isOutsideWorkspace - ? t("chat:directoryOperations.wantsToViewRecursiveOutsideWorkspace") - : t("chat:directoryOperations.wantsToViewRecursive") - : tool.isOutsideWorkspace - ? t("chat:directoryOperations.didViewRecursiveOutsideWorkspace") - : t("chat:directoryOperations.didViewRecursive")} - + + {t(labelKey)}
) + } case "searchFiles": return ( <> diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 654f2e1011..4c0b2bbfd0 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -103,6 +103,7 @@ export const ChatTextArea = forwardRef( commands, cloudUserInfo, enterBehavior, + lockApiConfigAcrossModes, } = useExtensionState() // Find the ID and display text for the currently selected API configuration. @@ -945,6 +946,11 @@ export const ChatTextArea = forwardRef( vscode.postMessage({ type: "loadApiConfigurationById", text: value }) }, []) + const handleToggleLockApiConfig = useCallback(() => { + const newValue = !lockApiConfigAcrossModes + vscode.postMessage({ type: "lockApiConfigAcrossModes", bool: newValue }) + }, [lockApiConfigAcrossModes]) + return (
( listApiConfigMeta={listApiConfigMeta || []} pinnedApiConfigs={pinnedApiConfigs} togglePinnedApiConfig={togglePinnedApiConfig} + lockApiConfigAcrossModes={!!lockApiConfigAcrossModes} + onToggleLockApiConfig={handleToggleLockApiConfig} />
diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 6e9bea0389..90d5abf23b 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -11,8 +11,10 @@ import { Trans } from "react-i18next" import { useDebounceEffect } from "@src/utils/useDebounceEffect" import { appendImages } from "@src/utils/imageUtils" import { getCostBreakdownIfNeeded } from "@src/utils/costFormatting" +import { batchConsecutive } from "@src/utils/batchConsecutive" import type { ClineAsk, ClineSayTool, ClineMessage, ExtensionMessage, AudioType } from "@roo-code/types" +import { isRetiredProvider } from "@roo-code/types" import { findLast } from "@roo/array" import { SuggestionItem } from "@roo-code/types" @@ -39,6 +41,7 @@ import Announcement from "./Announcement" import BrowserActionRow from "./BrowserActionRow" import BrowserSessionStatusRow from "./BrowserSessionStatusRow" import ChatRow from "./ChatRow" +import WarningRow from "./WarningRow" import { ChatTextArea } from "./ChatTextArea" import TaskHeader from "./TaskHeader" import SystemPromptWarning from "./SystemPromptWarning" @@ -71,8 +74,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - const w = window as any - return w.AUDIO_BASE_URI || "" + return (window as unknown as { AUDIO_BASE_URI?: string }).AUDIO_BASE_URI || "" }) const { t } = useAppTranslation() @@ -99,6 +101,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + setShowRetiredProviderWarning(false) + }, [providerName]) + const messagesRef = useRef(messages) useEffect(() => { @@ -311,6 +322,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0) { + // Intercept when the active provider is retired — show a + // WarningRow instead of sending anything to the backend. + if (apiConfiguration?.apiProvider && isRetiredProvider(apiConfiguration.apiProvider)) { + setShowRetiredProviderWarning(true) + return + } + // Queue message if: // - Task is busy (sendingDisabled) // - API request in progress (isStreaming) // - Queue has items (preserve message order during drain) - if (sendingDisabled || isStreaming || messageQueue.length > 0) { + // - Command is running (command_output) - user's message should be queued for AI, not sent to terminal + if ( + sendingDisabled || + isStreaming || + messageQueue.length > 0 || + clineAskRef.current === "command_output" + ) { try { console.log("queueMessage", text, images) vscode.postMessage({ type: "queueMessage", text, images }) @@ -645,7 +687,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction vscode.postMessage({ type: "clearTask" }), []) + const startNewTask = useCallback(() => { + setShowRetiredProviderWarning(false) + vscode.postMessage({ type: "clearTask" }) + }, []) // Handle stop button click from textarea const handleStopTask = useCallback(() => { @@ -952,10 +1003,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction 1) { - // Create a synthetic batch message - const batchFiles = batch.map((batchMsg) => { - try { - const tool = JSON.parse(batchMsg.text || "{}") - return { - path: tool.path || "", - lineSnippet: tool.reason || "", - isOutsideWorkspace: tool.isOutsideWorkspace || false, - key: `${tool.path}${tool.reason ? ` (${tool.reason})` : ""}`, - content: tool.content || "", - } - } catch { - return { path: "", lineSnippet: "", key: "", content: "" } - } - }) - - // Use the first message as the base, but add batchFiles - const firstTool = JSON.parse(msg.text || "{}") - const syntheticMessage: ClineMessage = { - ...msg, - text: JSON.stringify({ - ...firstTool, - batchFiles, - }), - // Store original messages for response handling - _batchedMessages: batch, - } as ClineMessage & { _batchedMessages: ClineMessage[] } - - result.push(syntheticMessage) - i = j // Skip past all batched messages - } else { - // Single read_file ask, keep as-is - result.push(msg) - i++ - } - } else { - result.push(msg) - i++ + // Helper to check if a message is a list_files ask that should be batched + const isListFilesAsk = (msg: ClineMessage): boolean => { + if (msg.type !== "ask" || msg.ask !== "tool") return false + try { + const tool = JSON.parse(msg.text || "{}") + return ( + (tool.tool === "listFilesTopLevel" || tool.tool === "listFilesRecursive") && !tool.batchDirs // Don't re-batch already batched + ) + } catch { + return false } } + // Set of tool names that represent file-editing operations + const editFileTools = new Set([ + "editedExistingFile", + "appliedDiff", + "newFileCreated", + "insertContent", + "searchAndReplace", + ]) + + // Helper to check if a message is a file-edit ask that should be batched + const isEditFileAsk = (msg: ClineMessage): boolean => { + if (msg.type !== "ask" || msg.ask !== "tool") return false + try { + const tool = JSON.parse(msg.text || "{}") + return editFileTools.has(tool.tool) && !tool.batchDiffs // Don't re-batch already batched + } catch { + return false + } + } + + // Synthesize a batch of consecutive read_file asks into a single message + const synthesizeReadFileBatch = (batch: ClineMessage[]): ClineMessage => { + const batchFiles = batch.map((batchMsg) => { + try { + const tool = JSON.parse(batchMsg.text || "{}") + return { + path: tool.path || "", + lineSnippet: tool.reason || "", + isOutsideWorkspace: tool.isOutsideWorkspace || false, + key: `${tool.path}${tool.reason ? ` (${tool.reason})` : ""}`, + content: tool.content || "", + } + } catch { + return { path: "", lineSnippet: "", key: "", content: "" } + } + }) + + let firstTool + try { + firstTool = JSON.parse(batch[0].text || "{}") + } catch { + return batch[0] + } + return { + ...batch[0], + text: JSON.stringify({ ...firstTool, batchFiles }), + } + } + + // Synthesize a batch of consecutive list_files asks into a single message + const synthesizeListFilesBatch = (batch: ClineMessage[]): ClineMessage => { + const batchDirs = batch.map((batchMsg) => { + try { + const tool = JSON.parse(batchMsg.text || "{}") + return { + path: tool.path || "", + recursive: tool.tool === "listFilesRecursive", + isOutsideWorkspace: tool.isOutsideWorkspace || false, + key: tool.path || "", + } + } catch { + return { path: "", recursive: false, key: "" } + } + }) + + let firstTool + try { + firstTool = JSON.parse(batch[0].text || "{}") + } catch { + return batch[0] + } + return { + ...batch[0], + text: JSON.stringify({ ...firstTool, batchDirs }), + } + } + + // Synthesize a batch of consecutive file-edit asks into a single message + const synthesizeEditFileBatch = (batch: ClineMessage[]): ClineMessage => { + const batchDiffs = batch.map((batchMsg) => { + try { + const tool = JSON.parse(batchMsg.text || "{}") + return { + path: tool.path || "", + changeCount: 1, + key: tool.path || "", + content: tool.content || tool.diff || "", + diffStats: tool.diffStats, + } + } catch { + return { path: "", changeCount: 0, key: "", content: "" } + } + }) + + let firstTool + try { + firstTool = JSON.parse(batch[0].text || "{}") + } catch { + return batch[0] + } + return { + ...batch[0], + text: JSON.stringify({ ...firstTool, batchDiffs }), + } + } + + // Consolidate consecutive ask messages into batches + const readFileBatched = batchConsecutive(filtered, isReadFileAsk, synthesizeReadFileBatch) + const listFilesBatched = batchConsecutive(readFileBatched, isListFilesAsk, synthesizeListFilesBatch) + const result = batchConsecutive(listFilesBatched, isEditFileAsk, synthesizeEditFileBatch) + if (isCondensing) { result.push({ type: "say", say: "condense_context", ts: Date.now(), partial: true, - } as any) + } as ClineMessage) } return result }, [isCondensing, visibleMessages, isBrowserSessionMessage]) @@ -1230,9 +1347,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { return () => { - if (scrollToBottomSmooth && typeof (scrollToBottomSmooth as any).cancel === "function") { - ;(scrollToBottomSmooth as any).cancel() - } + scrollToBottomSmooth.clear() } }, [scrollToBottomSmooth]) @@ -1496,9 +1611,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction ({ acceptInput: () => { + const hasInput = inputValue.trim() || selectedImages.length > 0 + + // Special case: during command_output, queue the message instead of + // triggering the primary button action (which would lose the message) + if (clineAskRef.current === "command_output" && hasInput) { + vscode.postMessage({ type: "queueMessage", text: inputValue.trim(), images: selectedImages }) + setInputValue("") + setSelectedImages([]) + return + } + if (enableButtons && primaryButtonText) { handlePrimaryButtonClick(inputValue, selectedImages) - } else if (!sendingDisabled && !isProfileDisabled && (inputValue.trim() || selectedImages.length > 0)) { + } else if (!sendingDisabled && !isProfileDisabled && hasInput) { handleSendMessage(inputValue, selectedImages) } }, @@ -1736,6 +1862,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction + {showRetiredProviderWarning && ( +
+ vscode.postMessage({ type: "switchTab", tab: "settings" })} + /> +
+ )} void - onClick?: (command: Command) => void -} - -export const SlashCommandItem: React.FC = ({ command, onDelete, onClick }) => { - const { t } = useAppTranslation() - - // Built-in commands cannot be edited or deleted - const isBuiltIn = command.source === "built-in" - - const handleEdit = () => { - if (command.filePath) { - vscode.postMessage({ - type: "openFile", - text: command.filePath, - }) - } else { - // Fallback: request to open command file by name and source - vscode.postMessage({ - type: "openCommandFile", - text: command.name, - values: { source: command.source }, - }) - } - } - - const handleDelete = () => { - onDelete(command) - } - - return ( -
- {/* Command name - clickable */} -
onClick?.(command)}> -
- {command.name} - {command.description && ( -
- {command.description} -
- )} -
-
- - {/* Action buttons - only show for non-built-in commands */} - {!isBuiltIn && ( -
- - - - - - - -
- )} -
- ) -} diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index d5424b7422..ff516425c4 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -68,7 +68,8 @@ const TaskHeader = ({ todos, }: TaskHeaderProps) => { const { t } = useTranslation() - const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive } = useExtensionState() + const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive, taskHeaderHighlightEnabled } = + useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false) @@ -76,19 +77,34 @@ const TaskHeader = ({ autoOpenOnAuth: false, }) + // Determine if this is a subtask (has a parent) + const isSubtask = !!parentTaskId + + // Find the last message that isn't a resume action (shared by isTaskComplete and highlightClass) + const lastRelevantMessage = useMemo(() => { + const msgs = clineMessages || [] + const idx = findLastIndex(msgs, (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task")) + return idx !== -1 ? msgs[idx] : undefined + }, [clineMessages]) + // Check if the task is complete by looking at the last relevant message (skipping resume messages) - const isTaskComplete = - clineMessages && clineMessages.length > 0 - ? (() => { - const lastRelevantIndex = findLastIndex( - clineMessages, - (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"), - ) - return lastRelevantIndex !== -1 - ? clineMessages[lastRelevantIndex]?.ask === "completion_result" - : false - })() - : false + const isTaskComplete = lastRelevantMessage?.ask === "completion_result" + + // Compute highlight CSS class: green for task complete, yellow for user attention needed + const highlightClass = useMemo(() => { + if (!taskHeaderHighlightEnabled || isSubtask) return undefined + if (!lastRelevantMessage || lastRelevantMessage.partial) return undefined + + if (lastRelevantMessage.ask === "completion_result") { + return "task-header-highlight-green" + } + + if (lastRelevantMessage.ask) { + return "task-header-highlight-yellow" + } + + return undefined + }, [taskHeaderHighlightEnabled, isSubtask, lastRelevantMessage]) useEffect(() => { const timer = setTimeout(() => { @@ -141,9 +157,6 @@ const TaskHeader = ({ const hasTodos = todos && Array.isArray(todos) && todos.length > 0 - // Determine if this is a subtask (has a parent) - const isSubtask = !!parentTaskId - const handleBackToParent = () => { if (parentTaskId) { vscode.postMessage({ type: "showTaskWithId", text: parentTaskId }) @@ -174,12 +187,14 @@ const TaskHeader = ({ )}
{ // Don't expand if clicking on todos section diff --git a/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx index ff1b95f949..a71216d96f 100644 --- a/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx @@ -72,6 +72,8 @@ describe("ApiConfigSelector", () => { ], pinnedApiConfigs: { config1: true }, togglePinnedApiConfig: mockTogglePinnedApiConfig, + lockApiConfigAcrossModes: false, + onToggleLockApiConfig: vi.fn(), } beforeEach(() => { diff --git a/webview-ui/src/components/chat/__tests__/BatchListFilesPermission.spec.tsx b/webview-ui/src/components/chat/__tests__/BatchListFilesPermission.spec.tsx new file mode 100644 index 0000000000..21ea05192f --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/BatchListFilesPermission.spec.tsx @@ -0,0 +1,103 @@ +import { render, screen } from "@/utils/test-utils" + +import { TranslationProvider } from "@/i18n/__mocks__/TranslationContext" + +import { BatchListFilesPermission } from "../BatchListFilesPermission" + +describe("BatchListFilesPermission", () => { + const mockDirs = [ + { + key: "apps/cli", + path: "apps/cli", + }, + { + key: "apps/web-roo-code", + path: "apps/web-roo-code", + }, + { + key: "packages/core", + path: "packages/core", + }, + ] + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders directory list correctly", () => { + render( + + + , + ) + + expect(screen.getByText("apps/cli")).toBeInTheDocument() + expect(screen.getByText("apps/web-roo-code")).toBeInTheDocument() + expect(screen.getByText("packages/core")).toBeInTheDocument() + }) + + it("renders nothing when dirs array is empty", () => { + const { container } = render( + + + , + ) + + expect(container.firstChild).toBeNull() + }) + + it("re-renders when timestamp changes", () => { + const { rerender } = render( + + + , + ) + + expect(screen.getByText("apps/cli")).toBeInTheDocument() + + rerender( + + + , + ) + + expect(screen.getByText("apps/cli")).toBeInTheDocument() + }) + + it("renders all directories in a single container", () => { + render( + + + , + ) + + // All directories should be within a single bordered container + const container = screen.getByText("apps/cli").closest(".border.border-border.rounded-md") + expect(container).toBeInTheDocument() + + // All 3 dirs should be inside this container + expect(container?.querySelectorAll(".flex.items-center.gap-2")).toHaveLength(mockDirs.length) + }) + + it("renders a single directory", () => { + const singleDir = [ + { + key: "apps/cli", + path: "apps/cli", + }, + ] + + render( + + + , + ) + + expect(screen.getByText("apps/cli")).toBeInTheDocument() + + // Single directory should still be rendered inside the container + const bordered = screen.getByText("apps/cli").closest(".border.border-border.rounded-md") + expect(bordered).toBeInTheDocument() + expect(bordered?.querySelectorAll(".flex.items-center.gap-2")).toHaveLength(1) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.lockApiConfig.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.lockApiConfig.spec.tsx new file mode 100644 index 0000000000..d3fb2b6890 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.lockApiConfig.spec.tsx @@ -0,0 +1,156 @@ +import { defaultModeSlug } from "@roo/modes" + +import { render, fireEvent, screen } from "@src/utils/test-utils" +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { vscode } from "@src/utils/vscode" + +import { ChatTextArea } from "../ChatTextArea" + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +vi.mock("@src/components/common/CodeBlock") +vi.mock("@src/components/common/MarkdownBlock") +vi.mock("@src/utils/path-mentions", () => ({ + convertToMentionPath: vi.fn((path: string) => path), +})) + +// Mock ExtensionStateContext +vi.mock("@src/context/ExtensionStateContext") + +const mockPostMessage = vscode.postMessage as ReturnType + +describe("ChatTextArea - lockApiConfigAcrossModes toggle", () => { + const defaultProps = { + inputValue: "", + setInputValue: vi.fn(), + onSend: vi.fn(), + sendingDisabled: false, + selectApiConfigDisabled: false, + onSelectImages: vi.fn(), + shouldDisableImages: false, + placeholderText: "Type a message...", + selectedImages: [] as string[], + setSelectedImages: vi.fn(), + onHeightChange: vi.fn(), + mode: defaultModeSlug, + setMode: vi.fn(), + modeShortcutText: "(⌘. for next mode)", + } + + const defaultState = { + filePaths: [], + openedTabs: [], + apiConfiguration: { apiProvider: "anthropic" }, + taskHistory: [], + cwd: "/test/workspace", + listApiConfigMeta: [{ id: "default", name: "Default", modelId: "claude-3" }], + currentApiConfigName: "Default", + pinnedApiConfigs: {}, + togglePinnedApiConfig: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + /** + * Helper: Opens the ApiConfigSelector popover by clicking the trigger, + * then returns the lock toggle button by its aria-label. + */ + const openPopoverAndGetLockToggle = (ariaLabel: string) => { + const trigger = screen.getByTestId("dropdown-trigger") + fireEvent.click(trigger) + return screen.getByRole("button", { name: ariaLabel }) + } + + describe("rendering", () => { + it("renders with muted opacity when lockApiConfigAcrossModes is false", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + lockApiConfigAcrossModes: false, + }) + + render() + + const button = openPopoverAndGetLockToggle("chat:lockApiConfigAcrossModes") + expect(button).toBeInTheDocument() + // Unlocked state has muted opacity + expect(button.className).toContain("opacity-60") + expect(button.className).not.toContain("text-vscode-focusBorder") + }) + + it("renders with highlight color when lockApiConfigAcrossModes is true", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + lockApiConfigAcrossModes: true, + }) + + render() + + const button = openPopoverAndGetLockToggle("chat:unlockApiConfigAcrossModes") + expect(button).toBeInTheDocument() + // Locked state has the focus border highlight color + expect(button.className).toContain("text-vscode-focusBorder") + expect(button.className).not.toContain("opacity-60") + }) + + it("renders in unlocked state when lockApiConfigAcrossModes is undefined (default)", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + }) + + render() + + const button = openPopoverAndGetLockToggle("chat:lockApiConfigAcrossModes") + expect(button).toBeInTheDocument() + // Default (undefined/falsy) renders in unlocked style + expect(button.className).toContain("opacity-60") + }) + }) + + describe("interaction", () => { + it("posts lockApiConfigAcrossModes=true message when locking", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + lockApiConfigAcrossModes: false, + }) + + render() + + // Clear any initialization messages + mockPostMessage.mockClear() + + const button = openPopoverAndGetLockToggle("chat:lockApiConfigAcrossModes") + fireEvent.click(button) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "lockApiConfigAcrossModes", + bool: true, + }) + }) + + it("posts lockApiConfigAcrossModes=false message when unlocking", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + lockApiConfigAcrossModes: true, + }) + + render() + + // Clear any initialization messages + mockPostMessage.mockClear() + + const button = openPopoverAndGetLockToggle("chat:unlockApiConfigAcrossModes") + fireEvent.click(button) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "lockApiConfigAcrossModes", + bool: false, + }) + }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index bb12700c4f..1026ac86d0 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -1081,6 +1081,68 @@ describe("ChatView - Message Queueing Tests", () => { }), ) }) + + it("queues messages during command_output state instead of losing them", async () => { + const { getByTestId } = renderChatView() + + // Hydrate state with command_output ask (Proceed While Running state) + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "task", + ts: Date.now() - 2000, + text: "Initial task", + }, + { + type: "ask", + ask: "command_output", + ts: Date.now(), + text: "", + partial: false, // Non-partial so buttons are enabled + }, + ], + }) + + // Wait for state to be updated - need to allow time for React effects to propagate + // (clineAsk state update -> clineAskRef.current update) + await waitFor(() => { + expect(getByTestId("chat-textarea")).toBeInTheDocument() + }) + + // Allow React effects to complete (clineAsk -> clineAskRef sync) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + + // Clear message calls before simulating user input + vi.mocked(vscode.postMessage).mockClear() + + // Simulate user typing and sending a message during command execution + const chatTextArea = getByTestId("chat-textarea") + const input = chatTextArea.querySelector("input")! as HTMLInputElement + + await act(async () => { + fireEvent.change(input, { target: { value: "message during command execution" } }) + fireEvent.keyDown(input, { key: "Enter", code: "Enter" }) + }) + + // Verify that the message was queued (not lost via terminalOperation) + await waitFor(() => { + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "queueMessage", + text: "message during command execution", + images: [], + }) + }) + + // Verify it was NOT sent as terminalOperation (which would lose the message) + expect(vscode.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "terminalOperation", + }), + ) + }) }) describe("ChatView - Context Condensing Indicator Tests", () => { diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index c4ebe06973..7414d7a9c5 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -40,6 +40,7 @@ let mockExtensionState: { apiConfiguration: ProviderSettings currentTaskItem: { id: string } | null clineMessages: any[] + taskHeaderHighlightEnabled?: boolean } = { apiConfiguration: { apiProvider: "anthropic", @@ -48,6 +49,7 @@ let mockExtensionState: { } as ProviderSettings, currentTaskItem: { id: "test-task-id" }, clineMessages: [], + taskHeaderHighlightEnabled: false, } // Mock the ExtensionStateContext @@ -215,6 +217,7 @@ describe("TaskHeader", () => { } as ProviderSettings, currentTaskItem: { id: "test-task-id" }, clineMessages: [], + taskHeaderHighlightEnabled: false, } }) @@ -423,6 +426,175 @@ describe("TaskHeader", () => { }) }) + describe("Task header highlight", () => { + const completionMessages = [ + { + type: "ask", + ask: "completion_result", + ts: Date.now(), + text: "Task completed!", + }, + ] + + beforeEach(() => { + mockExtensionState = { + apiConfiguration: { + apiProvider: "anthropic", + apiKey: "test-api-key", + apiModelId: "claude-3-opus-20240229", + } as ProviderSettings, + currentTaskItem: { id: "test-task-id" }, + clineMessages: [], + taskHeaderHighlightEnabled: false, + } + }) + + it("should apply green highlight class when task is complete and highlight is enabled", () => { + mockExtensionState = { + ...mockExtensionState, + clineMessages: completionMessages, + taskHeaderHighlightEnabled: true, + } + + renderTaskHeader() + + const container = screen.getByTestId("task-header-container") + expect(container.classList.contains("task-header-highlight-green")).toBe(true) + expect(container.classList.contains("task-header-highlight-yellow")).toBe(false) + }) + + it("should apply yellow highlight class when task needs user attention and highlight is enabled", () => { + mockExtensionState = { + ...mockExtensionState, + clineMessages: [ + { + type: "ask", + ask: "tool", + ts: Date.now(), + text: "Need permission to use tool", + }, + ], + taskHeaderHighlightEnabled: true, + } + + renderTaskHeader() + + const container = screen.getByTestId("task-header-container") + expect(container.classList.contains("task-header-highlight-yellow")).toBe(true) + expect(container.classList.contains("task-header-highlight-green")).toBe(false) + }) + + it("should not apply highlight when highlight is disabled", () => { + mockExtensionState = { + ...mockExtensionState, + clineMessages: completionMessages, + taskHeaderHighlightEnabled: false, + } + + renderTaskHeader() + + const container = screen.getByTestId("task-header-container") + expect(container.classList.contains("task-header-highlight-green")).toBe(false) + expect(container.classList.contains("task-header-highlight-yellow")).toBe(false) + }) + + it("should not apply highlight when task is a subtask", () => { + mockExtensionState = { + ...mockExtensionState, + clineMessages: completionMessages, + taskHeaderHighlightEnabled: true, + } + + renderTaskHeader({ parentTaskId: "parent-task-123" }) + + const container = screen.getByTestId("task-header-container") + expect(container.classList.contains("task-header-highlight-green")).toBe(false) + expect(container.classList.contains("task-header-highlight-yellow")).toBe(false) + }) + + it("should not apply highlight when last message is partial", () => { + mockExtensionState = { + ...mockExtensionState, + clineMessages: [ + { + type: "ask", + ask: "completion_result", + ts: Date.now(), + text: "Task completed!", + partial: true, + }, + ], + taskHeaderHighlightEnabled: true, + } + + renderTaskHeader() + + const container = screen.getByTestId("task-header-container") + expect(container.classList.contains("task-header-highlight-green")).toBe(false) + expect(container.classList.contains("task-header-highlight-yellow")).toBe(false) + }) + + it("should not apply highlight when no clineMessages exist", () => { + mockExtensionState = { + ...mockExtensionState, + clineMessages: [], + taskHeaderHighlightEnabled: true, + } + + renderTaskHeader() + + const container = screen.getByTestId("task-header-container") + expect(container.classList.contains("task-header-highlight-green")).toBe(false) + expect(container.classList.contains("task-header-highlight-yellow")).toBe(false) + }) + + it("should not apply highlight when last relevant message has no ask type", () => { + mockExtensionState = { + ...mockExtensionState, + clineMessages: [{ type: "say", say: "text", ts: Date.now(), text: "Working..." }], + taskHeaderHighlightEnabled: true, + } + + renderTaskHeader() + + const container = screen.getByTestId("task-header-container") + expect(container.classList.contains("task-header-highlight-green")).toBe(false) + expect(container.classList.contains("task-header-highlight-yellow")).toBe(false) + }) + + it("should apply green class when completion_result is followed by resume messages", () => { + mockExtensionState = { + ...mockExtensionState, + clineMessages: [ + { + type: "ask", + ask: "completion_result", + ts: Date.now() - 2000, + text: "Task completed!", + }, + { + type: "ask", + ask: "resume_completed_task", + ts: Date.now() - 1000, + text: "Resume completed task?", + }, + { + type: "ask", + ask: "resume_task", + ts: Date.now(), + text: "Resume task?", + }, + ], + taskHeaderHighlightEnabled: true, + } + + renderTaskHeader() + + const container = screen.getByTestId("task-header-container") + expect(container.classList.contains("task-header-highlight-green")).toBe(true) + }) + }) + describe("Context window percentage calculation", () => { // The percentage should be calculated as: // contextTokens / (contextWindow - reservedForOutput) * 100 diff --git a/webview-ui/src/components/history/HistoryPreview.tsx b/webview-ui/src/components/history/HistoryPreview.tsx index 02464e69c0..70467c44fb 100644 --- a/webview-ui/src/components/history/HistoryPreview.tsx +++ b/webview-ui/src/components/history/HistoryPreview.tsx @@ -38,6 +38,7 @@ const HistoryPreview = () => { group={group} variant="compact" onToggleExpand={() => toggleExpand(group.parent.id)} + onToggleSubtaskExpand={toggleExpand} /> ))} diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index 88b6551881..1d6de93e64 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -21,6 +21,7 @@ import { useAppTranslation } from "@/i18n/TranslationContext" import { Tab, TabContent, TabHeader } from "../common/Tab" import { useTaskSearch } from "./useTaskSearch" import { useGroupedTasks } from "./useGroupedTasks" +import { countAllSubtasks } from "./types" import TaskItem from "./TaskItem" import TaskGroupItem from "./TaskGroupItem" @@ -52,11 +53,11 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { const [selectedTaskIds, setSelectedTaskIds] = useState([]) const [showBatchDeleteDialog, setShowBatchDeleteDialog] = useState(false) - // Get subtask count for a task + // Get subtask count for a task (recursive total) const getSubtaskCount = useMemo(() => { const countMap = new Map() for (const group of groups) { - countMap.set(group.parent.id, group.subtasks.length) + countMap.set(group.parent.id, countAllSubtasks(group.subtasks)) } return (taskId: string) => countMap.get(taskId) || 0 }, [groups]) @@ -300,6 +301,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { onToggleSelection={toggleTaskSelection} onDelete={handleDelete} onToggleExpand={() => toggleExpand(group.parent.id)} + onToggleSubtaskExpand={toggleExpand} className="m-2" /> )} diff --git a/webview-ui/src/components/history/SubtaskRow.tsx b/webview-ui/src/components/history/SubtaskRow.tsx index dec227ebc8..0089e1f81d 100644 --- a/webview-ui/src/components/history/SubtaskRow.tsx +++ b/webview-ui/src/components/history/SubtaskRow.tsx @@ -2,46 +2,87 @@ import { memo } from "react" import { ArrowRight } from "lucide-react" import { vscode } from "@/utils/vscode" import { cn } from "@/lib/utils" -import type { DisplayHistoryItem } from "./types" +import type { SubtaskTreeNode } from "./types" +import { countAllSubtasks } from "./types" import { StandardTooltip } from "../ui" +import SubtaskCollapsibleRow from "./SubtaskCollapsibleRow" interface SubtaskRowProps { - /** The subtask to display */ - item: DisplayHistoryItem + /** The subtask tree node to display */ + node: SubtaskTreeNode + /** Nesting depth (1 = direct child of parent group) */ + depth: number + /** Callback when expand/collapse is toggled for a node */ + onToggleExpand: (taskId: string) => void /** Optional className for styling */ className?: string } /** - * Displays an individual subtask row when the parent's subtask list is expanded. - * Shows the task name and token/cost info in an indented format. + * Displays a subtask row with recursive nesting support. + * Leaf nodes render just the task row. Nodes with children show + * a collapsible section that can be expanded to reveal nested subtasks. */ -const SubtaskRow = ({ item, className }: SubtaskRowProps) => { +const SubtaskRow = ({ node, depth, onToggleExpand, className }: SubtaskRowProps) => { + const { item, children, isExpanded } = node + const hasChildren = children.length > 0 + const handleClick = () => { vscode.postMessage({ type: "showTaskWithId", text: item.id }) } return ( -
+ {/* Task row with depth indentation */} +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + handleClick() + } + }}> + + {item.task} + + +
+ + {/* Nested subtask collapsible section */} + {hasChildren && ( +
+ onToggleExpand(item.id)} + /> +
+ )} + + {/* Expanded nested subtasks */} + {hasChildren && ( +
+ {children.map((child) => ( + + ))} +
)} - onClick={handleClick} - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - handleClick() - } - }}> - - {item.task} - -
) } diff --git a/webview-ui/src/components/history/TaskGroupItem.tsx b/webview-ui/src/components/history/TaskGroupItem.tsx index 6bf2e1a957..45b8293f01 100644 --- a/webview-ui/src/components/history/TaskGroupItem.tsx +++ b/webview-ui/src/components/history/TaskGroupItem.tsx @@ -1,6 +1,7 @@ import { memo } from "react" import { cn } from "@/lib/utils" import type { TaskGroup } from "./types" +import { countAllSubtasks } from "./types" import TaskItem from "./TaskItem" import SubtaskCollapsibleRow from "./SubtaskCollapsibleRow" import SubtaskRow from "./SubtaskRow" @@ -20,15 +21,17 @@ interface TaskGroupItemProps { onToggleSelection?: (taskId: string, isSelected: boolean) => void /** Callback when delete is requested */ onDelete?: (taskId: string) => void - /** Callback when expand/collapse is toggled */ + /** Callback when the parent group expand/collapse is toggled */ onToggleExpand: () => void + /** Callback when a nested subtask node expand/collapse is toggled */ + onToggleSubtaskExpand: (taskId: string) => void /** Optional className for styling */ className?: string } /** - * Renders a task group consisting of a parent task and its collapsible subtask list. - * When expanded, shows individual subtask rows. + * Renders a task group consisting of a parent task and its collapsible subtask tree. + * When expanded, shows recursively nested subtask rows. */ const TaskGroupItem = ({ group, @@ -39,10 +42,12 @@ const TaskGroupItem = ({ onToggleSelection, onDelete, onToggleExpand, + onToggleSubtaskExpand, className, }: TaskGroupItemProps) => { const { parent, subtasks, isExpanded } = group const hasSubtasks = subtasks.length > 0 + const totalSubtaskCount = hasSubtasks ? countAllSubtasks(subtasks) : 0 return (
- {/* Subtask collapsible row */} + {/* Subtask collapsible row — shows total recursive count */} {hasSubtasks && ( - + )} - {/* Expanded subtasks */} + {/* Expanded subtask tree */} {hasSubtasks && (
- {subtasks.map((subtask) => ( - + {subtasks.map((node) => ( + ))}
)} diff --git a/webview-ui/src/components/history/__tests__/SubtaskRow.spec.tsx b/webview-ui/src/components/history/__tests__/SubtaskRow.spec.tsx new file mode 100644 index 0000000000..6337b9f1fa --- /dev/null +++ b/webview-ui/src/components/history/__tests__/SubtaskRow.spec.tsx @@ -0,0 +1,213 @@ +import { render, screen, fireEvent } from "@/utils/test-utils" + +import { vscode } from "@src/utils/vscode" + +import SubtaskRow from "../SubtaskRow" +import type { SubtaskTreeNode, DisplayHistoryItem } from "../types" + +vi.mock("@src/utils/vscode") +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string, options?: Record) => { + if (key === "history:subtasks" && options?.count !== undefined) { + return `${options.count} Subtask${options.count === 1 ? "" : "s"}` + } + if (key === "history:collapseSubtasks") return "Collapse subtasks" + if (key === "history:expandSubtasks") return "Expand subtasks" + return key + }, + }), +})) + +const createMockDisplayItem = (overrides: Partial = {}): DisplayHistoryItem => ({ + id: "task-1", + number: 1, + task: "Test task", + ts: Date.now(), + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + workspace: "/workspace/project", + ...overrides, +}) + +const createMockNode = ( + itemOverrides: Partial = {}, + children: SubtaskTreeNode[] = [], + isExpanded = false, +): SubtaskTreeNode => ({ + item: createMockDisplayItem(itemOverrides), + children, + isExpanded, +}) + +describe("SubtaskRow", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("leaf node rendering", () => { + it("renders leaf node with correct text", () => { + const node = createMockNode({ id: "leaf-1", task: "Leaf task content" }) + + render() + + expect(screen.getByText("Leaf task content")).toBeInTheDocument() + }) + + it("renders with correct depth indentation", () => { + const node = createMockNode({ id: "leaf-1", task: "Indented task" }) + + render() + + const row = screen.getByTestId("subtask-row-leaf-1") + // The clickable row inside should have paddingLeft = depth * 16 = 32px + const clickableRow = row.querySelector("[role='button']") + expect(clickableRow).toHaveStyle({ paddingLeft: "32px" }) + }) + + it("does not render collapsible row for leaf node", () => { + const node = createMockNode({ id: "leaf-1", task: "Leaf only" }) + + render() + + expect(screen.queryByTestId("subtask-collapsible-row")).not.toBeInTheDocument() + }) + }) + + describe("node with children", () => { + it("renders collapsible row with correct child count", () => { + const node = createMockNode( + { id: "parent-1", task: "Parent task" }, + [ + createMockNode({ id: "child-1", task: "Child 1" }), + createMockNode({ id: "child-2", task: "Child 2" }), + ], + false, + ) + + render() + + expect(screen.getByText("2 Subtasks")).toBeInTheDocument() + expect(screen.getByTestId("subtask-collapsible-row")).toBeInTheDocument() + }) + + it("renders nested children count including grandchildren", () => { + const node = createMockNode( + { id: "parent-1", task: "Parent task" }, + [ + createMockNode({ id: "child-1", task: "Child 1" }, [ + createMockNode({ id: "grandchild-1", task: "Grandchild 1" }), + ]), + ], + false, + ) + + render() + + // countAllSubtasks counts child-1 (1) + grandchild-1 (1) = 2 + expect(screen.getByText("2 Subtasks")).toBeInTheDocument() + }) + }) + + describe("click behavior", () => { + it("sends showTaskWithId message when task row is clicked", () => { + const node = createMockNode({ id: "task-42", task: "Clickable task" }) + + render() + + const row = screen.getByRole("button") + fireEvent.click(row) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "showTaskWithId", + text: "task-42", + }) + }) + + it("calls onToggleExpand with correct task ID when collapsible row is clicked", () => { + const onToggleExpand = vi.fn() + const node = createMockNode( + { id: "expandable-1", task: "Expandable task" }, + [createMockNode({ id: "child-1", task: "Child" })], + false, + ) + + render() + + const collapsibleRow = screen.getByTestId("subtask-collapsible-row") + fireEvent.click(collapsibleRow) + + expect(onToggleExpand).toHaveBeenCalledWith("expandable-1") + }) + }) + + describe("expand/collapse behavior", () => { + it("renders child SubtaskRow components when expanded", () => { + const node = createMockNode( + { id: "parent-1", task: "Parent" }, + [ + createMockNode({ id: "child-1", task: "Child 1" }), + createMockNode({ id: "child-2", task: "Child 2" }), + ], + true, // expanded + ) + + render() + + expect(screen.getByTestId("subtask-row-child-1")).toBeInTheDocument() + expect(screen.getByTestId("subtask-row-child-2")).toBeInTheDocument() + expect(screen.getByText("Child 1")).toBeInTheDocument() + expect(screen.getByText("Child 2")).toBeInTheDocument() + }) + + it("uses max-h-0 for collapsed node with children", () => { + const node = createMockNode( + { id: "parent-1", task: "Parent" }, + [createMockNode({ id: "child-1", task: "Child 1" })], + false, // collapsed + ) + + const { container } = render() + + // The children wrapper div should have max-h-0 when collapsed + const childrenWrapper = container.querySelector(".max-h-0") + expect(childrenWrapper).toBeInTheDocument() + }) + + it("does not use max-h-0 when node is expanded", () => { + const node = createMockNode( + { id: "parent-1", task: "Parent" }, + [createMockNode({ id: "child-1", task: "Child 1" })], + true, // expanded + ) + + const { container } = render() + + // The children wrapper should NOT have max-h-0 when expanded + const collapsedWrapper = container.querySelector(".max-h-0") + expect(collapsedWrapper).not.toBeInTheDocument() + }) + + it("renders deeply nested recursive structure when all levels expanded", () => { + const node = createMockNode( + { id: "root", task: "Root" }, + [ + createMockNode( + { id: "child", task: "Child" }, + [createMockNode({ id: "grandchild", task: "Grandchild" })], + true, // child expanded + ), + ], + true, // root expanded + ) + + render() + + expect(screen.getByTestId("subtask-row-root")).toBeInTheDocument() + expect(screen.getByTestId("subtask-row-child")).toBeInTheDocument() + expect(screen.getByTestId("subtask-row-grandchild")).toBeInTheDocument() + expect(screen.getByText("Grandchild")).toBeInTheDocument() + }) + }) +}) diff --git a/webview-ui/src/components/history/__tests__/TaskGroupItem.spec.tsx b/webview-ui/src/components/history/__tests__/TaskGroupItem.spec.tsx index ff40963a87..b04fac6b54 100644 --- a/webview-ui/src/components/history/__tests__/TaskGroupItem.spec.tsx +++ b/webview-ui/src/components/history/__tests__/TaskGroupItem.spec.tsx @@ -1,7 +1,7 @@ import { render, screen, fireEvent } from "@/utils/test-utils" import TaskGroupItem from "../TaskGroupItem" -import type { TaskGroup, DisplayHistoryItem } from "../types" +import type { TaskGroup, DisplayHistoryItem, SubtaskTreeNode } from "../types" vi.mock("@src/utils/vscode") vi.mock("@src/i18n/TranslationContext", () => ({ @@ -34,6 +34,16 @@ const createMockDisplayHistoryItem = (overrides: Partial = { ...overrides, }) +const createMockSubtaskNode = ( + itemOverrides: Partial = {}, + children: SubtaskTreeNode[] = [], + isExpanded = false, +): SubtaskTreeNode => ({ + item: createMockDisplayHistoryItem(itemOverrides), + children, + isExpanded, +}) + const createMockGroup = (overrides: Partial = {}): TaskGroup => ({ parent: createMockDisplayHistoryItem({ id: "parent-1", task: "Parent task" }), subtasks: [], @@ -55,7 +65,9 @@ describe("TaskGroupItem", () => { }), }) - render() + render( + , + ) expect(screen.getByText("Test parent task content")).toBeInTheDocument() }) @@ -65,7 +77,9 @@ describe("TaskGroupItem", () => { parent: createMockDisplayHistoryItem({ id: "my-parent-id" }), }) - render() + render( + , + ) expect(screen.getByTestId("task-group-my-parent-id")).toBeInTheDocument() }) @@ -75,23 +89,27 @@ describe("TaskGroupItem", () => { it("shows correct subtask count", () => { const group = createMockGroup({ subtasks: [ - createMockDisplayHistoryItem({ id: "child-1", task: "Child 1" }), - createMockDisplayHistoryItem({ id: "child-2", task: "Child 2" }), - createMockDisplayHistoryItem({ id: "child-3", task: "Child 3" }), + createMockSubtaskNode({ id: "child-1", task: "Child 1" }), + createMockSubtaskNode({ id: "child-2", task: "Child 2" }), + createMockSubtaskNode({ id: "child-3", task: "Child 3" }), ], }) - render() + render( + , + ) expect(screen.getByText("3 Subtasks")).toBeInTheDocument() }) it("shows singular subtask text for single subtask", () => { const group = createMockGroup({ - subtasks: [createMockDisplayHistoryItem({ id: "child-1", task: "Child 1" })], + subtasks: [createMockSubtaskNode({ id: "child-1", task: "Child 1" })], }) - render() + render( + , + ) expect(screen.getByText("1 Subtask")).toBeInTheDocument() }) @@ -99,20 +117,48 @@ describe("TaskGroupItem", () => { it("does not show subtask row when no subtasks", () => { const group = createMockGroup({ subtasks: [] }) - render() + render( + , + ) expect(screen.queryByTestId("subtask-collapsible-row")).not.toBeInTheDocument() }) + + it("renders correct total subtask count with nested children", () => { + const group = createMockGroup({ + subtasks: [ + createMockSubtaskNode({ id: "child-1", task: "Child 1" }, [ + createMockSubtaskNode({ id: "grandchild-1", task: "Grandchild 1" }), + createMockSubtaskNode({ id: "grandchild-2", task: "Grandchild 2" }), + ]), + createMockSubtaskNode({ id: "child-2", task: "Child 2" }), + ], + }) + + render( + , + ) + + // 2 direct children + 2 grandchildren = 4 total + expect(screen.getByText("4 Subtasks")).toBeInTheDocument() + }) }) describe("expand/collapse behavior", () => { it("calls onToggleExpand when chevron row is clicked", () => { const onToggleExpand = vi.fn() const group = createMockGroup({ - subtasks: [createMockDisplayHistoryItem({ id: "child-1", task: "Child 1" })], + subtasks: [createMockSubtaskNode({ id: "child-1", task: "Child 1" })], }) - render() + render( + , + ) const collapsibleRow = screen.getByTestId("subtask-collapsible-row") fireEvent.click(collapsibleRow) @@ -124,12 +170,14 @@ describe("TaskGroupItem", () => { const group = createMockGroup({ isExpanded: true, subtasks: [ - createMockDisplayHistoryItem({ id: "child-1", task: "Subtask content 1" }), - createMockDisplayHistoryItem({ id: "child-2", task: "Subtask content 2" }), + createMockSubtaskNode({ id: "child-1", task: "Subtask content 1" }), + createMockSubtaskNode({ id: "child-2", task: "Subtask content 2" }), ], }) - render() + render( + , + ) expect(screen.getByTestId("subtask-list")).toBeInTheDocument() expect(screen.getByText("Subtask content 1")).toBeInTheDocument() @@ -139,16 +187,39 @@ describe("TaskGroupItem", () => { it("hides subtasks when collapsed", () => { const group = createMockGroup({ isExpanded: false, - subtasks: [createMockDisplayHistoryItem({ id: "child-1", task: "Subtask content" })], + subtasks: [createMockSubtaskNode({ id: "child-1", task: "Subtask content" })], }) - render() + render( + , + ) // The subtask-list element is present but collapsed via CSS (max-h-0) const subtaskList = screen.queryByTestId("subtask-list") expect(subtaskList).toBeInTheDocument() expect(subtaskList).toHaveClass("max-h-0") }) + + it("renders nested subtask when a node has children and is expanded", () => { + const group = createMockGroup({ + isExpanded: true, + subtasks: [ + createMockSubtaskNode( + { id: "child-1", task: "Parent subtask" }, + [createMockSubtaskNode({ id: "grandchild-1", task: "Nested subtask" })], + true, // child-1 is expanded + ), + ], + }) + + render( + , + ) + + expect(screen.getByText("Parent subtask")).toBeInTheDocument() + expect(screen.getByText("Nested subtask")).toBeInTheDocument() + expect(screen.getByTestId("subtask-row-grandchild-1")).toBeInTheDocument() + }) }) describe("selection mode", () => { @@ -166,6 +237,7 @@ describe("TaskGroupItem", () => { isSelected={false} onToggleSelection={onToggleSelection} onToggleExpand={vi.fn()} + onToggleSubtaskExpand={vi.fn()} />, ) @@ -188,6 +260,7 @@ describe("TaskGroupItem", () => { isSelected={true} onToggleSelection={vi.fn()} onToggleExpand={vi.fn()} + onToggleSubtaskExpand={vi.fn()} />, ) @@ -201,7 +274,14 @@ describe("TaskGroupItem", () => { it("passes compact variant to TaskItem", () => { const group = createMockGroup() - render() + render( + , + ) // TaskItem should be rendered with compact styling const taskItem = screen.getByTestId("task-item-parent-1") @@ -211,7 +291,9 @@ describe("TaskGroupItem", () => { it("passes full variant to TaskItem", () => { const group = createMockGroup() - render() + render( + , + ) const taskItem = screen.getByTestId("task-item-parent-1") expect(taskItem).toBeInTheDocument() @@ -225,7 +307,15 @@ describe("TaskGroupItem", () => { parent: createMockDisplayHistoryItem({ id: "parent-1", task: "Parent task" }), }) - render() + render( + , + ) // Delete button uses "delete-task-button" as testid const deleteButton = screen.getByTestId("delete-task-button") @@ -244,7 +334,15 @@ describe("TaskGroupItem", () => { }), }) - render() + render( + , + ) // Workspace should be displayed in TaskItem const taskItem = screen.getByTestId("task-item-parent-1") @@ -258,7 +356,15 @@ describe("TaskGroupItem", () => { it("applies custom className to container", () => { const group = createMockGroup() - render() + render( + , + ) const container = screen.getByTestId("task-group-parent-1") expect(container).toHaveClass("custom-class") diff --git a/webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts b/webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts index 4f280e72d4..8873695c62 100644 --- a/webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts +++ b/webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts @@ -2,7 +2,8 @@ import { renderHook, act } from "@/utils/test-utils" import type { HistoryItem } from "@roo-code/types" -import { useGroupedTasks } from "../useGroupedTasks" +import { useGroupedTasks, buildSubtree } from "../useGroupedTasks" +import { countAllSubtasks } from "../types" const createMockTask = (overrides: Partial = {}): HistoryItem => ({ id: "task-1", @@ -42,8 +43,8 @@ describe("useGroupedTasks", () => { expect(result.current.groups).toHaveLength(1) expect(result.current.groups[0].parent.id).toBe("parent-1") expect(result.current.groups[0].subtasks).toHaveLength(2) - expect(result.current.groups[0].subtasks[0].id).toBe("child-2") // Newest first - expect(result.current.groups[0].subtasks[1].id).toBe("child-1") + expect(result.current.groups[0].subtasks[0].item.id).toBe("child-2") // Newest first + expect(result.current.groups[0].subtasks[1].item.id).toBe("child-1") }) it("handles tasks with no children", () => { @@ -121,7 +122,7 @@ describe("useGroupedTasks", () => { expect(result.current.isSearchMode).toBe(false) }) - it("handles deeply nested tasks (grandchildren treated as children of their direct parent)", () => { + it("handles deeply nested tasks with recursive tree structure", () => { const rootTask = createMockTask({ id: "root-1", task: "Root task", @@ -146,10 +147,12 @@ describe("useGroupedTasks", () => { expect(result.current.groups).toHaveLength(1) expect(result.current.groups[0].parent.id).toBe("root-1") expect(result.current.groups[0].subtasks).toHaveLength(1) - expect(result.current.groups[0].subtasks[0].id).toBe("child-1") + expect(result.current.groups[0].subtasks[0].item.id).toBe("child-1") - // Note: grandchild is a child of child-1, not root-1 - // The current implementation only shows direct children in subtasks + // Grandchild is nested inside child's children + expect(result.current.groups[0].subtasks[0].children).toHaveLength(1) + expect(result.current.groups[0].subtasks[0].children[0].item.id).toBe("grandchild-1") + expect(result.current.groups[0].subtasks[0].children[0].children).toHaveLength(0) }) }) @@ -395,3 +398,199 @@ describe("useGroupedTasks", () => { }) }) }) + +describe("buildSubtree", () => { + it("builds a leaf node with no children", () => { + const task = createMockTask({ id: "task-1", task: "Leaf task" }) + const childrenMap = new Map() + + const node = buildSubtree(task, childrenMap, new Set()) + + expect(node.item.id).toBe("task-1") + expect(node.children).toHaveLength(0) + expect(node.isExpanded).toBe(false) + }) + + it("builds a node with direct children sorted newest first", () => { + const parent = createMockTask({ id: "parent-1", task: "Parent" }) + const child1 = createMockTask({ + id: "child-1", + task: "Child 1", + parentTaskId: "parent-1", + ts: new Date("2024-01-15T12:00:00").getTime(), + }) + const child2 = createMockTask({ + id: "child-2", + task: "Child 2", + parentTaskId: "parent-1", + ts: new Date("2024-01-15T14:00:00").getTime(), + }) + + const childrenMap = new Map() + childrenMap.set("parent-1", [child1, child2]) + + const node = buildSubtree(parent, childrenMap, new Set()) + + expect(node.item.id).toBe("parent-1") + expect(node.children).toHaveLength(2) + expect(node.children[0].item.id).toBe("child-2") // Newest first + expect(node.children[1].item.id).toBe("child-1") + expect(node.isExpanded).toBe(false) + expect(node.children[0].isExpanded).toBe(false) + expect(node.children[1].isExpanded).toBe(false) + }) + + it("builds a deeply nested tree recursively", () => { + const root = createMockTask({ id: "root", task: "Root" }) + const child = createMockTask({ + id: "child", + task: "Child", + parentTaskId: "root", + ts: new Date("2024-01-15T13:00:00").getTime(), + }) + const grandchild = createMockTask({ + id: "grandchild", + task: "Grandchild", + parentTaskId: "child", + ts: new Date("2024-01-15T14:00:00").getTime(), + }) + const greatGrandchild = createMockTask({ + id: "great-grandchild", + task: "Great Grandchild", + parentTaskId: "grandchild", + ts: new Date("2024-01-15T15:00:00").getTime(), + }) + + const childrenMap = new Map() + childrenMap.set("root", [child]) + childrenMap.set("child", [grandchild]) + childrenMap.set("grandchild", [greatGrandchild]) + + const node = buildSubtree(root, childrenMap, new Set()) + + expect(node.item.id).toBe("root") + expect(node.children).toHaveLength(1) + expect(node.children[0].item.id).toBe("child") + expect(node.children[0].children).toHaveLength(1) + expect(node.children[0].children[0].item.id).toBe("grandchild") + expect(node.children[0].children[0].children).toHaveLength(1) + expect(node.children[0].children[0].children[0].item.id).toBe("great-grandchild") + expect(node.children[0].children[0].children[0].children).toHaveLength(0) + }) + + it("does not mutate the original childrenMap arrays", () => { + const parent = createMockTask({ id: "parent-1", task: "Parent" }) + const child1 = createMockTask({ + id: "child-1", + task: "Child 1", + parentTaskId: "parent-1", + ts: new Date("2024-01-15T12:00:00").getTime(), + }) + const child2 = createMockTask({ + id: "child-2", + task: "Child 2", + parentTaskId: "parent-1", + ts: new Date("2024-01-15T14:00:00").getTime(), + }) + + const originalChildren = [child1, child2] + const childrenMap = new Map() + childrenMap.set("parent-1", originalChildren) + + buildSubtree(parent, childrenMap, new Set()) + + // Original array should not be mutated (sort is on a slice) + expect(originalChildren[0].id).toBe("child-1") + expect(originalChildren[1].id).toBe("child-2") + }) + + it("sets isExpanded: true when task ID is in expandedIds", () => { + const parent = createMockTask({ id: "parent-1", task: "Parent" }) + const child = createMockTask({ + id: "child-1", + task: "Child", + parentTaskId: "parent-1", + ts: new Date("2024-01-15T13:00:00").getTime(), + }) + + const childrenMap = new Map() + childrenMap.set("parent-1", [child]) + + const expandedIds = new Set(["parent-1"]) + const node = buildSubtree(parent, childrenMap, expandedIds) + + expect(node.isExpanded).toBe(true) + expect(node.children[0].isExpanded).toBe(false) + }) + + it("propagates isExpanded correctly through deeply nested tree", () => { + const root = createMockTask({ id: "root", task: "Root" }) + const child = createMockTask({ + id: "child", + task: "Child", + parentTaskId: "root", + ts: new Date("2024-01-15T13:00:00").getTime(), + }) + const grandchild = createMockTask({ + id: "grandchild", + task: "Grandchild", + parentTaskId: "child", + ts: new Date("2024-01-15T14:00:00").getTime(), + }) + const greatGrandchild = createMockTask({ + id: "great-grandchild", + task: "Great Grandchild", + parentTaskId: "grandchild", + ts: new Date("2024-01-15T15:00:00").getTime(), + }) + + const childrenMap = new Map() + childrenMap.set("root", [child]) + childrenMap.set("child", [grandchild]) + childrenMap.set("grandchild", [greatGrandchild]) + + // Expand root and grandchild, but NOT child + const expandedIds = new Set(["root", "grandchild"]) + const node = buildSubtree(root, childrenMap, expandedIds) + + expect(node.isExpanded).toBe(true) + expect(node.children[0].isExpanded).toBe(false) // child not expanded + expect(node.children[0].children[0].isExpanded).toBe(true) // grandchild expanded + expect(node.children[0].children[0].children[0].isExpanded).toBe(false) // great-grandchild not expanded + }) +}) + +describe("countAllSubtasks", () => { + it("returns 0 for empty array", () => { + expect(countAllSubtasks([])).toBe(0) + }) + + it("returns count of items in flat list (no grandchildren)", () => { + const nodes = [ + { item: createMockTask({ id: "a" }), children: [], isExpanded: false }, + { item: createMockTask({ id: "b" }), children: [], isExpanded: false }, + { item: createMockTask({ id: "c" }), children: [], isExpanded: false }, + ] + expect(countAllSubtasks(nodes)).toBe(3) + }) + + it("returns total count at all nesting levels", () => { + const nodes = [ + { + item: createMockTask({ id: "a" }), + children: [ + { + item: createMockTask({ id: "a1" }), + children: [{ item: createMockTask({ id: "a1i" }), children: [], isExpanded: false }], + isExpanded: false, + }, + { item: createMockTask({ id: "a2" }), children: [], isExpanded: false }, + ], + isExpanded: false, + }, + { item: createMockTask({ id: "b" }), children: [], isExpanded: false }, + ] + // a (1) + a1 (1) + a1i (1) + a2 (1) + b (1) = 5 + expect(countAllSubtasks(nodes)).toBe(5) + }) +}) diff --git a/webview-ui/src/components/history/types.ts b/webview-ui/src/components/history/types.ts index a12dfbce63..0de5e43081 100644 --- a/webview-ui/src/components/history/types.ts +++ b/webview-ui/src/components/history/types.ts @@ -11,13 +11,36 @@ export interface DisplayHistoryItem extends HistoryItem { } /** - * A group of tasks consisting of a parent task and its subtasks + * A node in the subtask tree, representing a task and its recursively nested children. + */ +export interface SubtaskTreeNode { + /** The task at this tree node */ + item: DisplayHistoryItem + /** Recursively nested child subtasks */ + children: SubtaskTreeNode[] + /** Whether this node's children are expanded in the UI */ + isExpanded: boolean +} + +/** + * Recursively counts all subtasks in a tree of SubtaskTreeNodes. + */ +export function countAllSubtasks(nodes: SubtaskTreeNode[]): number { + let count = 0 + for (const node of nodes) { + count += 1 + countAllSubtasks(node.children) + } + return count +} + +/** + * A group of tasks consisting of a parent task and its nested subtask tree */ export interface TaskGroup { /** The parent task */ parent: DisplayHistoryItem - /** List of direct subtasks */ - subtasks: DisplayHistoryItem[] + /** Tree of subtasks (supports arbitrary nesting depth) */ + subtasks: SubtaskTreeNode[] /** Whether the subtask list is expanded */ isExpanded: boolean } diff --git a/webview-ui/src/components/history/useGroupedTasks.ts b/webview-ui/src/components/history/useGroupedTasks.ts index 9d7085881e..d3f3d4e953 100644 --- a/webview-ui/src/components/history/useGroupedTasks.ts +++ b/webview-ui/src/components/history/useGroupedTasks.ts @@ -1,6 +1,29 @@ import { useState, useMemo, useCallback } from "react" import type { HistoryItem } from "@roo-code/types" -import type { DisplayHistoryItem, TaskGroup, GroupedTasksResult } from "./types" +import type { DisplayHistoryItem, SubtaskTreeNode, TaskGroup, GroupedTasksResult } from "./types" + +/** + * Recursively builds a subtask tree node for the given task. + * Pure function — exported for independent testing. + * + * @param task - The task to build a tree node for + * @param childrenMap - Map of parentId → direct children + * @param expandedIds - Set of task IDs whose children are currently expanded + * @returns A SubtaskTreeNode with recursively built children sorted by ts (newest first) + */ +export function buildSubtree( + task: HistoryItem, + childrenMap: Map, + expandedIds: Set, +): SubtaskTreeNode { + const directChildren = (childrenMap.get(task.id) || []).slice().sort((a, b) => b.ts - a.ts) + + return { + item: task as DisplayHistoryItem, + children: directChildren.map((child) => buildSubtree(child, childrenMap, expandedIds)), + isExpanded: expandedIds.has(task.id), + } +} /** * Hook to transform a flat task list into grouped structure based on parent-child relationships. @@ -31,7 +54,7 @@ export function useGroupedTasks(tasks: HistoryItem[], searchQuery: string): Grou return [] } - // Build children map: parentId -> children[] + // Build children map: parentId -> direct children[] const childrenMap = new Map() for (const task of tasks) { @@ -44,19 +67,16 @@ export function useGroupedTasks(tasks: HistoryItem[], searchQuery: string): Grou // Identify root tasks - tasks that either: // 1. Have no parentTaskId - // 2. Have a parentTaskId that doesn't exist in our task list + // 2. Have a parentTaskId that doesn't exist in our task list (orphans promoted to root) const rootTasks = tasks.filter((task) => !task.parentTaskId || !taskMap.has(task.parentTaskId)) - // Build groups from root tasks + // Build groups from root tasks with recursively nested subtask trees const taskGroups: TaskGroup[] = rootTasks.map((parent) => { - // Get direct children (sorted by timestamp, newest first) - const subtasks = (childrenMap.get(parent.id) || []) - .slice() - .sort((a, b) => b.ts - a.ts) as DisplayHistoryItem[] + const directChildren = (childrenMap.get(parent.id) || []).slice().sort((a, b) => b.ts - a.ts) return { parent: parent as DisplayHistoryItem, - subtasks, + subtasks: directChildren.map((child) => buildSubtree(child, childrenMap, expandedIds)), isExpanded: expandedIds.has(parent.id), } }) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 939d2734d4..51210de4f4 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -7,24 +7,20 @@ import { ExternalLinkIcon } from "@radix-ui/react-icons" import { type ProviderName, type ProviderSettings, + isRetiredProvider, DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, openRouterDefaultModelId, requestyDefaultModelId, - unboundDefaultModelId, litellmDefaultModelId, openAiNativeDefaultModelId, openAiCodexDefaultModelId, anthropicDefaultModelId, - doubaoDefaultModelId, qwenCodeDefaultModelId, geminiDefaultModelId, deepSeekDefaultModelId, moonshotDefaultModelId, mistralDefaultModelId, xaiDefaultModelId, - groqDefaultModelId, - cerebrasDefaultModelId, - chutesDefaultModelId, basetenDefaultModelId, bedrockDefaultModelId, vertexDefaultModelId, @@ -32,11 +28,8 @@ import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId, fireworksDefaultModelId, - featherlessDefaultModelId, - ioIntelligenceDefaultModelId, rooDefaultModelId, vercelAiGatewayDefaultModelId, - deepInfraDefaultModelId, minimaxDefaultModelId, } from "@roo-code/types" @@ -73,16 +66,11 @@ import { import { Anthropic, + Azure, Baseten, Bedrock, - Cerebras, - Chutes, DeepSeek, - Doubao, Gemini, - Groq, - HuggingFace, - IOIntelligence, LMStudio, LiteLLM, Mistral, @@ -96,15 +84,12 @@ import { Requesty, Roo, SambaNova, - Unbound, Vertex, VSCodeLM, XAI, ZAi, Fireworks, - Featherless, VercelAiGateway, - DeepInfra, MiniMax, } from "./providers" @@ -171,7 +156,7 @@ const ApiOptions = ({ // Only update if the processed object is different from the current config. if (JSON.stringify(currentConfigHeaders) !== JSON.stringify(newHeadersObject)) { - setApiConfigurationField("openAiHeaders", newHeadersObject) + setApiConfigurationField("openAiHeaders", newHeadersObject, false) } }, 300, @@ -196,6 +181,11 @@ const ApiOptions = ({ id: selectedModelId, info: selectedModelInfo, } = useSelectedModel(apiConfiguration) + const activeSelectedProvider: ProviderName | undefined = isRetiredProvider(selectedProvider) + ? undefined + : selectedProvider + const isRetiredSelectedProvider = + typeof apiConfiguration.apiProvider === "string" && isRetiredProvider(apiConfiguration.apiProvider) const { data: routerModels, refetch: refetchRouterModels } = useRouterModels() @@ -213,12 +203,16 @@ const ApiOptions = ({ // Update `apiModelId` whenever `selectedModelId` changes. useEffect(() => { + if (isRetiredSelectedProvider) { + return + } + if (selectedModelId && apiConfiguration.apiModelId !== selectedModelId) { // Pass false as third parameter to indicate this is not a user action // This is an internal sync, not a user-initiated change setApiConfigurationField("apiModelId", selectedModelId, false) } - }, [selectedModelId, setApiConfigurationField, apiConfiguration.apiModelId]) + }, [selectedModelId, setApiConfigurationField, apiConfiguration.apiModelId, isRetiredSelectedProvider]) // Debounced refresh model updates, only executed 250ms after the user // stops typing. @@ -243,11 +237,7 @@ const ApiOptions = ({ vscode.postMessage({ type: "requestLmStudioModels" }) } else if (selectedProvider === "vscode-lm") { vscode.postMessage({ type: "requestVsCodeLmModels" }) - } else if ( - selectedProvider === "litellm" || - selectedProvider === "deepinfra" || - selectedProvider === "roo" - ) { + } else if (selectedProvider === "litellm" || selectedProvider === "roo") { vscode.postMessage({ type: "requestRouterModels" }) } }, @@ -261,20 +251,23 @@ const ApiOptions = ({ apiConfiguration?.lmStudioBaseUrl, apiConfiguration?.litellmBaseUrl, apiConfiguration?.litellmApiKey, - apiConfiguration?.deepInfraApiKey, - apiConfiguration?.deepInfraBaseUrl, customHeaders, ], ) useEffect(() => { + if (isRetiredSelectedProvider) { + setErrorMessage(undefined) + return + } + const apiValidationResult = validateApiConfigurationExcludingModelErrors( apiConfiguration, routerModels, organizationAllowList, ) setErrorMessage(apiValidationResult) - }, [apiConfiguration, routerModels, organizationAllowList, setErrorMessage]) + }, [apiConfiguration, routerModels, organizationAllowList, setErrorMessage, isRetiredSelectedProvider]) const onProviderChange = useCallback( (value: ProviderName) => { @@ -282,7 +275,7 @@ const ApiOptions = ({ // It would be much easier to have a single attribute that stores // the modelId, but we have a separate attribute for each of - // OpenRouter, Unbound, and Requesty. + // OpenRouter and Requesty. // If you switch to one of these providers and the corresponding // modelId is not set then you immediately end up in an error state. // To address that we set the modelId to the default value for th @@ -336,25 +329,19 @@ const ApiOptions = ({ } > > = { - deepinfra: { field: "deepInfraModelId", default: deepInfraDefaultModelId }, openrouter: { field: "openRouterModelId", default: openRouterDefaultModelId }, - unbound: { field: "unboundModelId", default: unboundDefaultModelId }, requesty: { field: "requestyModelId", default: requestyDefaultModelId }, litellm: { field: "litellmModelId", default: litellmDefaultModelId }, anthropic: { field: "apiModelId", default: anthropicDefaultModelId }, - cerebras: { field: "apiModelId", default: cerebrasDefaultModelId }, "openai-codex": { field: "apiModelId", default: openAiCodexDefaultModelId }, "qwen-code": { field: "apiModelId", default: qwenCodeDefaultModelId }, "openai-native": { field: "apiModelId", default: openAiNativeDefaultModelId }, gemini: { field: "apiModelId", default: geminiDefaultModelId }, deepseek: { field: "apiModelId", default: deepSeekDefaultModelId }, - doubao: { field: "apiModelId", default: doubaoDefaultModelId }, moonshot: { field: "apiModelId", default: moonshotDefaultModelId }, minimax: { field: "apiModelId", default: minimaxDefaultModelId }, mistral: { field: "apiModelId", default: mistralDefaultModelId }, xai: { field: "apiModelId", default: xaiDefaultModelId }, - groq: { field: "apiModelId", default: groqDefaultModelId }, - chutes: { field: "apiModelId", default: chutesDefaultModelId }, baseten: { field: "apiModelId", default: basetenDefaultModelId }, bedrock: { field: "apiModelId", default: bedrockDefaultModelId }, vertex: { field: "apiModelId", default: vertexDefaultModelId }, @@ -367,8 +354,6 @@ const ApiOptions = ({ : internationalZAiDefaultModelId, }, fireworks: { field: "apiModelId", default: fireworksDefaultModelId }, - featherless: { field: "apiModelId", default: featherlessDefaultModelId }, - "io-intelligence": { field: "ioIntelligenceModelId", default: ioIntelligenceDefaultModelId }, roo: { field: "apiModelId", default: rooDefaultModelId }, "vercel-ai-gateway": { field: "vercelAiGatewayModelId", default: vercelAiGatewayDefaultModelId }, openai: { field: "openAiModelId" }, @@ -500,387 +485,363 @@ const ApiOptions = ({ {errorMessage && } - {selectedProvider === "openrouter" && ( - - )} - - {selectedProvider === "requesty" && ( - - )} - - {selectedProvider === "unbound" && ( - - )} - - {selectedProvider === "deepinfra" && ( - - )} - - {selectedProvider === "anthropic" && ( - - )} - - {selectedProvider === "openai-codex" && ( - - )} - - {selectedProvider === "openai-native" && ( - - )} - - {selectedProvider === "mistral" && ( - - )} - - {selectedProvider === "baseten" && ( - - )} - - {selectedProvider === "bedrock" && ( - - )} - - {selectedProvider === "vertex" && ( - - )} - - {selectedProvider === "gemini" && ( - - )} - - {selectedProvider === "openai" && ( - - )} - - {selectedProvider === "lmstudio" && ( - - )} - - {selectedProvider === "deepseek" && ( - - )} - - {selectedProvider === "doubao" && ( - - )} - - {selectedProvider === "qwen-code" && ( - - )} - - {selectedProvider === "moonshot" && ( - - )} - - {selectedProvider === "minimax" && ( - - )} - - {selectedProvider === "vscode-lm" && ( - - )} - - {selectedProvider === "ollama" && ( - - )} - - {selectedProvider === "xai" && ( - - )} - - {selectedProvider === "groq" && ( - - )} - - {selectedProvider === "huggingface" && ( - - )} - - {selectedProvider === "cerebras" && ( - - )} - - {selectedProvider === "chutes" && ( - - )} - - {selectedProvider === "litellm" && ( - - )} - - {selectedProvider === "sambanova" && ( - - )} - - {selectedProvider === "zai" && ( - - )} - - {selectedProvider === "io-intelligence" && ( - - )} - - {selectedProvider === "vercel-ai-gateway" && ( - - )} - - {selectedProvider === "fireworks" && ( - - )} - - {selectedProvider === "roo" && ( - - )} - - {selectedProvider === "featherless" && ( - - )} - - {/* Generic model picker for providers with static models */} - {shouldUseGenericModelPicker(selectedProvider) && ( + {isRetiredSelectedProvider ? ( +
+ {t("settings:providers.retiredProviderMessage")} +
+ ) : ( <> - - handleModelChangeSideEffects(selectedProvider, modelId, setApiConfigurationField) - } - /> + {selectedProvider === "openrouter" && ( + + )} - {selectedProvider === "bedrock" && selectedModelId === "custom-arn" && ( - + )} + + {selectedProvider === "anthropic" && ( + + )} + + {selectedProvider === "azure" && ( + + )} + + {selectedProvider === "openai-codex" && ( + + )} + + {selectedProvider === "openai-native" && ( + + )} + + {selectedProvider === "mistral" && ( + + )} + + {selectedProvider === "baseten" && ( + + )} + + {selectedProvider === "bedrock" && ( + + )} + + {selectedProvider === "vertex" && ( + )} - - )} - {!fromWelcomeView && ( - - )} - - {/* Gate Verbosity UI by capability flag */} - {!fromWelcomeView && selectedModelInfo?.supportsVerbosity && ( - - )} - - {!fromWelcomeView && ( - - - - {t("settings:advancedSettings.title")} - - - setApiConfigurationField(field, value)} + {selectedProvider === "gemini" && ( + - {selectedModelInfo?.supportsTemperature !== false && ( - + )} + + {selectedProvider === "lmstudio" && ( + + )} + + {selectedProvider === "deepseek" && ( + + )} + + {selectedProvider === "qwen-code" && ( + + )} + + {selectedProvider === "moonshot" && ( + + )} + + {selectedProvider === "minimax" && ( + + )} + + {selectedProvider === "vscode-lm" && ( + + )} + + {selectedProvider === "ollama" && ( + + )} + + {selectedProvider === "xai" && ( + + )} + + {selectedProvider === "litellm" && ( + + )} + + {selectedProvider === "sambanova" && ( + + )} + + {selectedProvider === "zai" && ( + + )} + + {selectedProvider === "vercel-ai-gateway" && ( + + )} + + {selectedProvider === "fireworks" && ( + + )} + + {selectedProvider === "roo" && ( + + )} + + {/* Generic model picker for providers with static models */} + {activeSelectedProvider && shouldUseGenericModelPicker(activeSelectedProvider) && ( + <> + + handleModelChangeSideEffects( + activeSelectedProvider, + modelId, + setApiConfigurationField, + ) + } /> - )} - setApiConfigurationField("rateLimitSeconds", value)} - /> - setApiConfigurationField("consecutiveMistakeLimit", value)} - /> - {selectedProvider === "openrouter" && - openRouterModelProviders && - Object.keys(openRouterModelProviders).length > 0 && ( -
-
- - - - -
- -
- {t("settings:providers.openRouter.providerRouting.description")}{" "} - - {t("settings:providers.openRouter.providerRouting.learnMore")}. - -
-
+ + {selectedProvider === "bedrock" && selectedModelId === "custom-arn" && ( + )} -
-
+ + )} + + {!fromWelcomeView && ( + + )} + + {/* Gate Verbosity UI by capability flag */} + {!fromWelcomeView && selectedModelInfo?.supportsVerbosity && ( + + )} + + {!fromWelcomeView && ( + + + + {t("settings:advancedSettings.title")} + + + setApiConfigurationField(field, value)} + /> + {selectedModelInfo?.supportsTemperature !== false && ( + + )} + setApiConfigurationField("rateLimitSeconds", value)} + /> + setApiConfigurationField("consecutiveMistakeLimit", value)} + /> + {selectedProvider === "openrouter" && + openRouterModelProviders && + Object.keys(openRouterModelProviders).length > 0 && ( +
+
+ + + + +
+ +
+ {t("settings:providers.openRouter.providerRouting.description")}{" "} + + {t("settings:providers.openRouter.providerRouting.learnMore")}. + +
+
+ )} +
+
+ )} + )}
) diff --git a/webview-ui/src/components/settings/CreateSkillDialog.tsx b/webview-ui/src/components/settings/CreateSkillDialog.tsx index a4daa9989c..3a8def14ee 100644 --- a/webview-ui/src/components/settings/CreateSkillDialog.tsx +++ b/webview-ui/src/components/settings/CreateSkillDialog.tsx @@ -7,17 +7,20 @@ import { useAppTranslation } from "@/i18n/TranslationContext" import { useExtensionState } from "@/context/ExtensionStateContext" import { Button, + Checkbox, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, + Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, + Textarea, } from "@/components/ui" import { vscode } from "@/utils/vscode" @@ -65,9 +68,6 @@ const validateDescription = (description: string): string | null => { return null } -// Sentinel value for "Any mode" since Radix Select doesn't allow empty string values -const MODE_ANY = "__any__" - export const CreateSkillDialog: React.FC = ({ open, onOpenChange, @@ -80,11 +80,14 @@ export const CreateSkillDialog: React.FC = ({ const [name, setName] = useState("") const [description, setDescription] = useState("") const [source, setSource] = useState<"global" | "project">(hasWorkspace ? "project" : "global") - const [mode, setMode] = useState(MODE_ANY) const [nameError, setNameError] = useState(null) const [descriptionError, setDescriptionError] = useState(null) - // Get available modes for the dropdown (built-in + custom modes) + // Multi-mode selection state (same pattern as SkillsSettings mode dialog) + const [selectedModes, setSelectedModes] = useState([]) + const [isAnyMode, setIsAnyMode] = useState(true) + + // Get available modes for the checkboxes (built-in + custom modes) const availableModes = useMemo(() => { return getAllModes(customModes).map((m) => ({ slug: m.slug, name: m.name })) }, [customModes]) @@ -93,7 +96,8 @@ export const CreateSkillDialog: React.FC = ({ setName("") setDescription("") setSource(hasWorkspace ? "project" : "global") - setMode(MODE_ANY) + setSelectedModes([]) + setIsAnyMode(true) setNameError(null) setDescriptionError(null) }, [hasWorkspace]) @@ -114,6 +118,33 @@ export const CreateSkillDialog: React.FC = ({ setDescriptionError(null) }, []) + // Handle "Any mode" toggle - mutually exclusive with specific modes + const handleAnyModeToggle = useCallback((checked: boolean) => { + if (checked) { + setIsAnyMode(true) + setSelectedModes([]) // Clear specific modes when "Any mode" is selected + } else { + setIsAnyMode(false) + } + }, []) + + // Handle specific mode toggle - unchecks "Any mode" when a specific mode is selected + const handleModeToggle = useCallback((modeSlug: string, checked: boolean) => { + if (checked) { + setIsAnyMode(false) // Uncheck "Any mode" when selecting a specific mode + setSelectedModes((prev) => [...prev, modeSlug]) + } else { + setSelectedModes((prev) => { + const newModes = prev.filter((m) => m !== modeSlug) + // If no modes selected, default back to "Any mode" + if (newModes.length === 0) { + setIsAnyMode(true) + } + return newModes + }) + } + }, []) + const handleCreate = useCallback(() => { // Validate fields const nameValidationError = validateSkillName(name) @@ -130,73 +161,64 @@ export const CreateSkillDialog: React.FC = ({ } // Send message to create skill - // Convert MODE_ANY sentinel value to undefined for the backend + // Convert to modeSlugs: undefined for "Any mode", or array of selected modes + const modeSlugs = isAnyMode ? undefined : selectedModes.length > 0 ? selectedModes : undefined vscode.postMessage({ type: "createSkill", skillName: name, source, skillDescription: description, - skillMode: mode === MODE_ANY ? undefined : mode, + skillModeSlugs: modeSlugs, }) // Close dialog and notify parent handleClose() onSkillCreated() - }, [name, description, source, mode, handleClose, onSkillCreated]) + }, [name, description, source, isAnyMode, selectedModes, handleClose, onSkillCreated]) return ( {t("settings:skills.createDialog.title")} - {t("settings:skills.createDialog.description")} + -
+
{/* Name Input */} -
+
- - - {t("settings:skills.createDialog.nameHint")} - {nameError && {t(nameError)}}
{/* Description Input */} -
- -