Merge remote-tracking branch 'origin/main' into bugfix/settingssave

This commit is contained in:
Roo Code 2026-02-08 03:53:35 +00:00
commit 27d3fc9613
246 changed files with 15674 additions and 13413 deletions

2
.github/CODEOWNERS vendored
View file

@ -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

390
.github/workflows/cli-release.yml vendored Normal file
View file

@ -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<<EOF" >> $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<<EOF" >> $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

1
.gitignore vendored
View file

@ -18,6 +18,7 @@ bin/
# Local prompts and rules
/local-prompts
AGENTS.local.md
# Test environment
.test_env

View file

@ -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

View file

@ -1,163 +1,113 @@
<extraction_workflow>
<mode_overview>
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
<overview>
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.
</overview>
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 tradeoffs
- Provide troubleshooting playbooks (symptoms → causes → fixes → prevention)
- Recommend targeted visuals for complex states (not stepbystep screenshots)
This mode does not generate final user documentation; it produces verification and source-material reports for docs teams.
</mode_overview>
<initialization_phase>
<process>
<step number="1">
<title>Parse Request</title>
<title>Identify Target</title>
<actions>
<action>Identify the feature/aspect in the user's request.</action>
<action>Decide path: verification vs. source-material generation.</action>
<action>For source-material: capture audience (user or developer) and depth (overview vs task-focused).</action>
<action>For verification: identify the documentation to be verified (provided text/links/files).</action>
<action>Note any specific areas to emphasize or check.</action>
<action>Parse the user's request to identify the feature/aspect</action>
<action>Clarify scope if ambiguous (ask one question max)</action>
</actions>
</step>
<step number="2">
<title>Discover Feature</title>
<title>Discover Code</title>
<actions>
<action>Locate relevant code and assets using appropriate discovery methods.</action>
<action>Identify entry points and key components that affect user experience.</action>
<action>Map the high-level workflow a user follows.</action>
<action>Use codebase_search to find relevant files</action>
<action>Identify entry points, components, and related code</action>
<action>Map the boundaries of the feature</action>
</actions>
</step>
</initialization_phase>
<analysis_focus>
<area>UI components and their interactions</area>
<area>User workflows and decision points</area>
<area>Configuration that changes user-visible behavior</area>
<area>Error states, messages, and recovery</area>
<area>Benefits, limits, prerequisites, and version notes</area>
<area>Why this exists: user goals, constraints, and design intent</area>
<area>“Cannot do” boundaries: permissions, invariants, and business rules</area>
<area>Troubleshooting: symptoms, likely causes, diagnostics, fixes, prevention</area>
<area>Common pitfalls and antipatterns (what to avoid and why)</area>
<area>Decision rationale and tradeoffs that affect user choices</area>
<area>Complex UI states that merit visuals (criteria for screenshots/diagrams)</area>
</analysis_focus>
<step number="3">
<title>Extract Facts</title>
<actions>
<action>Read code and extract facts into categories (see fact_categories)</action>
<action>Record file paths as sources for each fact</action>
<action>Do NOT interpret, summarize, or explain - just extract</action>
</actions>
</step>
<workflow_paths>
<path name="source_material">
<title>Generate Source Material for User-Facing Docs</title>
<description>Extract concise, user-oriented facts and structure them for documentation teams.</description>
<steps>
<step number="1">
<title>Scope and Audience</title>
<actions>
<action>Confirm the feature/aspect and intended audience.</action>
<action>List primary tasks the audience performs with this feature.</action>
</actions>
</step>
<step number="2">
<title>Extract User-Facing Facts</title>
<actions>
<action>Summarize what the feature does and key benefits.</action>
<action>Explain why users need this (jobs-to-be-done, outcomes) and when to use it.</action>
<action>Document step-by-step user workflows and UI interactions.</action>
<action>Capture configuration options that impact user behavior (name, default, effect).</action>
<action>Clarify constraints, limits, and “cannot do” cases with rationale.</action>
<action>Identify common pitfalls and anti-patterns; include “Do/Dont” guidance.</action>
<action>List common errors with user-facing messages, diagnostics, fixes, and prevention.</action>
<action>Record prerequisites, permissions, and compatibility/version notes.</action>
<action>Flag complex states that warrant visuals (what to show and why), not every step.</action>
</actions>
</step>
<step number="3">
<title>Create Source Material Report</title>
<actions>
<action>Organize findings using user-focused structure (benefits, use cases, how it works, configuration, FAQ, troubleshooting).</action>
<action>Include short code/UI snippets or paths where relevant.</action>
<action>Create `EXTRACTION-[feature].md` with findings.</action>
<action>Highlight items that need visuals (screenshots/diagrams).</action>
</actions>
<output_format>
- 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/Dont and antipatterns
- Recommended visuals (what complex states to illustrate and why)
- FAQ and tips
- Version/compatibility notes
</output_format>
</step>
</steps>
</path>
<step number="4">
<title>Output Structured Data</title>
<actions>
<action>Write extraction to .roo/extraction/EXTRACT-[feature].yaml</action>
<action>Use the output schema (see output_format.xml)</action>
</actions>
</step>
</process>
<path name="verification">
<title>Verify Documentation Accuracy</title>
<description>Check provided documentation against codebase reality and actual UX.</description>
<steps>
<step number="1">
<title>Analyze Provided Documentation</title>
<actions>
<action>Parse the documentation to identify claims and descriptions.</action>
<action>Extract technical or user-facing specifics mentioned.</action>
<action>Note workflows, configuration, and examples described.</action>
</actions>
</step>
<step number="2">
<title>Verify Against Codebase</title>
<actions>
<action>Check claims against actual implementation and UX.</action>
<action>Verify endpoints/parameters if referenced.</action>
<action>Confirm configuration options and defaults.</action>
<action>Validate code snippets and examples.</action>
<action>Ensure described workflows match implementation.</action>
</actions>
</step>
<step number="3">
<title>Create Verification Report</title>
<actions>
<action>Categorize findings by severity (Critical, Major, Minor).</action>
<action>List inaccuracies with the correct information.</action>
<action>Identify missing important information.</action>
<action>Provide specific corrections and suggestions.</action>
<action>Create `VERIFICATION-[feature].md` with findings.</action>
</actions>
<output_format>
- 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
</output_format>
</step>
</steps>
</path>
</workflow_paths>
<fact_categories>
<category name="identity">
<extracts>
<extract>Feature name as it appears in code</extract>
<extract>File paths where feature is implemented</extract>
<extract>Entry points (commands, UI elements, API endpoints)</extract>
</extracts>
</category>
<completion_criteria>
<for_source_material>
<criterion>Audience and scope captured</criterion>
<criterion>User workflows and UI interactions documented</criterion>
<criterion>User-impacting configuration recorded</criterion>
<criterion>Common errors and troubleshooting documented</criterion>
<criterion>Report organized for documentation team use</criterion>
</for_source_material>
<for_verification>
<criterion>All documentation claims verified</criterion>
<criterion>Inaccuracies identified and corrected</criterion>
<criterion>Missing information noted</criterion>
<criterion>Suggestions for improvement provided</criterion>
<criterion>Clear verification report created</criterion>
</for_verification>
</completion_criteria>
<category name="behavior">
<extracts>
<extract>What the feature does (from code logic)</extract>
<extract>Inputs it accepts</extract>
<extract>Outputs it produces</extract>
<extract>Side effects (files created, state changed, etc.)</extract>
</extracts>
</category>
<category name="configuration">
<extracts>
<extract>Settings/options that affect behavior</extract>
<extract>Default values</extract>
<extract>Valid ranges or allowed values</extract>
<extract>Where configured (settings file, env var, UI)</extract>
</extracts>
</category>
<category name="constraints">
<extracts>
<extract>Prerequisites and dependencies</extract>
<extract>Limitations (what it cannot do)</extract>
<extract>Permissions required</extract>
<extract>Compatibility requirements</extract>
</extracts>
</category>
<category name="errors">
<extracts>
<extract>Error conditions in code</extract>
<extract>Error messages (exact text)</extract>
<extract>Recovery paths in code</extract>
</extracts>
</category>
<category name="ui">
<extracts>
<extract>UI components involved</extract>
<extract>User-visible labels and text</extract>
<extract>Interaction patterns</extract>
</extracts>
</category>
<category name="integration">
<extracts>
<extract>Other features this interacts with</extract>
<extract>External APIs or services called</extract>
<extract>Events emitted or consumed</extract>
</extracts>
</category>
</fact_categories>
<rules>
<rule>Extract facts, not opinions</rule>
<rule>Include source file paths for every fact</rule>
<rule>Use code identifiers and exact strings from source</rule>
<rule>Do NOT paraphrase - quote when possible</rule>
<rule>Do NOT decide what's important - extract everything relevant</rule>
<rule>Do NOT format for end users - output is for docs team</rule>
</rules>
</extraction_workflow>

