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/workflows/cli-release.yml b/.github/workflows/cli-release.yml new file mode 100644 index 0000000000..3bcb8995fd --- /dev/null +++ b/.github/workflows/cli-release.yml @@ -0,0 +1,390 @@ +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 + + 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) or Linux x64" >> "$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 "" >> "$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..87fcc57add 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -5,6 +5,35 @@ 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.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..b9589d985b 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.51", "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/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-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/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index 52f1b884d5..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.107.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/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 d4a05f8e3e..54267d67e4 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -1,6 +1,7 @@ import { z } from "zod" import { clineMessageSchema, queuedMessageSchema, tokenUsageSchema } from "./message.js" +import { modelInfoSchema } from "./model.js" import { toolNamesSchema, toolUsageSchema } from "./tool.js" /** @@ -45,6 +46,11 @@ export enum RooCodeEventName { ModeChanged = "modeChanged", ProviderProfileChanged = "providerProfileChanged", + // Query Responses + CommandsResponse = "commandsResponse", + ModesResponse = "modesResponse", + ModelsResponse = "modelsResponse", + // Evals EvalPass = "evalPass", EvalFail = "evalFail", @@ -108,6 +114,20 @@ export const rooCodeEventsSchema = z.object({ [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 @@ -237,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 11b9fe148d..fce48cfb5d 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" /** @@ -232,6 +233,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 diff --git a/packages/types/src/ipc.ts b/packages/types/src/ipc.ts index 9f6d2de04d..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", } /** @@ -79,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..555513500b 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -227,8 +227,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 +271,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({ 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/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/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/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/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/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/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 21bc59092a..49a63c3ae4 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -334,7 +334,9 @@ export type ExtensionState = Pick< | "maxGitStatusFiles" | "requestDelaySeconds" | "showWorktreesInHomeScreen" + | "disabledTools" > & { + lockApiConfigAcrossModes?: boolean version: string clineMessages: ClineMessage[] currentTaskItem?: HistoryItem @@ -523,6 +525,7 @@ export interface WebviewMessage { | "searchFiles" | "toggleApiConfigPin" | "hasOpenedModeSelector" + | "lockApiConfigAcrossModes" | "clearCloudAuthSkipModel" | "cloudButtonClicked" | "rooCloudSignIn" @@ -605,6 +608,7 @@ export interface WebviewMessage { | "createSkill" | "deleteSkill" | "moveSkill" + | "updateSkillModes" | "openSkillFile" text?: string editedMessageContent?: string @@ -641,9 +645,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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41db99fb0e..7f48e153c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -746,24 +746,36 @@ importers: src: dependencies: + '@ai-sdk/amazon-bedrock': + specifier: ^4.0.51 + version: 4.0.51(zod@3.25.76) + '@ai-sdk/baseten': + specifier: ^1.0.31 + version: 1.0.31(zod@3.25.76) '@ai-sdk/cerebras': - specifier: ^1.0.0 - version: 1.0.35(zod@3.25.76) + specifier: ^2.0.31 + version: 2.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) + 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/groq': + specifier: ^3.0.22 + version: 3.0.22(zod@3.25.76) + '@ai-sdk/mistral': specifier: ^3.0.19 version: 3.0.19(zod@3.25.76) - '@ai-sdk/mistral': - specifier: ^3.0.0 - version: 3.0.16(zod@3.25.76) - '@anthropic-ai/bedrock-sdk': - specifier: ^0.10.2 - version: 0.10.4 + '@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 @@ -938,6 +950,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 @@ -1004,16 +1019,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 @@ -1087,8 +1105,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 @@ -1405,83 +1423,109 @@ 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/baseten@1.0.31': + resolution: {integrity: sha512-tGbV96WBb5nnfyUYFrPyBxrhw53YlKSJbMC+rH3HhQlUaIs8+m/Bm4M0isrek9owIIf4MmmSDZ5VZL08zz7eFQ==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/gateway@3.0.25': - resolution: {integrity: sha512-j0AQeA7hOVqwImykQlganf/Euj3uEXf0h3G0O4qKTDpEwE+EZGIPnVimCWht5W91lAetPZSfavDyvfpuPDd2PQ==} + '@ai-sdk/cerebras@2.0.31': + resolution: {integrity: sha512-s7o4BRsbG2RFina4VwHs46RWlQPGCL1CrfOoMomYneJeA0CgpxPigPqwlrupaWWB42KIDDHN5gNOIsLst0oOPg==} 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/mistral@3.0.16': - resolution: {integrity: sha512-8I/gxXJwghaDLbQQHMBwd61WxYz/PaFUFlG8I38daNYj5qRTMmQ5V10Idi6GJJC0wWEqQkal31lidm9+Y+u6TQ==} + '@ai-sdk/fireworks@2.0.32': + resolution: {integrity: sha512-2qOEvocoRxUND086pjgliSBFKTyy6LUKbHZvXr++zlHm8ZbMT4dES78f5MHbOP9UVvRCPfTKmlPsUFUP/EVhJQ==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/openai-compatible@1.0.31': - resolution: {integrity: sha512-znBvaVHM0M6yWNerIEy3hR+O8ZK2sPcE7e2cxfb6kYLEX3k//JH5VDnRnajseVofg7LXtTCFFdjsB7WLf1BdeQ==} + '@ai-sdk/gateway@3.0.39': + resolution: {integrity: sha512-SeCZBAdDNbWpVUXiYgOAqis22p5MEYfrjRw0hiBa5hM+7sDGYQpMinUjkM8kbPXMkY+AhKLrHleBl+SuqpzlgA==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/openai-compatible@2.0.24': - resolution: {integrity: sha512-3QrCKpQCn3g6sIMoFGuEroaqk7Xg+qfsohRp4dKszjto5stjBg4SdtOKqHg+CpE3X4woj2O62w2qr5dSekMZeQ==} + '@ai-sdk/google-vertex@4.0.45': + resolution: {integrity: sha512-KkOsYd9DiyNatqxr/dSKzC6qrxwxOXZ63vu6Yfz2A7bPCsrwKzcN9SQRuhbVkBa1j0C78YiSDKuQvclfOk/0Kw==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/provider-utils@3.0.20': - resolution: {integrity: sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ==} + '@ai-sdk/google@3.0.22': + resolution: {integrity: sha512-g1N5P/jfTiH4qwdv4WT3hkKzzAbITFz457NomtBfjP8Q3SCzdbU9oPK5ACBMG8RN5mc2QPL6DLtM3Hf5T8KPmw==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/provider-utils@4.0.10': - resolution: {integrity: sha512-VeDAiCH+ZK8Xs4hb9Cw7pHlujWNL52RKe8TExOkrw6Ir1AmfajBZTb9XUdKOZO08RwQElIKA8+Ltm+Gqfo8djQ==} + '@ai-sdk/groq@3.0.22': + resolution: {integrity: sha512-QBkqBmlts2qz2vX54gXeP9IdztMFxZw7xPNwjOjHYhEL7RynzB2aFafPIbAYTVNosrU0YEETxhw9LISjS2TtXw==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/provider-utils@4.0.11': - resolution: {integrity: sha512-y/WOPpcZaBjvNaogy83mBsCRPvbtaK0y1sY9ckRrrbTGMvG2HC/9Y/huqNXKnLAxUIME2PGa2uvF2CDwIsxoXQ==} + '@ai-sdk/mistral@3.0.19': + resolution: {integrity: sha512-yd0OJ3fm2YKdwxh1pd9m720sENVVcylAD+Bki8C80QqVpUxGNL1/C4N4JJGb56eCCWr6VU/3gHFe9PKui9n/Hg==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/provider@2.0.1': - resolution: {integrity: sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==} + '@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/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.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@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==} @@ -1501,9 +1545,6 @@ 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==} @@ -1513,9 +1554,6 @@ packages: '@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'} @@ -1523,9 +1561,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'} @@ -1533,12 +1568,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==} @@ -1634,14 +1663,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'} @@ -1670,9 +1691,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'} @@ -1820,6 +1838,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==} @@ -3805,10 +3910,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'} @@ -3825,9 +3926,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'} @@ -3840,25 +3938,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'} @@ -3875,10 +3962,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'} @@ -3887,10 +3970,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'} @@ -3899,66 +3978,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'} @@ -3967,57 +4014,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'} @@ -4034,10 +4050,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'} @@ -4058,26 +4070,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'} @@ -4086,22 +4082,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'} @@ -4110,10 +4094,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'} @@ -4901,8 +4881,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 @@ -5072,6 +5052,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==} @@ -6066,6 +6049,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'} @@ -6497,10 +6484,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'} @@ -9520,6 +9503,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==} @@ -11000,6 +10986,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'} @@ -11053,90 +11043,128 @@ 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/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.20(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/deepseek@2.0.14(zod@3.25.76)': + '@ai-sdk/anthropic@3.0.38(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 3.0.5 - '@ai-sdk/provider-utils': 4.0.10(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/fireworks@2.0.26(zod@3.25.76)': + '@ai-sdk/baseten@1.0.31(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/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/gateway@3.0.25(zod@3.25.76)': + '@ai-sdk/cerebras@2.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) + zod: 3.25.76 + + '@ai-sdk/deepseek@2.0.18(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/fireworks@2.0.32(zod@3.25.76)': + dependencies: + '@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/mistral@3.0.16(zod@3.25.76)': + '@ai-sdk/groq@3.0.22(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/openai-compatible@1.0.31(zod@3.25.76)': + '@ai-sdk/mistral@3.0.19(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.20(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/openai-compatible@2.0.24(zod@3.25.76)': + '@ai-sdk/openai-compatible@1.0.11(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': 2.0.0 + '@ai-sdk/provider-utils': 3.0.5(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/provider-utils@3.0.20(zod@3.25.76)': + '@ai-sdk/openai-compatible@2.0.28(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 2.0.1 + '@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.5(zod@3.25.76)': + dependencies: + '@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.10(zod@3.25.76)': + '@ai-sdk/provider@2.0.0': dependencies: - '@ai-sdk/provider': 3.0.5 - '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.0.6 + json-schema: 0.4.0 + + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/xai@3.0.48(zod@3.25.76)': + dependencies: + '@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/provider-utils@4.0.11(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 3.0.6 - '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.0.6 - zod: 3.25.76 - - '@ai-sdk/provider@2.0.1': - dependencies: - json-schema: 0.4.0 - - '@ai-sdk/provider@3.0.5': - dependencies: - json-schema: 0.4.0 - - '@ai-sdk/provider@3.0.6': - dependencies: - json-schema: 0.4.0 - '@alcalzone/ansi-tokenize@0.2.3': dependencies: ansi-styles: 6.2.3 @@ -11156,23 +11184,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 @@ -11201,12 +11212,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 @@ -11223,12 +11228,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 @@ -11239,18 +11238,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 @@ -11658,16 +11645,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 @@ -11707,10 +11684,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 @@ -11920,6 +11893,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': {} @@ -12849,9 +12881,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': @@ -13893,11 +13925,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 @@ -13933,13 +13960,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 @@ -13958,38 +13978,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 @@ -14014,10 +14014,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 @@ -14028,16 +14024,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 @@ -14061,34 +14047,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 @@ -14096,14 +14065,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 @@ -14112,43 +14073,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 @@ -14158,26 +14098,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 @@ -14189,15 +14114,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 @@ -14208,40 +14124,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 @@ -14261,11 +14153,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 @@ -14298,28 +14185,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 @@ -14331,17 +14200,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 @@ -14353,14 +14211,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 @@ -14370,11 +14220,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 @@ -15113,7 +14958,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: @@ -15263,11 +15108,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 @@ -15479,6 +15324,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + aws4fetch@1.0.20: {} + axios@1.12.0: dependencies: follow-redirects: 1.15.11 @@ -16469,6 +16316,8 @@ snapshots: dotenv@16.0.3: {} + dotenv@16.4.5: {} + dotenv@16.5.0: {} drizzle-kit@0.31.4: @@ -16960,13 +16809,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: @@ -20622,6 +20469,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 @@ -22393,6 +22249,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/api/index.ts b/src/api/index.ts index 30119b7dc7..0e25a739a6 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -117,6 +117,15 @@ 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 { diff --git a/src/api/providers/__tests__/baseten.spec.ts b/src/api/providers/__tests__/baseten.spec.ts new file mode 100644 index 0000000000..e44b201f29 --- /dev/null +++ b/src/api/providers/__tests__/baseten.spec.ts @@ -0,0 +1,446 @@ +// 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(), + mockGenerateText: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) + +vi.mock("@ai-sdk/baseten", () => ({ + createBaseten: vi.fn(() => { + return vi.fn(() => ({ + modelId: "zai-org/GLM-4.6", + provider: "baseten", + })) + }), +})) + +import type { Anthropic } from "@anthropic-ai/sdk" + +import { basetenDefaultModelId, basetenModels, type BasetenModelId } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../../shared/api" + +import { BasetenHandler } from "../baseten" + +describe("BasetenHandler", () => { + let handler: BasetenHandler + let mockOptions: ApiHandlerOptions + + beforeEach(() => { + mockOptions = { + basetenApiKey: "test-baseten-api-key", + apiModelId: "zai-org/GLM-4.6", + } + handler = new BasetenHandler(mockOptions) + vi.clearAllMocks() + }) + + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(BasetenHandler) + expect(handler.getModel().id).toBe(mockOptions.apiModelId) + }) + + it("should use default model ID if not provided", () => { + const handlerWithoutModel = new BasetenHandler({ + ...mockOptions, + apiModelId: undefined, + }) + expect(handlerWithoutModel.getModel().id).toBe(basetenDefaultModelId) + }) + }) + + describe("getModel", () => { + 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 BasetenHandler({ + ...mockOptions, + apiModelId: "invalid-model", + }) + const model = handlerWithInvalidModel.getModel() + expect(model.id).toBe("invalid-model") + expect(model.info).toBeDefined() + expect(model.info).toBe(basetenModels[basetenDefaultModelId]) + }) + + 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 Baseten" } + } + + 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) + } + + 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 Baseten") + }) + + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 20, + }) + + 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(20) + }) + + 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 }), + }) + + 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) + 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 Baseten", + }) + + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("Test completion from Baseten") + 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("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 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, + }) + + 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 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 index c89ccb7990..22da350003 100644 --- a/src/api/providers/__tests__/chutes.spec.ts +++ b/src/api/providers/__tests__/chutes.spec.ts @@ -1,336 +1,490 @@ // npx vitest run api/providers/__tests__/chutes.spec.ts -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +const { mockStreamText, mockGenerateText, mockGetModels, mockGetModelsFromCache } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), + mockGetModels: vi.fn(), + mockGetModelsFromCache: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) + +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + return vi.fn((modelId: string) => ({ + modelId, + provider: "chutes", + })) + }), +})) + +vi.mock("../fetchers/modelCache", () => ({ + getModels: mockGetModels, + getModelsFromCache: mockGetModelsFromCache, +})) + +import type { Anthropic } from "@anthropic-ai/sdk" 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, + mockGetModels.mockResolvedValue({ + [chutesDefaultModelId]: chutesDefaultModelInfo, }) - handler.fetchModel = mockFetchModel + mockGetModelsFromCache.mockReturnValue(undefined) + handler = new ChutesHandler({ chutesApiKey: "test-key" }) }) 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" })) + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(ChutesHandler) + }) + + it("should use default model when no model ID is provided", () => { + const model = handler.getModel() + expect(model.id).toBe(chutesDefaultModelId) + }) }) - it("should use the provided API key", () => { - const chutesApiKey = "test-chutes-api-key" - new ChutesHandler({ chutesApiKey }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: chutesApiKey })) + describe("getModel", () => { + it("should return default model when no model is specified and no cache", () => { + const model = handler.getModel() + expect(model.id).toBe(chutesDefaultModelId) + expect(model.info).toEqual( + expect.objectContaining({ + ...chutesDefaultModelInfo, + }), + ) + }) + + it("should return model info from fetched models", async () => { + const testModelInfo = { + maxTokens: 4096, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + } + mockGetModels.mockResolvedValue({ + "some-model": testModelInfo, + }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "some-model", + chutesApiKey: "test-key", + }) + const model = await handlerWithModel.fetchModel() + expect(model.id).toBe("some-model") + expect(model.info).toEqual(expect.objectContaining(testModelInfo)) + }) + + it("should fall back to global cache when instance models are empty", () => { + const cachedInfo = { + maxTokens: 2048, + contextWindow: 64000, + supportsImages: false, + supportsPromptCache: false, + } + mockGetModelsFromCache.mockReturnValue({ + "cached-model": cachedInfo, + }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "cached-model", + chutesApiKey: "test-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe("cached-model") + expect(model.info).toEqual(expect.objectContaining(cachedInfo)) + }) + + it("should apply DeepSeek default temperature for R1 models", () => { + const r1Info = { + maxTokens: 32768, + contextWindow: 163840, + supportsImages: false, + supportsPromptCache: false, + } + mockGetModelsFromCache.mockReturnValue({ + "deepseek-ai/DeepSeek-R1-0528": r1Info, + }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "deepseek-ai/DeepSeek-R1-0528", + chutesApiKey: "test-key", + }) + const model = handlerWithModel.getModel() + expect(model.info.defaultTemperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE) + expect(model.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE) + }) + + it("should use default temperature for non-DeepSeek models", () => { + const modelInfo = { + maxTokens: 4096, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + } + mockGetModelsFromCache.mockReturnValue({ + "unsloth/Llama-3.3-70B-Instruct": modelInfo, + }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "unsloth/Llama-3.3-70B-Instruct", + chutesApiKey: "test-key", + }) + const model = handlerWithModel.getModel() + expect(model.info.defaultTemperature).toBe(0.5) + expect(model.temperature).toBe(0.5) + }) }) - 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 }, - } - }, - })) + describe("fetchModel", () => { + it("should fetch models and return the resolved model", async () => { + const model = await handler.fetchModel() + expect(mockGetModels).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "chutes", + }), + ) + expect(model.id).toBe(chutesDefaultModelId) + }) + }) + describe("createMessage", () => { 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) - } + it("should handle non-DeepSeek models with standard streaming", async () => { + mockGetModels.mockResolvedValue({ + "some-other-model": { maxTokens: 1024, contextWindow: 8192, supportsPromptCache: false }, + }) - 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 }), - }), + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } } - }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) - }) + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) - 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 handlerWithModel = new ChutesHandler({ + apiModelId: "some-other-model", + chutesApiKey: "test-key", + }) + + const stream = handlerWithModel.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - }) - 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: {} }, + expect(chunks).toEqual([ + { type: "text", text: "Test response" }, + { + type: "usage", + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: undefined, + reasoningTokens: undefined, }, - }, - ] - const tool_choice = "auto" as const + ]) + }) - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi.fn().mockResolvedValueOnce({ done: true }), - }), + it("should handle DeepSeek R1 reasoning format with TagMatcher", async () => { + mockGetModels.mockResolvedValue({ + "deepseek-ai/DeepSeek-R1-0528": { + maxTokens: 32768, + contextWindow: 163840, + supportsImages: false, + supportsPromptCache: false, + }, + }) + + async function* mockFullStream() { + yield { type: "text-delta", text: "Thinking..." } + yield { type: "text-delta", text: "Hello" } } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "deepseek-ai/DeepSeek-R1-0528", + chutesApiKey: "test-key", + }) + + const stream = handlerWithModel.createMessage(systemPrompt, messages) + const chunks: any[] = [] + 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, + cacheReadTokens: undefined, + reasoningTokens: undefined, + }, + ]) }) - const stream = handler.createMessage("system prompt", [], { tools, tool_choice, taskId: "test-task-id" }) - // Consume stream - for await (const _ of stream) { - // noop - } + it("should handle tool calls in R1 path", async () => { + mockGetModels.mockResolvedValue({ + "deepseek-ai/DeepSeek-R1-0528": { + maxTokens: 32768, + contextWindow: 163840, + supportsImages: false, + supportsPromptCache: false, + }, + }) - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools, - tool_choice, - }), - ) + async function* mockFullStream() { + yield { type: "text-delta", text: "Let me help" } + yield { + type: "tool-input-start", + id: "call_123", + toolName: "test_tool", + } + yield { + type: "tool-input-delta", + id: "call_123", + delta: '{"arg":"value"}', + } + yield { + type: "tool-input-end", + id: "call_123", + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 15, + outputTokens: 10, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "deepseek-ai/DeepSeek-R1-0528", + chutesApiKey: "test-key", + }) + + const stream = handlerWithModel.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks).toContainEqual({ type: "text", text: "Let me help" }) + expect(chunks).toContainEqual({ + type: "tool_call_start", + id: "call_123", + name: "test_tool", + }) + expect(chunks).toContainEqual({ + type: "tool_call_delta", + id: "call_123", + delta: '{"arg":"value"}', + }) + expect(chunks).toContainEqual({ + type: "tool_call_end", + id: "call_123", + }) + }) + + it("should merge system prompt into first user message for R1 path", async () => { + mockGetModels.mockResolvedValue({ + "deepseek-ai/DeepSeek-R1-0528": { + maxTokens: 32768, + contextWindow: 163840, + supportsImages: false, + supportsPromptCache: false, + }, + }) + + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 3 }), + }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "deepseek-ai/DeepSeek-R1-0528", + chutesApiKey: "test-key", + }) + + const stream = handlerWithModel.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.any(Array), + }), + ) + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.system).toBeUndefined() + }) + + it("should pass system prompt separately for non-R1 path", async () => { + mockGetModels.mockResolvedValue({ + "some-model": { maxTokens: 1024, contextWindow: 8192, supportsPromptCache: false }, + }) + + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 3 }), + }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "some-model", + chutesApiKey: "test-key", + }) + + const stream = handlerWithModel.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + system: systemPrompt, + }), + ) + }) + + it("should include usage information from stream", async () => { + mockGetModels.mockResolvedValue({ + "some-model": { maxTokens: 1024, contextWindow: 8192, supportsPromptCache: false }, + }) + + async function* mockFullStream() { + yield { type: "text-delta", text: "Hello" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 20, + outputTokens: 10, + }), + }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "some-model", + chutesApiKey: "test-key", + }) + + const stream = handlerWithModel.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(20) + expect(usageChunks[0].outputTokens).toBe(10) + }) }) - 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", + describe("completePrompt", () => { + it("should return text from generateText", async () => { + const expectedResponse = "This is a test response from Chutes" + mockGenerateText.mockResolvedValue({ text: expectedResponse }) + + const result = await handler.completePrompt("test prompt") + expect(result).toBe(expectedResponse) + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "test prompt", + }), + ) + }) + + it("should handle errors in completePrompt", async () => { + const errorMessage = "Chutes API error" + mockGenerateText.mockRejectedValue(new Error(errorMessage)) + await expect(handler.completePrompt("test prompt")).rejects.toThrow( + `Chutes completion error: ${errorMessage}`, + ) + }) + + it("should pass temperature for R1 models in completePrompt", async () => { + mockGetModels.mockResolvedValue({ + "deepseek-ai/DeepSeek-R1-0528": { + maxTokens: 32768, + contextWindow: 163840, + supportsImages: false, + supportsPromptCache: false, + }, + }) + + mockGenerateText.mockResolvedValue({ text: "response" }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "deepseek-ai/DeepSeek-R1-0528", + chutesApiKey: "test-key", + }) + + await handlerWithModel.completePrompt("test prompt") + + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: DEEP_SEEK_DEFAULT_TEMPERATURE, + }), + ) }) - 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", + describe("isAiSdkProvider", () => { + it("should return true", () => { + expect(handler.isAiSdkProvider()).toBe(true) }) - // 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__/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 index 936c10fcd0..0223da9a63 100644 --- a/src/api/providers/__tests__/featherless.spec.ts +++ b/src/api/providers/__tests__/featherless.spec.ts @@ -1,259 +1,356 @@ // npx vitest run api/providers/__tests__/featherless.spec.ts -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +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-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + return vi.fn(() => ({ + modelId: "featherless-model", + provider: "Featherless", + })) + }), +})) + +import type { Anthropic } from "@anthropic-ai/sdk" import { type FeatherlessModelId, featherlessDefaultModelId, featherlessModels } from "@roo-code/types" +import type { ApiHandlerOptions } from "../../../shared/api" + 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 + let mockOptions: ApiHandlerOptions beforeEach(() => { + mockOptions = { + featherlessApiKey: "test-api-key", + } + handler = new FeatherlessHandler(mockOptions) 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, + }) + + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(FeatherlessHandler) + expect(handler.getModel().id).toBe(featherlessDefaultModelId) + }) + + it("should use specified model ID when provided", () => { + const testModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct" + const handlerWithModel = new FeatherlessHandler({ + apiModelId: testModelId, + featherlessApiKey: "test-api-key", + }) + expect(handlerWithModel.getModel().id).toBe(testModelId) + }) + }) + + describe("getModel", () => { + 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-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual(expect.objectContaining(featherlessModels[testModelId])) + }) + + it("should use default temperature for non-DeepSeek models", () => { + const testModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct" + const handlerWithModel = new FeatherlessHandler({ + apiModelId: testModelId, + featherlessApiKey: "test-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.temperature).toBe(0.5) + }) + + 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!", }, - } + ], }, - })) - 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 }), - }), + it("should handle streaming responses for non-R1 models", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } } - }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) - }) + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) - 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(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") }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 }) + 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 format from DeepSeek-R1 models using TagMatcher", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Thinking..." } + yield { type: "text-delta", text: "Hello" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + vi.spyOn(handler, "getModel").mockReturnValue({ + id: "some-DeepSeek-R1-model", + info: { maxTokens: 1024, temperature: 0.6 }, + maxTokens: 1024, + temperature: 0.6, + } as any) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + 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 delegate to super.createMessage for non-DeepSeek-R1 models", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Standard response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 15, + outputTokens: 8, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + vi.spyOn(handler, "getModel").mockReturnValue({ + id: "some-other-model", + info: { maxTokens: 1024, temperature: 0.5 }, + maxTokens: 1024, + temperature: 0.5, + } as any) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks[0]).toEqual({ type: "text", text: "Standard response" }) + expect(chunks[1]).toMatchObject({ type: "usage", inputTokens: 15, outputTokens: 8 }) + }) + + it("should pass correct model to streamText for R1 path", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + vi.spyOn(handler, "getModel").mockReturnValue({ + id: "some-DeepSeek-R1-model", + info: { maxTokens: 2048, temperature: 0.6 }, + maxTokens: 2048, + temperature: 0.6, + } as any) + + const stream = handler.createMessage(systemPrompt, messages) + // Consume stream + for await (const _ of stream) { + // drain + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.6, + }), + ) + }) + + it("should not pass system prompt to streamText for R1 path", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + vi.spyOn(handler, "getModel").mockReturnValue({ + id: "some-DeepSeek-R1-model", + info: { maxTokens: 2048, temperature: 0.6 }, + maxTokens: 2048, + temperature: 0.6, + } as any) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // drain + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.system).toBeUndefined() + expect(callArgs.messages).toBeDefined() + }) + + it("should merge consecutive user messages in R1 path to avoid DeepSeek rejection", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + vi.spyOn(handler, "getModel").mockReturnValue({ + id: "some-DeepSeek-R1-model", + info: { maxTokens: 2048, temperature: 0.6 }, + maxTokens: 2048, + temperature: 0.6, + } as any) + + // messages starts with a user message, so after prepending the system + // prompt as a user message we'd have two consecutive user messages. + const userFirstMessages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Hello!" }, + { role: "assistant", content: "Hi there" }, + { role: "user", content: "Follow-up" }, + ] + + const stream = handler.createMessage(systemPrompt, userFirstMessages) + for await (const _ of stream) { + // drain + } + + const callArgs = mockStreamText.mock.calls[0][0] + const passedMessages = callArgs.messages + + // Verify no two consecutive messages share the same role + for (let i = 1; i < passedMessages.length; i++) { + expect(passedMessages[i].role).not.toBe(passedMessages[i - 1].role) + } + + // The system prompt and first user message should be merged into a single user message + expect(passedMessages[0].role).toBe("user") + expect(passedMessages[1].role).toBe("assistant") + expect(passedMessages[2].role).toBe("user") + expect(passedMessages).toHaveLength(3) + }) }) - it("createMessage should pass correct parameters to Featherless client", async () => { - const modelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct" + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion from Featherless", + }) - // Clear previous mocks and set up new implementation - mockCreate.mockClear() - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - // Empty stream for this test - }, - })) + const result = await handler.completePrompt("Test prompt") - const handlerWithModel = new FeatherlessHandler({ - apiModelId: modelId, - featherlessApiKey: "test-featherless-api-key", + expect(result).toBe("Test completion from Featherless") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) }) - - 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", + describe("isAiSdkProvider", () => { + it("should return true", () => { + expect(handler.isAiSdkProvider()).toBe(true) }) - 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..ceeb553da3 100644 --- a/src/api/providers/__tests__/gemini.spec.ts +++ b/src/api/providers/__tests__/gemini.spec.ts @@ -10,6 +10,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 +49,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 +66,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 +114,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 +140,27 @@ 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 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 +174,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 +201,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 +308,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 +345,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 +367,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__/huggingface.spec.ts b/src/api/providers/__tests__/huggingface.spec.ts new file mode 100644 index 0000000000..e7682474c1 --- /dev/null +++ b/src/api/providers/__tests__/huggingface.spec.ts @@ -0,0 +1,553 @@ +// npx vitest run src/api/providers/__tests__/huggingface.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/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + // Return a function that returns a mock language model + return vi.fn(() => ({ + modelId: "meta-llama/Llama-3.3-70B-Instruct", + provider: "huggingface", + })) + }), +})) + +// Mock the fetchers +vi.mock("../fetchers/huggingface", () => ({ + getHuggingFaceModels: vi.fn(() => Promise.resolve({})), + getCachedHuggingFaceModels: vi.fn(() => ({})), +})) + +import type { Anthropic } from "@anthropic-ai/sdk" + +import type { ApiHandlerOptions } from "../../../shared/api" + +import { HuggingFaceHandler } from "../huggingface" + +describe("HuggingFaceHandler", () => { + let handler: HuggingFaceHandler + let mockOptions: ApiHandlerOptions + + beforeEach(() => { + mockOptions = { + huggingFaceApiKey: "test-huggingface-api-key", + huggingFaceModelId: "meta-llama/Llama-3.3-70B-Instruct", + } + handler = new HuggingFaceHandler(mockOptions) + vi.clearAllMocks() + }) + + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(HuggingFaceHandler) + expect(handler.getModel().id).toBe(mockOptions.huggingFaceModelId) + }) + + it("should use default model ID if not provided", () => { + const handlerWithoutModel = new HuggingFaceHandler({ + ...mockOptions, + huggingFaceModelId: undefined, + }) + expect(handlerWithoutModel.getModel().id).toBe("meta-llama/Llama-3.3-70B-Instruct") + }) + + it("should throw error if API key is not provided", () => { + expect(() => { + new HuggingFaceHandler({ + ...mockOptions, + huggingFaceApiKey: undefined, + }) + }).toThrow("Hugging Face API key is required") + }) + }) + + describe("getModel", () => { + it("should return default model when no model is specified", () => { + const handlerWithoutModel = new HuggingFaceHandler({ + huggingFaceApiKey: "test-huggingface-api-key", + }) + const model = handlerWithoutModel.getModel() + expect(model.id).toBe("meta-llama/Llama-3.3-70B-Instruct") + expect(model.info).toBeDefined() + }) + + it("should return specified model when valid model is provided", () => { + const testModelId = "mistralai/Mistral-7B-Instruct-v0.3" + const handlerWithModel = new HuggingFaceHandler({ + huggingFaceModelId: testModelId, + huggingFaceApiKey: "test-huggingface-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + }) + + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") + }) + + it("should return fallback info when model not in cache", () => { + const model = handler.getModel() + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 8192, + contextWindow: 131072, + supportsImages: false, + supportsPromptCache: false, + }), + ) + }) + }) + + 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 HuggingFace" } + } + + 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 HuggingFace") + }) + + 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, + }) + + // HuggingFace provides cache metrics via providerMetadata for supported models + const mockProviderMetadata = Promise.resolve({ + huggingface: { + 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 HuggingFaceHandler({ + huggingFaceApiKey: "test-key", + huggingFaceModelId: "meta-llama/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 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 HuggingFaceHandler({ + huggingFaceApiKey: "test-key", + huggingFaceModelId: "meta-llama/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 }) + }) + + it("should handle errors with handleAiSdkError", async () => { + async function* mockFullStream(): AsyncGenerator { + yield { type: "text-delta", text: "" } // Yield something before error to satisfy lint + 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("HuggingFace: API Error") + }) + }) + + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion from HuggingFace", + }) + + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("Test completion from HuggingFace") + 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 TestHuggingFaceHandler extends HuggingFaceHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestHuggingFaceHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + } + + const providerMetadata = { + huggingface: { + 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 TestHuggingFaceHandler extends HuggingFaceHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestHuggingFaceHandler(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 TestHuggingFaceHandler extends HuggingFaceHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestHuggingFaceHandler(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") + }) + }) +}) diff --git a/src/api/providers/__tests__/io-intelligence.spec.ts b/src/api/providers/__tests__/io-intelligence.spec.ts index 99dfcefea4..2978ef856c 100644 --- a/src/api/providers/__tests__/io-intelligence.spec.ts +++ b/src/api/providers/__tests__/io-intelligence.spec.ts @@ -1,303 +1,197 @@ -import { Anthropic } from "@anthropic-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/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + return vi.fn(() => ({ + modelId: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", + provider: "IO Intelligence", + })) + }), +})) + +import type { Anthropic } from "@anthropic-ai/sdk" + +import { ioIntelligenceDefaultModelId } from "@roo-code/types" -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), -})) +import { IOIntelligenceHandler } from "../io-intelligence" 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", + ioIntelligenceModelId: "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) + vi.clearAllMocks() }) - afterEach(() => { - vi.restoreAllMocks() - }) + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(IOIntelligenceHandler) + expect(handler.getModel().id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8") + }) - 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 use default model ID if not provided", () => { + const handlerWithoutModel = new IOIntelligenceHandler({ + ...mockOptions, + ioIntelligenceModelId: undefined, + } as ApiHandlerOptions) + expect(handlerWithoutModel.getModel().id).toBe(ioIntelligenceDefaultModelId) + }) - 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 throw error when API key is missing", () => { - const optionsWithoutKey = { ...mockOptions } - delete optionsWithoutKey.ioIntelligenceApiKey + describe("getModel", () => { + it("should return model info for valid model ID", () => { + const model = handler.getModel() + expect(model.id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8") + expect(model.info).toBeDefined() + expect(model.info.maxTokens).toBe(8192) + expect(model.info.contextWindow).toBe(430000) + expect(model.info.supportsImages).toBe(true) + expect(model.info.supportsPromptCache).toBe(false) + }) - expect(() => new IOIntelligenceHandler(optionsWithoutKey)).toThrow("IO Intelligence API key is required") + it("should return default model info for unknown model ID", () => { + const handlerWithUnknown = new IOIntelligenceHandler({ + ...mockOptions, + ioIntelligenceModelId: "unknown-model", + } as ApiHandlerOptions) + const model = handlerWithUnknown.getModel() + expect(model.id).toBe("unknown-model") + expect(model.info).toBeDefined() + expect(model.info.contextWindow).toBe(handler.getModel().info.contextWindow) + }) + + it("should return default model if no model ID is provided", () => { + const handlerWithoutModel = new IOIntelligenceHandler({ + ...mockOptions, + ioIntelligenceModelId: undefined, + } as ApiHandlerOptions) + const model = handlerWithoutModel.getModel() + expect(model.id).toBe(ioIntelligenceDefaultModelId) + expect(model.info).toBeDefined() + }) + + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") + }) }) - it("should handle streaming response correctly", async () => { - const mockStream = [ + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ { - choices: [{ delta: { content: "Hello" } }], - usage: null, - }, - { - choices: [{ delta: { content: " world" } }], - usage: null, - }, - { - choices: [{ delta: {} }], - usage: { prompt_tokens: 10, completion_tokens: 5 }, + role: "user", + content: [ + { + type: "text" as const, + text: "Hello!", + }, + ], }, ] - 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 }), - }), + it("should handle streaming responses", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } } - }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: {}, + raw: {}, + }) - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) - }) + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) - 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(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") }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 }) - }) + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: {}, + raw: {}, + }) - 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, + 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 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, - }) - }) + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", + }) - it("should use default model when no model is specified", () => { - const handlerWithoutModel = new IOIntelligenceHandler({ - ...mockOptions, - apiModelId: undefined, + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("Test completion") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) }) - 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__/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.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts index 86bb0e9721..ac50e6b0a1 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/src/api/providers/__tests__/openai-native.spec.ts @@ -11,6 +11,7 @@ vitest.mock("@roo-code/telemetry", () => ({ })) import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" import { ApiProviderError } from "@roo-code/types" @@ -76,6 +77,28 @@ describe("OpenAiNativeHandler", () => { }) expect(handlerWithoutKey).toBeInstanceOf(OpenAiNativeHandler) }) + + it("should pass undefined baseURL when openAiNativeBaseUrl is empty string", () => { + ;(OpenAI as unknown as ReturnType).mockClear() + new OpenAiNativeHandler({ + apiModelId: "gpt-4.1", + openAiNativeApiKey: "test-key", + openAiNativeBaseUrl: "", + }) + expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: undefined })) + }) + + it("should pass custom baseURL when openAiNativeBaseUrl is a valid URL", () => { + ;(OpenAI as unknown as ReturnType).mockClear() + new OpenAiNativeHandler({ + apiModelId: "gpt-4.1", + openAiNativeApiKey: "test-key", + openAiNativeBaseUrl: "https://custom-openai.example.com/v1", + }) + expect(OpenAI).toHaveBeenCalledWith( + expect.objectContaining({ baseURL: "https://custom-openai.example.com/v1" }), + ) + }) }) describe("createMessage", () => { 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__/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..3ed5dd45cc 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -231,7 +231,13 @@ 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 const betas: string[] = [] diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 3139f5d25a..b2b158f095 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -64,9 +64,11 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa // 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 + // Add 1M context beta flag if enabled for supported models (Claude Sonnet 4/4.5, Opus 4.6) if ( - (modelId === "claude-sonnet-4-20250514" || modelId === "claude-sonnet-4-5") && + (modelId === "claude-sonnet-4-20250514" || + modelId === "claude-sonnet-4-5" || + modelId === "claude-opus-4-6") && this.options.anthropicBeta1MContext ) { betas.push("context-1m-2025-08-07") @@ -80,6 +82,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa switch (modelId) { case "claude-sonnet-4-5": case "claude-sonnet-4-20250514": + case "claude-opus-4-6": case "claude-opus-4-5-20251101": case "claude-opus-4-1-20250805": case "claude-opus-4-20250514": @@ -144,6 +147,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa switch (modelId) { case "claude-sonnet-4-5": case "claude-sonnet-4-20250514": + case "claude-opus-4-6": case "claude-opus-4-5-20251101": case "claude-opus-4-1-20250805": case "claude-opus-4-20250514": @@ -330,8 +334,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) { + // 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 + ) { // Use the tier pricing for 1M context const tier = info.tiers?.[0] if (tier) { @@ -351,6 +358,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" 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 index de1a4b2dbb..f6c516b7a2 100644 --- a/src/api/providers/cerebras.ts +++ b/src/api/providers/cerebras.ts @@ -49,7 +49,13 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan 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 }) + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: CEREBRAS_DEFAULT_TEMPERATURE, + }) return { id, info, ...params } } @@ -156,4 +162,8 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan return text } + + override isAiSdkProvider(): boolean { + return true + } } diff --git a/src/api/providers/chutes.ts b/src/api/providers/chutes.ts index 6b040834cd..66e1d6c987 100644 --- a/src/api/providers/chutes.ts +++ b/src/api/providers/chutes.ts @@ -1,62 +1,110 @@ -import { DEEP_SEEK_DEFAULT_TEMPERATURE, chutesDefaultModelId, chutesDefaultModelInfo } from "@roo-code/types" import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import { streamText, generateText, LanguageModel, ToolSet } from "ai" + +import { + DEEP_SEEK_DEFAULT_TEMPERATURE, + chutesDefaultModelId, + chutesDefaultModelInfo, + type ModelInfo, + type ModelRecord, +} from "@roo-code/types" 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 { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" import { ApiStream } from "../transform/stream" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { RouterProvider } from "./router-provider" +import { OpenAICompatibleHandler, OpenAICompatibleConfig } from "./openai-compatible" +import { getModels, getModelsFromCache } from "./fetchers/modelCache" + +export class ChutesHandler extends OpenAICompatibleHandler implements SingleCompletionHandler { + private models: ModelRecord = {} -export class ChutesHandler extends RouterProvider implements SingleCompletionHandler { constructor(options: ApiHandlerOptions) { - super({ - options, - name: "chutes", + const modelId = options.apiModelId ?? chutesDefaultModelId + + const config: OpenAICompatibleConfig = { + providerName: "chutes", baseURL: "https://llm.chutes.ai/v1", - apiKey: options.chutesApiKey, - modelId: options.apiModelId, - defaultModelId: chutesDefaultModelId, - defaultModelInfo: chutesDefaultModelInfo, - }) + apiKey: options.chutesApiKey ?? "not-provided", + modelId, + modelInfo: chutesDefaultModelInfo, + } + + super(options, config) } - private getCompletionParams( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming { - const { id: model, info } = this.getModel() + async fetchModel() { + this.models = await getModels({ provider: "chutes", apiKey: this.config.apiKey, baseUrl: this.config.baseURL }) + return this.getModel() + } - // Centralized cap: clamp to 20% of the context window (unless provider-specific exceptions apply) - const max_tokens = + override getModel(): { id: string; info: ModelInfo; temperature?: number } { + const id = this.options.apiModelId ?? chutesDefaultModelId + + let info: ModelInfo | undefined = this.models[id] + + if (!info) { + const cachedModels = getModelsFromCache("chutes") + if (cachedModels?.[id]) { + this.models = cachedModels + info = cachedModels[id] + } + } + + if (!info) { + const isDeepSeekR1 = chutesDefaultModelId.includes("DeepSeek-R1") + const defaultTemp = isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0.5 + return { + id: chutesDefaultModelId, + info: { + ...chutesDefaultModelInfo, + defaultTemperature: defaultTemp, + }, + temperature: this.options.modelTemperature ?? defaultTemp, + } + } + + const isDeepSeekR1 = id.includes("DeepSeek-R1") + const defaultTemp = isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0.5 + + return { + id, + info: { + ...info, + defaultTemperature: defaultTemp, + }, + temperature: this.supportsTemperature(id) ? (this.options.modelTemperature ?? defaultTemp) : undefined, + } + } + + protected override getLanguageModel(): LanguageModel { + const { id } = this.getModel() + return this.provider(id) + } + + protected override getMaxOutputTokens(): number | undefined { + const { id, info } = this.getModel() + return ( getModelMaxOutputTokens({ - modelId: model, + modelId: id, 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 + private supportsTemperature(modelId: string): boolean { + return !modelId.startsWith("openai/o3-mini") } override async *createMessage( @@ -67,125 +115,123 @@ export class ChutesHandler extends RouterProvider implements SingleCompletionHan 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 - } + yield* this.createR1Message(systemPrompt, messages, model, metadata) } 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, - } - } - } + yield* super.createMessage(systemPrompt, messages, metadata) } } - async completePrompt(prompt: string): Promise { - const model = await this.fetchModel() - const { id: modelId, info } = model + private async *createR1Message( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + model: { id: string; info: ModelInfo }, + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const languageModel = this.getLanguageModel() + + const modifiedMessages = [...messages] as Anthropic.Messages.MessageParam[] + + if (modifiedMessages.length > 0 && modifiedMessages[0].role === "user") { + const first = modifiedMessages[0] + if (typeof first.content === "string") { + modifiedMessages[0] = { role: "user", content: `${systemPrompt}\n\n${first.content}` } + } else { + modifiedMessages[0] = { + role: "user", + content: [{ type: "text", text: systemPrompt }, ...first.content], + } + } + } else { + modifiedMessages.unshift({ role: "user", content: systemPrompt }) + } + + const aiSdkMessages = convertToAiSdkMessages(modifiedMessages) + + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + const maxOutputTokens = + getModelMaxOutputTokens({ + modelId: model.id, + model: model.info, + settings: this.options, + format: "openai", + }) ?? undefined + + const temperature = this.supportsTemperature(model.id) + ? (this.options.modelTemperature ?? model.info.defaultTemperature) + : undefined + + const result = streamText({ + model: languageModel, + messages: aiSdkMessages, + temperature, + maxOutputTokens, + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + }) + + const matcher = new TagMatcher( + "think", + (chunk) => + ({ + type: chunk.matched ? "reasoning" : "text", + text: chunk.data, + }) as const, + ) 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, + for await (const part of result.fullStream) { + if (part.type === "text-delta") { + for (const processedChunk of matcher.update(part.text)) { + yield processedChunk + } + } else { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } } - // 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 + for (const processedChunk of matcher.final()) { + yield processedChunk } - const response = await this.client.chat.completions.create(requestParams) - return response.choices[0]?.message.content || "" + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage) + } + } catch (error) { + throw handleAiSdkError(error, "chutes") + } + } + + override async completePrompt(prompt: string): Promise { + const model = await this.fetchModel() + const languageModel = this.getLanguageModel() + + const maxOutputTokens = + getModelMaxOutputTokens({ + modelId: model.id, + model: model.info, + settings: this.options, + format: "openai", + }) ?? undefined + + const isDeepSeekR1 = model.id.includes("DeepSeek-R1") + const defaultTemperature = isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0.5 + const temperature = this.supportsTemperature(model.id) + ? (this.options.modelTemperature ?? defaultTemperature) + : undefined + + try { + const { text } = await generateText({ + model: languageModel, + prompt, + maxOutputTokens, + temperature, + }) + return text } catch (error) { if (error instanceof Error) { throw new Error(`Chutes completion error: ${error.message}`) @@ -193,17 +239,4 @@ export class ChutesHandler extends RouterProvider implements SingleCompletionHan 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 index e5b10e4e44..3dc2068372 100644 --- a/src/api/providers/deepinfra.ts +++ b/src/api/providers/deepinfra.ts @@ -47,6 +47,7 @@ export class DeepInfraHandler extends RouterProvider implements SingleCompletion modelId: id, model: info, settings: this.options, + defaultTemperature: 0, }) return { id, info, ...params } 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 index a1337ed558..6490e42208 100644 --- a/src/api/providers/doubao.ts +++ b/src/api/providers/doubao.ts @@ -64,7 +64,13 @@ export class DoubaoHandler extends OpenAiHandler { 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 }) + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) return { id, info, ...params } } 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 index 6a94fce983..a3aca6538f 100644 --- a/src/api/providers/featherless.ts +++ b/src/api/providers/featherless.ts @@ -1,55 +1,88 @@ -import { - DEEP_SEEK_DEFAULT_TEMPERATURE, - type FeatherlessModelId, - featherlessDefaultModelId, - featherlessModels, -} from "@roo-code/types" import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import { streamText } from "ai" + +import { DEEP_SEEK_DEFAULT_TEMPERATURE, featherlessDefaultModelId, featherlessModels } from "@roo-code/types" 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 { convertToAiSdkMessages, handleAiSdkError } from "../transform/ai-sdk" import { ApiStream } from "../transform/stream" +import { getModelParams } from "../transform/model-params" import type { ApiHandlerCreateMessageMetadata } from "../index" -import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" +import { OpenAICompatibleHandler, OpenAICompatibleConfig } from "./openai-compatible" -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, - }) +/** + * Merge consecutive Anthropic messages that share the same role. + * DeepSeek R1 does not support successive messages with the same role, + * so this is needed when the system prompt is injected as a user message + * before the existing conversation (which may also start with a user message). + */ +function mergeConsecutiveSameRoleMessages( + messages: Anthropic.Messages.MessageParam[], +): Anthropic.Messages.MessageParam[] { + if (messages.length <= 1) { + return messages } - private getCompletionParams( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - ): OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming { - const { - id: model, - info: { maxTokens: max_tokens }, - } = this.getModel() + const merged: Anthropic.Messages.MessageParam[] = [] - const temperature = this.options.modelTemperature ?? this.getModel().info.temperature + for (const msg of messages) { + const prev = merged[merged.length - 1] - return { - model, - max_tokens, - temperature, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], - stream: true, - stream_options: { include_usage: true }, + if (prev && prev.role === msg.role) { + const prevBlocks: Anthropic.Messages.ContentBlockParam[] = + typeof prev.content === "string" ? [{ type: "text", text: prev.content }] : prev.content + const currBlocks: Anthropic.Messages.ContentBlockParam[] = + typeof msg.content === "string" ? [{ type: "text", text: msg.content }] : msg.content + + merged[merged.length - 1] = { + role: prev.role, + content: [...prevBlocks, ...currBlocks], + } + } else { + merged.push(msg) } } + return merged +} + +export class FeatherlessHandler extends OpenAICompatibleHandler { + constructor(options: ApiHandlerOptions) { + const modelId = options.apiModelId ?? featherlessDefaultModelId + const modelInfo = + featherlessModels[modelId as keyof typeof featherlessModels] || featherlessModels[featherlessDefaultModelId] + + const config: OpenAICompatibleConfig = { + providerName: "Featherless", + baseURL: "https://api.featherless.ai/v1", + apiKey: options.featherlessApiKey ?? "not-provided", + modelId, + modelInfo, + modelMaxTokens: options.modelMaxTokens ?? undefined, + temperature: options.modelTemperature ?? undefined, + } + + super(options, config) + } + + override getModel() { + const id = this.options.apiModelId ?? featherlessDefaultModelId + const info = + featherlessModels[id as keyof typeof featherlessModels] || featherlessModels[featherlessDefaultModelId] + const isDeepSeekR1 = id.includes("DeepSeek-R1") + const defaultTemp = isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0.5 + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: defaultTemp, + }) + return { id, info, ...params } + } + override async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], @@ -58,9 +91,17 @@ export class FeatherlessHandler extends BaseOpenAiCompatibleProvider tags. + // mergeConsecutiveSameRoleMessages ensures no two successive messages share the + // same role (e.g. the injected system-as-user + original first user message). + const r1Messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: systemPrompt }, ...messages] + const aiSdkMessages = convertToAiSdkMessages(mergeConsecutiveSameRoleMessages(r1Messages)) + + const result = streamText({ + model: this.getLanguageModel(), + messages: aiSdkMessages, + temperature: model.temperature ?? 0, + maxOutputTokens: this.getMaxOutputTokens(), }) const matcher = new TagMatcher( @@ -72,42 +113,28 @@ export class FeatherlessHandler extends BaseOpenAiCompatibleProvider(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,106 @@ 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 - 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)) { + 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) - - // 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 + 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, model, "createMessage") + const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage") TelemetryService.instance.captureException(apiError) if (error instanceof Error) { @@ -366,7 +204,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 +267,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 +283,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 +321,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 +332,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 +395,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/groq.ts b/src/api/providers/groq.ts index 648679f92c..12bd6b4023 100644 --- a/src/api/providers/groq.ts +++ b/src/api/providers/groq.ts @@ -174,4 +174,8 @@ export class GroqHandler extends BaseProvider implements SingleCompletionHandler return text } + + override isAiSdkProvider(): boolean { + return true + } } diff --git a/src/api/providers/huggingface.ts b/src/api/providers/huggingface.ts index 21e429aaab..79daa95c77 100644 --- a/src/api/providers/huggingface.ts +++ b/src/api/providers/huggingface.ts @@ -1,22 +1,37 @@ -import OpenAI from "openai" import { Anthropic } from "@anthropic-ai/sdk" +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" +import { streamText, generateText, ToolSet } from "ai" -import type { ModelRecord } from "@roo-code/types" +import type { ModelRecord, ModelInfo } 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 { + 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 { getHuggingFaceModels, getCachedHuggingFaceModels } from "./fetchers/huggingface" -import { handleOpenAIError } from "./utils/openai-error-handler" +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +const HUGGINGFACE_DEFAULT_TEMPERATURE = 0.7 + +/** + * HuggingFace provider using @ai-sdk/openai-compatible for OpenAI-compatible API. + * Uses HuggingFace's OpenAI-compatible endpoint to enable tool message support. + * @see https://github.com/vercel/ai/issues/10766 - Workaround for tool messages not supported in @ai-sdk/huggingface + */ export class HuggingFaceHandler extends BaseProvider implements SingleCompletionHandler { - private client: OpenAI - private options: ApiHandlerOptions + protected options: ApiHandlerOptions + protected provider: ReturnType private modelCache: ModelRecord | null = null - private readonly providerName = "HuggingFace" constructor(options: ApiHandlerOptions) { super() @@ -26,10 +41,14 @@ export class HuggingFaceHandler extends BaseProvider implements SingleCompletion throw new Error("Hugging Face API key is required") } - this.client = new OpenAI({ + // Create an OpenAI-compatible provider pointing to HuggingFace's /v1 endpoint + // This fixes "tool messages not supported" error - the HuggingFace SDK doesn't + // properly handle function_call_output format, but OpenAI SDK does + this.provider = createOpenAICompatible({ + name: "huggingface", baseURL: "https://router.huggingface.co/v1", apiKey: this.options.huggingFaceApiKey, - defaultHeaders: DEFAULT_HEADERS, + headers: DEFAULT_HEADERS, }) // Try to get cached models first @@ -47,91 +66,150 @@ export class HuggingFaceHandler extends BaseProvider implements SingleCompletion } } + override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { + const id = this.options.huggingFaceModelId || "meta-llama/Llama-3.3-70B-Instruct" + + // Try to get model info from cache + const cachedInfo = this.modelCache?.[id] + + const info: ModelInfo = cachedInfo || { + maxTokens: 8192, + contextWindow: 131072, + supportsImages: false, + supportsPromptCache: false, + } + + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: HUGGINGFACE_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?: { + huggingface?: { + promptCacheHitTokens?: number + promptCacheMissTokens?: number + } + }, + ): ApiStreamUsageChunk { + // Extract cache metrics from HuggingFace's providerMetadata if available + const cacheReadTokens = providerMetadata?.huggingface?.promptCacheHitTokens ?? usage.details?.cachedInputTokens + const cacheWriteTokens = providerMetadata?.huggingface?.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 modelId = this.options.huggingFaceModelId || "meta-llama/Llama-3.3-70B-Instruct" - const temperature = this.options.modelTemperature ?? 0.7 + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() - const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { - model: modelId, - temperature, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], - stream: true, - stream_options: { include_usage: 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 ?? HUGGINGFACE_DEFAULT_TEMPERATURE, + maxOutputTokens: this.getMaxOutputTokens(), + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), } - // Add max_tokens if specified - if (this.options.includeMaxTokens && this.options.modelMaxTokens) { - params.max_tokens = this.options.modelMaxTokens - } + // Use streamText for streaming responses + const result = streamText(requestOptions) - let stream try { - stream = await this.client.chat.completions.create(params) + // Process the full stream to get all events + for await (const part of result.fullStream) { + // Use the processAiSdkStreamPart utility to convert stream parts + 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) { - 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, - } - } + // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) + throw handleAiSdkError(error, "HuggingFace") } } + /** + * Complete a prompt using the AI SDK generateText. + */ async completePrompt(prompt: string): Promise { - const modelId = this.options.huggingFaceModelId || "meta-llama/Llama-3.3-70B-Instruct" + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() - try { - const response = await this.client.chat.completions.create({ - model: modelId, - messages: [{ role: "user", content: prompt }], - }) + const { text } = await generateText({ + model: languageModel, + prompt, + maxOutputTokens: this.getMaxOutputTokens(), + temperature: this.options.modelTemperature ?? temperature ?? HUGGINGFACE_DEFAULT_TEMPERATURE, + }) - return response.choices[0]?.message.content || "" - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } + return text } - 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, - }, - } + override isAiSdkProvider(): boolean { + return true } } diff --git a/src/api/providers/io-intelligence.ts b/src/api/providers/io-intelligence.ts index ef1c60a6a2..11b8afe5c4 100644 --- a/src/api/providers/io-intelligence.ts +++ b/src/api/providers/io-intelligence.ts @@ -1,44 +1,62 @@ -import { ioIntelligenceDefaultModelId, ioIntelligenceModels, type IOIntelligenceModelId } from "@roo-code/types" +import { + ioIntelligenceDefaultModelId, + ioIntelligenceModels, + type IOIntelligenceModelId, + type ModelInfo, +} from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" -import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" -export class IOIntelligenceHandler extends BaseOpenAiCompatibleProvider { +import { getModelParams } from "../transform/model-params" + +import { OpenAICompatibleHandler, type OpenAICompatibleConfig } from "./openai-compatible" + +export class IOIntelligenceHandler extends OpenAICompatibleHandler { 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: { + const modelId = options.ioIntelligenceModelId ?? ioIntelligenceDefaultModelId + const modelInfo: ModelInfo = ioIntelligenceModels[modelId as IOIntelligenceModelId] ?? + ioIntelligenceModels[ioIntelligenceDefaultModelId] ?? { maxTokens: 8192, contextWindow: 128000, supportsImages: false, supportsPromptCache: false, - }, + } + + const config: OpenAICompatibleConfig = { + providerName: "IO Intelligence", + baseURL: "https://api.intelligence.io.solutions/api/v1", + apiKey: options.ioIntelligenceApiKey, + modelId, + modelInfo, + modelMaxTokens: options.modelMaxTokens ?? undefined, + temperature: options.modelTemperature ?? 0.7, } + + super(options, config) + } + + override getModel() { + const modelId = this.options.ioIntelligenceModelId ?? ioIntelligenceDefaultModelId + const modelInfo: ModelInfo = ioIntelligenceModels[modelId as IOIntelligenceModelId] ?? + ioIntelligenceModels[ioIntelligenceDefaultModelId] ?? { + maxTokens: 8192, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + } + + const params = getModelParams({ + format: "openai", + modelId, + model: modelInfo, + settings: this.options, + defaultTemperature: 0.7, + }) + + return { id: modelId, info: modelInfo, ...params } } } diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index 7ce2fc4586..be6665e324 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -55,7 +55,13 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand 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 }) + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) return { id, info, ...params } } @@ -198,4 +204,8 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand return text } + + 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..d7c60c5daf 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -87,7 +87,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // Include originator, session_id, and User-Agent headers for API tracking and debugging 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, + baseURL: this.options.openAiNativeBaseUrl || undefined, apiKey, defaultHeaders: { originator: "roo-code", 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 index 76dd60d976..ba144f6e1b 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -70,6 +70,7 @@ export class UnboundHandler extends RouterProvider implements SingleCompletionHa modelId: id, model: info, settings: this.options, + defaultTemperature: 0, }) return { id, info, ...params } 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..ea4b9a4235 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", () => { @@ -644,4 +838,226 @@ describe("AI SDK conversion utilities", () => { 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/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/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 72c34f94a0..e7b0067dd9 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/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 107cfdf9e9..ef6e956dff 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1021,6 +1021,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") { @@ -1071,6 +1072,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 = { @@ -1193,10 +1203,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. @@ -1226,7 +1236,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 @@ -1243,25 +1253,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 { @@ -1323,10 +1366,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, }) @@ -1356,8 +1399,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 } } @@ -1777,6 +1822,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, browserToolEnabled: state?.browserToolEnabled ?? true, + disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: false, }) @@ -3878,6 +3924,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, browserToolEnabled: state?.browserToolEnabled ?? true, + disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: false, }) @@ -4092,6 +4139,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, browserToolEnabled: state?.browserToolEnabled ?? true, + disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: false, }) @@ -4256,6 +4304,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, browserToolEnabled: state?.browserToolEnabled ?? true, + disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: supportsAllowedFunctionNames, }) @@ -4564,14 +4613,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__/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/__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..fc15a8dd5c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -158,7 +158,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 @@ -899,7 +899,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() @@ -1316,6 +1317,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 +1673,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<{ @@ -2037,6 +2054,7 @@ export class ClineProvider maxOpenTabsContext, maxWorkspaceFiles, browserToolEnabled, + disabledTools, telemetrySetting, showRooIgnoredFiles, enableSubfolderRules, @@ -2071,6 +2089,7 @@ export class ClineProvider openRouterImageGenerationSelectedModel, featureRoomoteControlEnabled, isBrowserSessionActive, + lockApiConfigAcrossModes, } = await this.getState() let cloudOrganizations: CloudOrganizationMembership[] = [] @@ -2174,6 +2193,7 @@ export class ClineProvider maxWorkspaceFiles: maxWorkspaceFiles ?? 200, cwd, browserToolEnabled: browserToolEnabled ?? true, + disabledTools, telemetrySetting, telemetryKey, machineId, @@ -2218,6 +2238,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, @@ -2416,6 +2437,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, @@ -2452,6 +2474,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, @@ -3179,7 +3202,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): ${ 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..4bad630ed5 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -405,6 +405,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" }, @@ -2147,6 +2152,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 +2287,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 +2357,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 +2524,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" }, @@ -2857,6 +2882,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 +3800,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..e0f1d2dc29 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" }, 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/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..ae0da75841 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 + } } } @@ -894,7 +901,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: { @@ -1654,6 +1661,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 +3007,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 be78a09cb9..a2b389abdc 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -20,10 +20,13 @@ 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 @@ -64,7 +67,15 @@ 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, command) => { + 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( @@ -88,13 +99,56 @@ export class API extends EventEmitter implements RooCodeAPI { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) 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 + // 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 -> ${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 } }) 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/package.json b/src/package.json index 98bd1d1b3e..70cc99ba73 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,12 +450,16 @@ "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", - "@ai-sdk/mistral": "^3.0.0", - "@anthropic-ai/bedrock-sdk": "^0.10.2", + "@ai-sdk/amazon-bedrock": "^4.0.51", + "@ai-sdk/baseten": "^1.0.31", + "@ai-sdk/cerebras": "^2.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/groq": "^3.0.22", + "@ai-sdk/mistral": "^3.0.19", + "@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", @@ -514,6 +518,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", @@ -536,11 +541,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:^", @@ -565,7 +571,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/__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/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/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/SlashCommandItem.tsx b/webview-ui/src/components/chat/SlashCommandItem.tsx deleted file mode 100644 index 04ade08bbd..0000000000 --- a/webview-ui/src/components/chat/SlashCommandItem.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from "react" -import { Edit, Trash2 } from "lucide-react" - -import type { Command } from "@roo-code/types" - -import { useAppTranslation } from "@/i18n/TranslationContext" -import { Button, StandardTooltip } from "@/components/ui" -import { vscode } from "@/utils/vscode" - -interface SlashCommandItemProps { - command: Command - onDelete: (command: Command) => 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/__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__/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/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 7134985e9c..de49536304 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -602,19 +602,11 @@ const ApiOptions = ({ )} {selectedProvider === "vertex" && ( - + )} {selectedProvider === "gemini" && ( - + )} {selectedProvider === "openai" && ( 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 */} -
- -