View file

@ -1,357 +0,0 @@
<documentation_patterns>
<overview>
Standard templates for structuring extracted documentation.
</overview>
<output_structure>
<user_focused_template>
# [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.]
</user_focused_template>
<comprehensive_template>
# [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.]
</comprehensive_template>
</output_structure>
<documentation_patterns>
<before_after>
<template>
**Before**: Multiple, sequential file read requests:
- "Read `src/app.js`?" → Approve
- "Read `src/utils.js`?" → Approve
- "Read `src/config.json`?" → Approve
**Now**: One request to read all related files.
</template>
</before_after>
<visual_separator>
<format>---</format>
<purpose>Separate sections.</purpose>
</visual_separator>
<faq>
<template>
## FAQ
**"Why disable this?"**
- Your AI model handles single files better.
- You need more control over file access.
- You are working with very large files.
**"What if some files are blocked?"**
- Roo reads approved files and works with what it has.
- `.rooignore` files are excluded automatically.
- Individual files can still be denied in the batch dialog.
</template>
</faq>
<examples>
<guideline>Show tool output or UI elements.</guideline>
<guideline>Use actual file paths and setting names.</guideline>
<guideline>Include common errors and solutions.</guideline>
</examples>
<troubleshooting>
<template>
## Troubleshooting
**"Too many files requested"**
- Lower the concurrent file limit in settings.
- Deny individual files in the batch dialog.
**"Feature isn't working"**
- Ensure "Enable concurrent file reads" is on in settings.
- Verify the file limit is set correctly (default: 100).
- Some AI models may not support this feature.
</template>
</troubleshooting>
<help>
<template>
## Help
- See the [FAQ](#faq) for common issues.
- Report problems on [GitHub Issues](https://github.com/RooCodeInc/Roo-Code/issues).
- Include reproduction steps and error messages.
</template>
</help>
</documentation_patterns>
<audience_sections>
<audience type="user">
<focus>
<area>Tutorials</area>
<area>Use cases</area>
<area>Troubleshooting</area>
<area>Benefits</area>
</focus>
<style>
<guideline>Simple language</guideline>
<guideline>Visual aids</guideline>
<guideline>Focus on outcomes</guideline>
<guideline>Clear action steps</guideline>
</style>
</audience>
<audience type="developer">
<focus>
<area>Code examples</area>
<area>API specs</area>
<area>Integration patterns</area>
<area>Performance</area>
</focus>
<style>
<guideline>Precise terminology</guideline>
<guideline>Code samples</guideline>
<guideline>Document edge cases</guideline>
<guideline>Debugging guidance</guideline>
</style>
</audience>
</audience_sections>
<metadata_patterns>
<version_info>
<template>
### Version Compatibility
| Component | Min | Recommended | Max | Notes |
|-----------|-----|-------------|-----|-------|
| [Component] | [version] | [version] | [version] | [notes] |
</template>
</version_info>
<deprecation_notice>
<template>
> ⚠️ **Deprecated**
>
> Deprecated since: [vX.Y.Z] on [date]
> Removal target: [vA.B.C]
> Migration: See [migration guide](#migration).
> Replacement: [new feature/method].
</template>
</deprecation_notice>
<security_warning>
<template>
> 🔒 **Security Warning**
>
> [Description of concern]
> - **Risk**: [High/Medium/Low]
> - **Affected**: [versions]
> - **Mitigation**: [steps]
> - **References**: [links]
</template>
</security_warning>
<performance_note>
<template>
> ⚡ **Performance Note**
>
> [Description of performance consideration]
> - **Impact**: [metrics]
> - **Optimization**: [approach]
> - **Trade-offs**: [considerations]
</template>
</performance_note>
</metadata_patterns>
<code_documentation_patterns>
<api_endpoint>
<template>
### `[METHOD] /api/[path]`
**Description**: [What this endpoint does]
**Authentication**: [Required/Optional] - [Type]
**Parameters**:
| Name | Type | Required | Description | Example |
|------|------|----------|-------------|---------|
| [param] | [type] | [Yes/No] | [description] | [example] |
**Request Body**:
```json
{
"field": "value"
}
```
**Response**:
- **Success (200)**:
```json
{
"status": "success",
"data": {}
}
```
- **Error (4xx/5xx)**:
```json
{
"error": "error_code",
"message": "Human readable message"
}
```
**Example**:
```bash
curl -X [METHOD] https://api.example.com/[path] \
-H "Authorization: Bearer [token]" \
-H "Content-Type: application/json" \
-d '{"field": "value"}'
```
</template>
</api_endpoint>
<function_documentation>
<template>
### `functionName(parameters)`
**Purpose**: [What this function does]
**Parameters**:
- `param1` (Type): [Description]
- `param2` (Type, optional): [Description] - Default: [value]
**Returns**: `Type` - [Description of return value]
**Throws**:
- `ErrorType`: [When this error occurs]
**Example**:
```typescript
const result = functionName(value1, value2);
// Expected output: [description]
```
**Notes**:
- [Important consideration 1]
- [Important consideration 2]
</template>
</function_documentation>
<configuration_option>
<template>
### `CONFIG_NAME`
**Type**: `string | number | boolean`
**Default**: `default_value`
**Environment Variable**: `APP_CONFIG_NAME`
**Description**: [What this configuration controls]
**Valid Values**:
- `value1`: [Description]
- `value2`: [Description]
**Example**:
```yaml
config:
name: value
```
**Impact**: [What changes when this is modified]
</template>
</configuration_option>
</code_documentation_patterns>
<cross_reference_patterns>
<internal_link>
<format>[Link Text](#section-anchor)</format>
<example>[See Configuration Guide](#configuration)</example>
</internal_link>
<external_link>
<format>[Link Text](https://external.url)</format>
<example>[Official Documentation](https://docs.example.com)</example>
</external_link>
<related_feature>
<template>
> 📌 **Related Features**
> - [Feature A](../feature-a/README.md): [How it relates]
> - [Feature B](../feature-b/README.md): [How it relates]
</template>
</related_feature>
<see_also>
<template>
> 👉 **See Also**
> - [Related Topic 1](#anchor1)
> - [Related Topic 2](#anchor2)
> - [External Resource](https://example.com)
</template>
</see_also>
</cross_reference_patterns>
</documentation_patterns>

View file

@ -0,0 +1,85 @@
<verification_workflow>
<overview>
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.
</overview>
<process>
<step number="1">
<title>Receive Documentation</title>
<actions>
<action>User provides documentation to verify (text, file, or URL)</action>
<action>Identify the feature/aspect being documented</action>
</actions>
</step>
<step number="2">
<title>Extract Claims</title>
<actions>
<action>Parse the documentation into discrete claims</action>
<action>Tag each claim with a category (behavior, config, constraint, etc.)</action>
<action>Record the exact quote from the documentation</action>
</actions>
</step>
<step number="3">
<title>Verify Against Code</title>
<actions>
<action>For each claim, find the relevant code</action>
<action>Compare claim to actual implementation</action>
<action>Record: ACCURATE, INACCURATE, OUTDATED, MISSING_CONTEXT, or UNVERIFIABLE</action>
<action>For inaccuracies, record what the code actually does</action>
</actions>
</step>
<step number="4">
<title>Output Verification Report</title>
<actions>
<action>Write verification to .roo/extraction/VERIFY-[feature].yaml</action>
<action>Use the output schema (see output_format.xml)</action>
</actions>
</step>
</process>
<verification_statuses>
<status name="ACCURATE">
<meaning>Claim matches implementation</meaning>
</status>
<status name="INACCURATE">
<meaning>Claim contradicts implementation</meaning>
<requires>What the code actually does</requires>
</status>
<status name="OUTDATED">
<meaning>Claim was once true but code has changed</meaning>
<requires>Current behavior</requires>
</status>
<status name="MISSING_CONTEXT">
<meaning>Claim is true but omits important information</meaning>
<requires>The missing context</requires>
</status>
<status name="UNVERIFIABLE">
<meaning>Cannot find code to verify this claim</meaning>
<requires>Search paths attempted</requires>
</status>
</verification_statuses>
<claim_categories>
<category>behavior</category>
<category>configuration</category>
<category>constraint</category>
<category>error_handling</category>
<category>ui</category>
<category>integration</category>
<category>prerequisite</category>
</claim_categories>
<rules>
<rule>Verify facts, not writing quality</rule>
<rule>Report what code does, not what docs should say</rule>
<rule>Include source file paths as evidence</rule>
<rule>Do NOT suggest documentation rewrites</rule>
<rule>Do NOT evaluate if docs are "good" - only if they're accurate</rule>
<rule>Quote exact code when showing discrepancies</rule>
</rules>
</verification_workflow>

View file

@ -1,349 +0,0 @@
<analysis_techniques>
<overview>
Heuristics for analyzing a codebase to extract reliable, user-facing documentation.
This file contains technique checklists only—no tool instructions or invocations.
</overview>
<ui_ux_analysis_techniques>
<technique name="component_discovery">
<description>Find and analyze UI components and their interactions</description>
<heuristics>
<rule>Start from feature or route directories and enumerate components related to the requested topic.</rule>
<rule>Differentiate container vs presentational components; note composition patterns.</rule>
<rule>Trace inputs/outputs: props, state, context, events, and side effects.</rule>
<rule>Record conditional rendering that affects user-visible states.</rule>
</heuristics>
<evidence_to_collect>
<item>Primary components and responsibilities.</item>
<item>Props/state/context that change behavior.</item>
<item>High-level dependency/composition map.</item>
</evidence_to_collect>
</technique>
<technique name="style_analysis">
<description>Analyze styling and visual elements</description>
<heuristics>
<rule>Identify design tokens and utility classes used to drive layout and state.</rule>
<rule>Capture responsive behavior and breakpoint rules that materially change UX.</rule>
<rule>Document visual affordances tied to state (loading, error, disabled).</rule>
</heuristics>
<evidence_to_collect>
<item>Key classes/selectors influencing layout/state.</item>
<item>Responsive behavior summary and breakpoints.</item>
</evidence_to_collect>
</technique>
<technique name="user_flow_mapping">
<description>Map user interactions and navigation flows</description>
<analysis_areas>
<area>Route definitions and navigation</area>
<area>Form submissions and validations</area>
<area>Button clicks and event handlers</area>
<area>State changes and UI updates</area>
<area>Loading and error states</area>
</analysis_areas>
<heuristics>
<rule>Outline entry points and expected outcomes for each primary flow.</rule>
<rule>Summarize validation rules and failure states the user can encounter.</rule>
<rule>Record redirects and deep-link behavior relevant to the feature.</rule>
</heuristics>
<evidence_to_collect>
<item>Flow diagrams or bullet sequences for main tasks.</item>
<item>Validation conditions and error messages.</item>
<item>Navigation transitions and guards.</item>
</evidence_to_collect>
</technique>
<technique name="user_feedback_analysis">
<description>Analyze how the system communicates with users</description>
<elements_to_find>
<element>Error messages and alerts</element>
<element>Success notifications</element>
<element>Loading indicators</element>
<element>Tooltips and help text</element>
<element>Confirmation dialogs</element>
<element>Progress indicators</element>
</elements_to_find>
<heuristics>
<rule>Map message triggers to the user actions that cause them.</rule>
<rule>Capture severity, persistence, and dismissal behavior.</rule>
<rule>Note localization or accessibility considerations in messages.</rule>
</heuristics>
<evidence_to_collect>
<item>Catalog of messages with purpose and conditions.</item>
<item>Loading/progress patterns and timeouts.</item>
</evidence_to_collect>
</technique>
<technique name="accessibility_analysis">
<description>Check for accessibility features and compliance</description>
<accessibility_checks>
<check>ARIA labels and roles</check>
<check>Keyboard navigation support</check>
<check>Screen reader compatibility</check>
<check>Focus management</check>
<check>Color contrast considerations</check>
</accessibility_checks>
<heuristics>
<rule>Confirm interactive elements have clear focus and labels.</rule>
<rule>Describe keyboard-only navigation paths for core flows.</rule>
</heuristics>
<evidence_to_collect>
<item>Accessibility gaps affecting task completion.</item>
</evidence_to_collect>
</technique>
<technique name="responsive_design_analysis">
<description>Analyze responsive design and mobile experience</description>
<analysis_points>
<point>Breakpoint definitions</point>
<point>Mobile-specific components</point>
<point>Touch event handlers</point>
<point>Viewport configurations</point>
<point>Media queries</point>
</analysis_points>
<heuristics>
<rule>Summarize layout changes across breakpoints that alter workflow.</rule>
<rule>Note touch targets and gestures required on mobile.</rule>
</heuristics>
<evidence_to_collect>
<item>Table of key differences per breakpoint.</item>
</evidence_to_collect>
</technique>
</ui_ux_analysis_techniques>
<code_analysis_techniques>
<technique name="entry_point_analysis">
<description>Understand feature entry points and control flow</description>
<steps>
<step>Identify main functions, controllers, or route handlers.</step>
<step>Trace execution and decision branches.</step>
<step>Document input validation and preconditions.</step>
</steps>
<evidence_to_collect>
<item>Entry points list and short purpose statements.</item>
<item>Decision matrix or flow sketch.</item>
</evidence_to_collect>
</technique>
<technique name="api_extraction">
<description>Extract API specifications from code</description>
<patterns>
<pattern type="rest">
<extraction>
<item>HTTP method and route path</item>
<item>Path/query parameters</item>
<item>Request/response schemas</item>
<item>Status codes and error bodies</item>
</extraction>
</pattern>
<pattern type="graphql">
<extraction>
<item>Schema and input types</item>
<item>Resolvers and return types</item>
<item>Field arguments and constraints</item>
</extraction>
</pattern>
</patterns>
</technique>
<technique name="dependency_mapping">
<description>Map dependencies and integration points</description>
<analysis_points>
<point>Imports and module boundaries</point>
<point>Package and runtime dependencies</point>
<point>External API/SDK usage</point>
<point>DB connections and migrations</point>
<point>Messaging/queue/event streams</point>
<point>Filesystem or network side effects</point>
</analysis_points>
<evidence_to_collect>
<item>Dependency graph summary and hot spots.</item>
<item>List of external integrations and auth methods.</item>
</evidence_to_collect>
</technique>
<technique name="data_model_extraction">
<description>Extract data models, schemas, and type definitions</description>
<sources>
<source type="typescript">
<patterns>- interfaces, types, classes, enums</patterns>
</source>
<source type="database">
<patterns>- Schema definitions, migration files, ORM models</patterns>
</source>
<source type="validation">
<patterns>- JSON Schema, Joi/Yup/Zod schemas, validation decorators</patterns>
</source>
</sources>
<evidence_to_collect>
<item>Canonical definitions and field constraints.</item>
<item>Entity relationships and ownership.</item>
</evidence_to_collect>
</technique>
<technique name="business_logic_extraction">
<description>Identify and document business rules</description>
<indicators>
<indicator>Complex conditionals</indicator>
<indicator>Calculation functions</indicator>
<indicator>Validation rules</indicator>
<indicator>State machines</indicator>
<indicator>Domain-specific constants and algorithms</indicator>
</indicators>
<documentation_focus>
<focus>Why the logic exists (business need)</focus>
<focus>When the logic applies (conditions)</focus>
<focus>What the logic does (transformation)</focus>
<focus>Edge cases and invariants</focus>
<focus>Impact of changes</focus>
</documentation_focus>
</technique>
<technique name="error_handling_analysis">
<description>Document error handling and recovery</description>
<analysis_areas>
<area>try/catch blocks and error boundaries</area>
<area>Custom error classes and codes</area>
<area>Logging, fallbacks, retries, circuit breakers</area>
</analysis_areas>
<evidence_to_collect>
<item>Error taxonomy and user-facing messages.</item>
<item>Recovery/rollback strategies and timeouts.</item>
</evidence_to_collect>
</technique>
<technique name="security_analysis">
<description>Identify security measures and vulnerabilities</description>
<security_checks>
<check category="authentication">JWT, sessions, OAuth, API keys</check>
<check category="authorization">RBAC, permission checks, ownership validation</check>
<check category="data_protection">Encryption, hashing, sensitive data handling</check>
<check category="input_validation">Sanitization and injection prevention</check>
</security_checks>
<evidence_to_collect>
<item>Threat surfaces and mitigations relevant to the feature.</item>
</evidence_to_collect>
</technique>
<technique name="performance_analysis">
<description>Identify performance factors and optimization opportunities</description>
<analysis_points>
<point>Expensive loops/algorithms</point>
<point>DB query patterns (e.g., N+1)</point>
<point>Caching strategies</point>
<point>Concurrency and async usage</point>
<point>Batching and resource pooling</point>
<point>Memory management and object lifetimes</point>
</analysis_points>
<metrics_to_document>
<metric>Time/space complexity</metric>
<metric>DB query counts</metric>
<metric>API response times</metric>
<metric>Memory usage</metric>
<metric>Concurrency handling</metric>
</metrics_to_document>
</technique>
<technique name="test_coverage_analysis">
<description>Assess test coverage at a useful granularity</description>
<test_types>
<type name="unit">
<analysis>Function-level coverage and edge cases</analysis>
</type>
<type name="integration">
<analysis>Workflow coverage and contract boundaries</analysis>
</type>
<type name="api">
<analysis>Endpoint success/failure paths and schemas</analysis>
</type>
</test_types>
<evidence_to_collect>
<item>List of critical behaviors missing tests.</item>
</evidence_to_collect>
</technique>
<technique name="configuration_extraction">
<description>Extract configuration options and their impacts</description>
<configuration_sources>
<source>.env files, config files, CLI args, feature flags</source>
</configuration_sources>
<documentation_requirements>
<requirement>Default values and valid ranges</requirement>
<requirement>Behavioral impact of each option</requirement>
<requirement>Dependencies between options</requirement>
<requirement>Security implications</requirement>
</documentation_requirements>
</technique>
</code_analysis_techniques>
<workflow_analysis>
<technique name="user_journey_mapping">
<description>Map user workflows through the feature</description>
<steps>
<step>Identify entry points (UI, API, CLI)</step>
<step>Trace user actions and decision points</step>
<step>Map data transformations</step>
<step>Identify outcomes and completion criteria</step>
</steps>
<deliverables>
<deliverable>Flow diagrams, procedures, decision trees, state diagrams</deliverable>
</deliverables>
</technique>
<technique name="integration_flow_analysis">
<description>Document integration with other systems</description>
<integration_types>
<type>Sync API calls, async messaging, events, batch processing, streaming</type>
</integration_types>
<documentation_focus>
<focus>Protocols, auth, error handling, data transforms, SLAs</focus>
</documentation_focus>
</technique>
</workflow_analysis>
<metadata_extraction>
<technique name="version_compatibility">
<description>Summarize version constraints and compatibility</description>
<sources>
<source>package manifests, READMEs, migration guides, breaking changes docs</source>
</sources>
<evidence_to_collect>
<item>Minimum/recommended versions and notable constraints.</item>
</evidence_to_collect>
</technique>
<technique name="deprecation_tracking">
<description>Track deprecations and migrations</description>
<indicators>
<indicator>Explicit deprecation notices and TODO markers</indicator>
<indicator>Legacy code paths and adapters</indicator>
</indicators>
<documentation_requirements>
<requirement>Deprecation date and removal timeline</requirement>
<requirement>Migration path and alternatives</requirement>
</documentation_requirements>
</technique>
</metadata_extraction>
<quality_indicators>
<indicator name="documentation_completeness">
<checks>
<check>Public APIs documented with inputs/outputs and errors</check>
<check>Examples for complex features</check>
<check>Error scenarios covered with recovery guidance</check>
<check>Config options explained with defaults and impacts</check>
<check>Security considerations addressed</check>
</checks>
</indicator>
<indicator name="code_quality_metrics">
<metrics>
<metric>Cyclomatic complexity</metric>
<metric>Code duplication</metric>
<metric>Test coverage and gaps</metric>
<metric>Documentation coverage for user-visible behaviors</metric>
<metric>Known technical debt affecting UX</metric>
</metrics>
</indicator>
</quality_indicators>
</analysis_techniques>

View file

@ -0,0 +1,133 @@
<output_format>
<overview>
Structured data output formats for extraction and verification.
All output is YAML. No prose. No markdown formatting.
This data feeds into documentation-writer mode.
</overview>
<extraction_schema>
<description>Schema for EXTRACT-[feature].yaml files</description>
<template>
feature:
name: [feature name from code]
slug: [lowercase-hyphenated identifier]
extracted_at: [ISO timestamp]
source_files:
- [list of primary files]
identity:
entry_points:
- type: [command|ui|api|event]
name: [identifier]
location: [file:line]
components:
- name: [component name]
file: [path]
purpose: [one line from code comments or inferred]
behavior:
primary_action: [what it does - from code]
inputs:
- name: [input name]
type: [data type]
required: [true|false]
source: [file:line]
outputs:
- name: [output name]
type: [data type]
source: [file:line]
side_effects:
- description: [what changes]
source: [file:line]
configuration:
- name: [setting name]
key: [config key path]
type: [data type]
default: [default value]
valid_values: [list or range]
effect: [what it changes]
source: [file:line]
constraints:
prerequisites:
- description: [requirement]
source: [file:line]
limitations:
- description: [what cannot be done]
source: [file:line]
permissions:
- description: [permission needed]
source: [file:line]
errors:
- condition: [when this error occurs]
message: "[exact error message text]"
code: [error code if any]
source: [file:line]
ui:
components:
- name: [component name]
type: [button|panel|input|etc]
label: "[visible text]"
source: [file:line]
interactions:
- trigger: [user action]
result: [what happens]
source: [file:line]
integration:
internal:
- feature: [other feature name]
relationship: [how they interact]
source: [file:line]
external:
- service: [external service]
api: [endpoint or method]
source: [file:line]
</template>
</extraction_schema>
<verification_schema>
<description>Schema for VERIFY-[feature].yaml files</description>
<template>
verification:
feature: [feature name]
doc_source: [where the docs came from]
verified_at: [ISO timestamp]
summary:
total_claims: [count]
accurate: [count]
inaccurate: [count]
outdated: [count]
missing_context: [count]
unverifiable: [count]
claims:
- id: [claim-1]
quote: "[exact text from documentation]"
category: [behavior|configuration|constraint|error_handling|ui|integration|prerequisite]
status: [ACCURATE|INACCURATE|OUTDATED|MISSING_CONTEXT|UNVERIFIABLE]
evidence:
code_file: [file:line]
actual_behavior: [what code does - only if status is not ACCURATE]
code_quote: "[relevant code snippet]"
</template>
</verification_schema>
<output_rules>
<rule>Use YAML, not JSON or markdown</rule>
<rule>Include source file:line for every fact</rule>
<rule>Quote exact strings from code using double quotes</rule>
<rule>Use null for unknown/missing values, not empty strings</rule>
<rule>Keep descriptions factual and brief - one line max</rule>
<rule>Do NOT add commentary, suggestions, or explanations</rule>
</output_rules>
<file_naming>
<extraction>EXTRACT-[feature-slug].yaml</extraction>
<verification>VERIFY-[feature-slug].yaml</verification>
<location>.roo/extraction/</location>
</file_naming>
</output_format>

View file

@ -1,298 +0,0 @@
<communication_guidelines>
<overview>
Guidelines for user communication and output formatting.
</overview>
<user_interaction>
<initial_contact>
<principle>Act on the user's request immediately.</principle>
<principle>Only ask for clarification if the request is ambiguous.</principle>
</initial_contact>
<clarification>
<when_to_ask>
<scenario>Multiple features with similar names are found.</scenario>
<scenario>The request is ambiguous.</scenario>
<scenario>The user explicitly asks for options.</scenario>
</when_to_ask>
</clarification>
<progress_updates>
<when_to_update>
<trigger>Starting a major analysis phase.</trigger>
<trigger>Extraction is complete.</trigger>
<trigger>Unexpected complexity is found.</trigger>
</when_to_update>
<update_format>
<template>
Analyzing [component]...
- Found [X] related files.
- Identified [Y] API endpoints.
- Found [Z] config options.
</template>
</update_format>
</progress_updates>
<findings_communication>
<important_findings>
<discovery type="security_issue">
Alert user to security concerns found during analysis.
</discovery>
<discovery type="deprecated_code">
Note deprecated features needing migration docs.
</discovery>
<discovery type="missing_docs">
Highlight code that lacks inline documentation.
</discovery>
<discovery type="complex_dependencies">
Warn about complex dependency chains.
</discovery>
</important_findings>
<extraction_findings>
<template>
Feature extraction complete for [feature name].
**Extraction Report**: `EXTRACTION-[feature].md`
**Key Findings**:
- Technical Components: [X] classes, [Y] APIs, [Z] configurations
- User Workflows: [number] primary use cases identified
- Business Logic: [summary of core functionality]
- Integration Points: [list of external dependencies]
**Documentation Considerations**:
- [Important aspect that needs clear explanation]
- [Complex area that may need diagrams]
- [Edge cases that should be documented]
The extraction report provides comprehensive details for your documentation team.
</template>
</extraction_findings>
<verification_findings>
<template>
Documentation verification complete.
**Verification Report**: `VERIFICATION-[feature].md`
**Overall Assessment**: [Accurate/Needs Updates/Contains Critical Errors]
**Summary of Findings**:
- Critical Inaccuracies: [number]
- Technical Corrections Needed: [number]
- Missing Information: [number]
- Clarity Improvements: [number]
**Most Important Issues**:
1. [Critical issue that could mislead users]
2. [Important technical inaccuracy]
3. [Key missing information]
See the full verification report for detailed corrections and suggestions.
</template>
</verification_findings>
</findings_communication>
</user_interaction>
<output_formatting>
<markdown_standards>
<headings>
<rule>Use # for main title, ## for major sections, ### for subsections.</rule>
<rule>Never skip heading levels.</rule>
</headings>
<code_blocks>
<rule>Always specify language for syntax highlighting (e.g., typescript, json, bash).</rule>
<rule>Include file paths as comments where relevant.</rule>
<example>
```typescript
// src/auth/auth.service.ts
export class AuthService {
async validateUser(email: string, password: string): Promise<User> {
// Implementation
}
}
```
</example>
</code_blocks>
<tables>
<rule>Use tables for structured data like configs.</rule>
<rule>Include headers and align columns.</rule>
<rule>Keep cell content brief.</rule>
<example>
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `JWT_SECRET` | string | - | Secret key for JWT signing |
| `JWT_EXPIRATION` | string | '15m' | Token expiration time |
</example>
</tables>
<lists>
<rule>Use bullets for unordered lists, numbers for sequential steps.</rule>
<rule>Keep list items parallel in structure.</rule>
</lists>
</markdown_standards>
<cross_references>
<internal_links>
<format>[Link text](#section-anchor)</format>
<rule>Use lowercase, hyphenated anchors. Test all links.</rule>
</internal_links>
<external_links>
<format>[Link text](https://example.com)</format>
<rule>Use HTTPS. Link to official docs.</rule>
</external_links>
<file_references>
<format>`path/to/file.ts`</format>
<rule>Use relative paths from project root, in backticks.</rule>
</file_references>
</cross_references>
<special_sections>
<alerts>
<type name="warning">
<format>> ⚠️ **Warning**: [message]</format>
<use_for>Security, breaking changes, deprecations.</use_for>
</type>
<type name="note">
<format>> 📝 **Note**: [message]</format>
<use_for>Important info, clarifications.</use_for>
</type>
<type name="tip">
<format>> 💡 **Tip**: [message]</format>
<use_for>Best practices, optimizations.</use_for>
</type>
</alerts>
<metadata_blocks>
<version_info>
---
Feature: Authentication System
Version: 2.1.0
Last Updated: 2024-01-15
Status: Stable
---
</version_info>
</metadata_blocks>
</special_sections>
</output_formatting>
<documentation_tone>
<general>
<principle>Be direct, not conversational.</principle>
<principle>Use active voice.</principle>
<principle>Lead with benefits.</principle>
<principle>Use concrete examples.</principle>
<principle>Keep paragraphs short.</principle>
<principle>Avoid unnecessary technical details.</principle>
</general>
<audience_tone>
<audience type="developer">
<tone>Technical and direct.</tone>
<vocabulary>Standard programming terms.</vocabulary>
<examples>Code snippets, implementation details.</examples>
</audience>
<audience type="user">
<tone>Instructional, step-by-step.</tone>
<vocabulary>Simple language, no jargon.</vocabulary>
<examples>Screenshots, real-world scenarios.</examples>
</audience>
</audience_tone>
</documentation_tone>
<completion_message>
<structure>
<element>Summary of analysis performed.</element>
<element>Key findings or issues identified.</element>
<element>Report file location.</element>
<element>Recommended next steps.</element>
</structure>
<extraction_example>
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.
</extraction_example>
<verification_example>
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.
</verification_example>
</completion_message>
<error_handling>
<scenarios>
<scenario type="feature_not_found">
<response>
Could not find a feature matching "[feature name]". Similar features found:
- [List similar features]
Document one of these instead?
</response>
</scenario>
<scenario type="insufficient_docs">
<response>
Code for [feature] has limited inline documentation. Extracting from code structure, tests, and usage patterns.
</response>
</scenario>
<scenario type="complex_feature">
<response>
This feature is complex. Choose documentation scope:
- Document comprehensively
- Focus on core functionality
- Split into multiple documents
</response>
</scenario>
</scenarios>
</error_handling>
<quality_checks>
<before_completion>
<check>No placeholder content remains.</check>
<check>Code examples are correct.</check>
<check>Links and cross-references work.</check>
<check>Tables are formatted correctly.</check>
<check>Version info is included.</check>
<check>Filename follows conventions.</check>
</before_completion>
</quality_checks>
</communication_guidelines>

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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 <path>` | Workspace path to operate in | Current directory |
| `-e, --extension <path>` | 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 <key>` | API key for the LLM provider | From env var |
| `-p, --provider <provider>` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` |
| `-m, --model <model>` | Model to use | `anthropic/claude-sonnet-4.5` |
| `-M, --mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
| `-r, --reasoning-effort <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 <path>` | Read prompt from a file instead of command line argument | None |
| `-w, --workspace <path>` | Workspace path to operate in | Current directory |
| `-p, --print` | Print response and exit (non-interactive mode) | `false` |
| `-e, --extension <path>` | 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 <key>` | API key for the LLM provider | From env var |
| `--provider <provider>` | API provider (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) |
| `-m, --model <model>` | Model to use | `anthropic/claude-opus-4.6` |
| `--mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
| `-r, --reasoning-effort <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 <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
```

View file

@ -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 ""
}

View file

@ -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": {

343
apps/cli/scripts/build.sh Executable file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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<string, unknown>)
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)

View file

@ -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

View file

@ -18,7 +18,8 @@ program
.option("-p, --print", "Print response and exit (non-interactive mode)", false)
.option("-e, --extension <path>", "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 <key>", "API key for the LLM provider")
.option("--provider <provider>", "API provider (roo, anthropic, openai, openrouter, etc.)")
.option("-m, --model <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(

View file

@ -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")
})

View file

@ -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)
})
})

View file

@ -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
}

View file

@ -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()

View file

@ -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"]

View file

@ -26,6 +26,7 @@ export type FlagOptions = {
debug: boolean
yes: boolean
dangerouslySkipPermissions: boolean
exitOnError: boolean
apiKey?: string
provider?: SupportedProvider
model?: string

View file

@ -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",

View file

@ -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) {

View file

@ -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,

View file

@ -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)

View file

@ -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",

View file

@ -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)
})
})

View file

@ -101,6 +101,7 @@ export const organizationDefaultSettingsSchema = globalSettingsSchema
terminalShellIntegrationDisabled: true,
terminalShellIntegrationTimeout: true,
terminalZshClearEolMark: true,
disabledTools: true,
})
// Add stronger validations for some fields.
.merge(

View file

@ -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<typeof rooCodeEventsSchema>
@ -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),

View file

@ -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<typeof globalSettingsSchema>

View file

@ -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<typeof taskCommandSchema>

View file

@ -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({

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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",

View file

@ -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",

View file

@ -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" },

View file

@ -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"],
},

View file

@ -7,7 +7,17 @@ export interface SkillMetadata {
description: string // Required: when to use this skill
path: string // Absolute path to SKILL.md (or "<built-in:name>" 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[]
}
/**

View file

@ -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

875
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

BIN
releases/3.47.0-release.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View file

@ -117,6 +117,15 @@ export interface ApiHandler {
* @returns A promise resolving to the token count
*/
countTokens(content: Array<Anthropic.Messages.ContentBlockParam>): Promise<number>
/**
* 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 {

View file

@ -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<typeof import("ai")>()
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<any> {
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<any> {
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)
}
})
})
})

View file

@ -22,38 +22,6 @@ vitest.mock("../../../utils/logging", () => ({
},
}))
// Mock AWS SDK
vitest.mock("@aws-sdk/client-bedrock-runtime", () => {
const mockModule = {
lastCommandInput: null as Record<string, any> | 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<ApiHandlerOptions> = {}) => {
@ -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", () => {

View file

@ -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<typeof import("ai")>()
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: <msg>".
//
// 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")
}
})
})
})
})

View file

@ -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<ApiHandlerOptions> = {}) => {

View file

@ -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<typeof import("ai")>()
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)
})
})

File diff suppressed because it is too large Load diff

View file

@ -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<typeof import("ai")>()
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<typeof vi.fn>
beforeEach(() => {
capturedPayload = null
mockSend = vi.fn()
// Mock ConverseStreamCommand to capture the payload
;(ConverseStreamCommand as unknown as ReturnType<typeof vi.fn>).mockImplementation((payload) => {
capturedPayload = payload
return {
input: payload,
}
})
;(BedrockRuntimeClient as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
send: mockSend,
config: { region: "us-east-1" },
}))
;(logger.info as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => {})
;(logger.error as unknown as ReturnType<typeof vi.fn>).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")
})
})
})

View file

@ -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<typeof import("ai")>()
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)
})
})
})

View file

@ -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<typeof import("ai")>()
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | undefined
expect(bedrockOpts).toBeDefined()
const additionalFields = bedrockOpts!.additionalModelRequestFields as
| Record<string, unknown>
| 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<string, unknown> | undefined
expect(bedrockOpts).toBeDefined()
const additionalFields = bedrockOpts!.additionalModelRequestFields as
| Record<string, unknown>
| 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<string, unknown> | undefined
if (bedrockOpts?.additionalModelRequestFields) {
const additionalFields = bedrockOpts.additionalModelRequestFields as Record<string, unknown>
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<string, unknown> | undefined
if (bedrockOpts?.additionalModelRequestFields) {
const additionalFields = bedrockOpts.additionalModelRequestFields as Record<string, unknown>
expect(additionalFields.service_tier).toBeUndefined()
}
})
})
@ -1127,16 +1175,16 @@ describe("AwsBedrockHandler", () => {
})
describe("error telemetry", () => {
let mockSend: ReturnType<typeof vi.fn>
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<typeof vi.fn>
// 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<typeof vi.fn>
// 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<typeof vi.fn>
// Mock the send to throw an error
mockSendFn.mockRejectedValueOnce(new Error("Test error for throw verification"))
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",

View file

@ -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<typeof import("ai")>()
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: "<think>Thinking..." },
index: 0,
},
],
usage: null,
}
yield {
choices: [
{
delta: { content: "</think>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: "<think>Thinking..." }
yield { type: "text-delta", text: "</think>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)
})
})

View file

@ -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", () => {

View file

@ -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<typeof import("ai")>()
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 <think> tags", async () => {
// Override the mock for this specific test
mockCreate.mockImplementationOnce(async () => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: { content: "<think>Thinking..." },
index: 0,
},
],
usage: null,
}
yield {
choices: [
{
delta: { content: "</think>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: "<think>Thinking..." }
yield { type: "text-delta", text: "</think>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)
})
})

View file

@ -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<typeof import("ai")>()
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()
})
})
})

View file

@ -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<typeof import("ai")>()
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<typeof import("@ai-sdk/google")>()
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)

View file

@ -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<typeof import("ai")>()
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<any> {
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")
})
})
})

View file

@ -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<typeof import("ai")>()
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("")
})
})

View file

@ -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()
})
})

View file

@ -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<typeof vitest.fn>).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<typeof vitest.fn>).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", () => {

View file

@ -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<typeof import("ai")>()
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<any> {
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<any> {
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)
}
})
})
})

View file

@ -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<typeof import("ai")>()
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)
})
})
})

File diff suppressed because it is too large Load diff

View file

@ -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<typeof import("ai")>()
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)
})
})
})

View file

@ -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[] = []

View file

@ -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"

View file

@ -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
}
}

View file

@ -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<BasetenModelId> {
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<typeof createBaseten>
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<typeof streamText>[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<string> {
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
}
}

File diff suppressed because it is too large Load diff

View file

@ -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
}
}

View file

@ -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<string> {
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<string> {
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,
},
}
}
}

View file

@ -47,6 +47,7 @@ export class DeepInfraHandler extends RouterProvider implements SingleCompletion
modelId: id,
model: info,
settings: this.options,
defaultTemperature: 0,
})
return { id, info, ...params }

View file

@ -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
}
}

View file

@ -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 }
}

View file

@ -78,4 +78,8 @@ export class FakeAIHandler implements ApiHandler, SingleCompletionHandler {
completePrompt(prompt: string): Promise<string> {
return this.ai.completePrompt(prompt)
}
isAiSdkProvider(): boolean {
return false
}
}

View file

@ -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<FeatherlessModelId> {
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<Featherless
const model = this.getModel()
if (model.id.includes("DeepSeek-R1")) {
const stream = await this.client.chat.completions.create({
...this.getCompletionParams(systemPrompt, messages),
messages: convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]),
// R1 path: merge system prompt into user messages, use TagMatcher for <think> 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<Featherless
}) 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
try {
for await (const part of result.fullStream) {
if (part.type === "text-delta") {
for (const processedChunk of matcher.update(part.text)) {
yield processedChunk
}
}
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
for (const processedChunk of matcher.final()) {
yield processedChunk
}
}
// Process any remaining content
for (const processedChunk of matcher.final()) {
yield processedChunk
const usage = await result.usage
if (usage) {
yield this.processUsageMetrics(usage)
}
} catch (error) {
throw handleAiSdkError(error, "Featherless")
}
} else {
yield* super.createMessage(systemPrompt, messages, metadata)
}
}
override getModel() {
const model = super.getModel()
const isDeepSeekR1 = model.id.includes("DeepSeek-R1")
return {
...model,
info: {
...model.info,
temperature: isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : this.defaultTemperature,
},
}
}
}

View file

@ -67,7 +67,7 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise<Model
switch (provider) {
case "openrouter":
models = await getOpenRouterModels()
models = await getOpenRouterModels({ openRouterBaseUrl: options.baseUrl })
break
case "requesty":
// Requesty models endpoint requires an API key for per-user custom policies.

View file

@ -248,6 +248,16 @@ export const parseOpenRouterModel = ({
modelInfo.maxTokens = anthropicModels["claude-opus-4-1-20250805"].maxTokens
}
// Set claude-opus-4.5 model to use the correct configuration
if (id === "anthropic/claude-opus-4.5") {
modelInfo.maxTokens = anthropicModels["claude-opus-4-5-20251101"].maxTokens
}
// Set claude-opus-4.6 model to use the correct configuration
if (id === "anthropic/claude-opus-4.6") {
modelInfo.maxTokens = anthropicModels["claude-opus-4-6"].maxTokens
}
// Ensure correct reasoning handling for Claude Haiku 4.5 on OpenRouter
// Use budget control and disable effort-based reasoning fallback
if (id === "anthropic/claude-haiku-4.5") {

View file

@ -172,4 +172,8 @@ export class FireworksHandler extends BaseProvider implements SingleCompletionHa
return text
}
override isAiSdkProvider(): boolean {
return true
}
}

View file

@ -1,13 +1,6 @@
import type { Anthropic } from "@anthropic-ai/sdk"
import {
GoogleGenAI,
type GenerateContentResponseUsageMetadata,
type GenerateContentParameters,
type GenerateContentConfig,
type GroundingMetadata,
FunctionCallingConfigMode,
} from "@google/genai"
import type { JWTInput } from "google-auth-library"
import { createGoogleGenerativeAI, type GoogleGenerativeAIProvider } from "@ai-sdk/google"
import { streamText, generateText, ToolSet } from "ai"
import {
type ModelInfo,
@ -16,59 +9,43 @@ import {
geminiModels,
ApiProviderError,
} from "@roo-code/types"
import { safeJsonParse } from "@roo-code/core"
import { TelemetryService } from "@roo-code/telemetry"
import type { ApiHandlerOptions } from "../../shared/api"
import { convertAnthropicMessageToGemini } from "../transform/gemini-format"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,
} from "../transform/ai-sdk"
import { t } from "i18next"
import type { ApiStream, GroundingSource } from "../transform/stream"
import type { ApiStream, ApiStreamUsageChunk, GroundingSource } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
import { BaseProvider } from "./base-provider"
type GeminiHandlerOptions = ApiHandlerOptions & {
isVertex?: boolean
}
import { DEFAULT_HEADERS } from "./constants"
export class GeminiHandler extends BaseProvider implements SingleCompletionHandler {
protected options: ApiHandlerOptions
private client: GoogleGenAI
private lastThoughtSignature?: string
private lastResponseId?: string
protected provider: GoogleGenerativeAIProvider
private readonly providerName = "Gemini"
private lastThoughtSignature: string | undefined
constructor({ isVertex, ...options }: GeminiHandlerOptions) {
constructor(options: ApiHandlerOptions) {
super()
this.options = options
const project = this.options.vertexProjectId ?? "not-provided"
const location = this.options.vertexRegion ?? "not-provided"
const apiKey = this.options.geminiApiKey ?? "not-provided"
this.client = this.options.vertexJsonCredentials
? new GoogleGenAI({
vertexai: true,
project,
location,
googleAuthOptions: {
credentials: safeJsonParse<JWTInput>(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<string, string>()
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<typeof streamText>[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<string, unknown> }
}>) {
// 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<string, unknown>,
): 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<string> {
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
}
}

View file

@ -174,4 +174,8 @@ export class GroqHandler extends BaseProvider implements SingleCompletionHandler
return text
}
override isAiSdkProvider(): boolean {
return true
}
}

View file

@ -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<typeof createOpenAICompatible>
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<typeof streamText>[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<string> {
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
}
}

View file

@ -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<IOIntelligenceModelId> {
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 }
}
}

View file

@ -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
}
}

View file

@ -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 }
}

View file

@ -186,4 +186,8 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si
return text
}
override isAiSdkProvider(): boolean {
return true
}
}

View file

@ -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",

View file

@ -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 }
}

View file

@ -89,6 +89,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan
modelId: id,
model: info,
settings: this.options,
defaultTemperature: 0,
})
return { id, info, ...params }

View file

@ -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<typeof createSambaNova>
export class SambaNovaHandler extends BaseOpenAiCompatibleProvider<SambaNovaModelId> {
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<typeof streamText>[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<string> {
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
}
}

Some files were not shown because too many files have changed in this diff Show more