chore: merge origin/main into vk/6325-multi-question and resolve conflicts

This commit is contained in:
ScDor 2026-02-09 23:51:13 +02:00
commit 621ab32d9a
372 changed files with 23547 additions and 27714 deletions

View file

@ -1,6 +0,0 @@
POSTHOG_API_KEY=key-goes-here
# Roo Code Cloud / Local Development
CLERK_BASE_URL=https://epic-chamois-85.clerk.accounts.dev
ROO_CODE_API_URL=http://localhost:3000
ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy/v1

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

View file

@ -1,8 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Feature Request
url: https://github.com/RooCodeInc/Roo-Code/discussions/categories/feature-requests
about: Share and vote on feature requests for Roo Code
- name: Leave a Review
url: https://marketplace.visualstudio.com/items?itemName=RooVeterinaryInc.roo-cline&ssr=false#review-details
about: Enjoying Roo Code? Leave a review here!

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

@ -0,0 +1,394 @@
name: CLI Release
on:
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., 0.1.0). Leave empty to use package.json version.'
required: false
type: string
dry_run:
description: 'Dry run (build and test but do not create release).'
required: false
type: boolean
default: false
jobs:
# Build CLI for each platform.
build:
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
platform: darwin-arm64
runs-on: macos-latest
- os: ubuntu-latest
platform: linux-x64
runs-on: ubuntu-latest
- os: ubuntu-24.04-arm
platform: linux-arm64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm
- name: Get version
id: version
run: |
if [ -n "${{ inputs.version }}" ]; then
VERSION="${{ inputs.version }}"
else
VERSION=$(node -p "require('./apps/cli/package.json').version")
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=cli-v$VERSION" >> $GITHUB_OUTPUT
echo "Using version: $VERSION"
- name: Build extension bundle
run: pnpm bundle
- name: Build CLI
run: pnpm --filter @roo-code/cli build
- name: Create release tarball
id: tarball
env:
VERSION: ${{ steps.version.outputs.version }}
PLATFORM: ${{ matrix.platform }}
run: |
RELEASE_DIR="roo-cli-${PLATFORM}"
TARBALL="roo-cli-${PLATFORM}.tar.gz"
# Clean up any previous build.
rm -rf "$RELEASE_DIR"
rm -f "$TARBALL"
# Create directory structure.
mkdir -p "$RELEASE_DIR/bin"
mkdir -p "$RELEASE_DIR/lib"
mkdir -p "$RELEASE_DIR/extension"
# Copy CLI dist files.
echo "Copying CLI files..."
cp -r apps/cli/dist/* "$RELEASE_DIR/lib/"
# Create package.json for npm install.
echo "Creating package.json..."
node -e "
const pkg = require('./apps/cli/package.json');
const newPkg = {
name: '@roo-code/cli',
version: '$VERSION',
type: 'module',
dependencies: {
'@inkjs/ui': pkg.dependencies['@inkjs/ui'],
'@trpc/client': pkg.dependencies['@trpc/client'],
'commander': pkg.dependencies.commander,
'fuzzysort': pkg.dependencies.fuzzysort,
'ink': pkg.dependencies.ink,
'p-wait-for': pkg.dependencies['p-wait-for'],
'react': pkg.dependencies.react,
'superjson': pkg.dependencies.superjson,
'zustand': pkg.dependencies.zustand
}
};
console.log(JSON.stringify(newPkg, null, 2));
" > "$RELEASE_DIR/package.json"
# Copy extension bundle.
echo "Copying extension bundle..."
cp -r src/dist/* "$RELEASE_DIR/extension/"
# Add package.json to extension directory for CommonJS.
echo '{"type": "commonjs"}' > "$RELEASE_DIR/extension/package.json"
# Find and copy ripgrep binary.
echo "Looking for ripgrep binary..."
RIPGREP_PATH=$(find node_modules -path "*/@vscode/ripgrep/bin/rg" -type f 2>/dev/null | head -1)
if [ -n "$RIPGREP_PATH" ] && [ -f "$RIPGREP_PATH" ]; then
echo "Found ripgrep at: $RIPGREP_PATH"
mkdir -p "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin"
cp "$RIPGREP_PATH" "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/"
chmod +x "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/rg"
mkdir -p "$RELEASE_DIR/bin"
cp "$RIPGREP_PATH" "$RELEASE_DIR/bin/"
chmod +x "$RELEASE_DIR/bin/rg"
else
echo "Warning: ripgrep binary not found"
fi
# Create the wrapper script
echo "Creating wrapper script..."
printf '%s\n' '#!/usr/bin/env node' \
'' \
"import { fileURLToPath } from 'url';" \
"import { dirname, join } from 'path';" \
'' \
'const __filename = fileURLToPath(import.meta.url);' \
'const __dirname = dirname(__filename);' \
'' \
'// Set environment variables for the CLI' \
"process.env.ROO_CLI_ROOT = join(__dirname, '..');" \
"process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension');" \
"process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg');" \
'' \
'// Import and run the actual CLI' \
"await import(join(__dirname, '..', 'lib', 'index.js'));" \
> "$RELEASE_DIR/bin/roo"
chmod +x "$RELEASE_DIR/bin/roo"
# Create empty .env file.
touch "$RELEASE_DIR/.env"
# Create tarball.
echo "Creating tarball..."
tar -czvf "$TARBALL" "$RELEASE_DIR"
# Clean up release directory.
rm -rf "$RELEASE_DIR"
# Create checksum.
if command -v sha256sum &> /dev/null; then
sha256sum "$TARBALL" > "${TARBALL}.sha256"
elif command -v shasum &> /dev/null; then
shasum -a 256 "$TARBALL" > "${TARBALL}.sha256"
fi
echo "tarball=$TARBALL" >> $GITHUB_OUTPUT
echo "Created: $TARBALL"
ls -la "$TARBALL"
- name: Verify tarball
env:
PLATFORM: ${{ matrix.platform }}
run: |
TARBALL="roo-cli-${PLATFORM}.tar.gz"
# Create temp directory for verification.
VERIFY_DIR=$(mktemp -d)
# Extract and verify structure.
tar -xzf "$TARBALL" -C "$VERIFY_DIR"
echo "Verifying tarball contents..."
ls -la "$VERIFY_DIR/roo-cli-${PLATFORM}/"
# Check required files exist.
test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/bin/roo" || { echo "Missing bin/roo"; exit 1; }
test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/lib/index.js" || { echo "Missing lib/index.js"; exit 1; }
test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/package.json" || { echo "Missing package.json"; exit 1; }
test -d "$VERIFY_DIR/roo-cli-${PLATFORM}/extension" || { echo "Missing extension directory"; exit 1; }
echo "Tarball verification passed!"
# Cleanup.
rm -rf "$VERIFY_DIR"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: cli-${{ matrix.platform }}
path: |
roo-cli-${{ matrix.platform }}.tar.gz
roo-cli-${{ matrix.platform }}.tar.gz.sha256
retention-days: 7
# Create GitHub release with all platform artifacts.
release:
needs: build
runs-on: ubuntu-latest
if: ${{ !inputs.dry_run }}
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get version
id: version
run: |
if [ -n "${{ inputs.version }}" ]; then
VERSION="${{ inputs.version }}"
else
VERSION=$(node -p "require('./apps/cli/package.json').version")
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=cli-v$VERSION" >> $GITHUB_OUTPUT
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Prepare release files
run: |
mkdir -p release
find artifacts -name "*.tar.gz" -exec cp {} release/ \;
find artifacts -name "*.sha256" -exec cp {} release/ \;
ls -la release/
- name: Extract changelog
id: changelog
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
CHANGELOG_FILE="apps/cli/CHANGELOG.md"
if [ -f "$CHANGELOG_FILE" ]; then
# Extract content between version headers.
CONTENT=$(awk -v version="$VERSION" '
BEGIN { found = 0; content = ""; target = "[" version "]" }
/^## \[/ {
if (found) { exit }
if (index($0, target) > 0) { found = 1; next }
}
found { content = content $0 "\n" }
END { print content }
' "$CHANGELOG_FILE")
if [ -n "$CONTENT" ]; then
echo "Found changelog content"
echo "content<<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), Linux x64, or Linux ARM64" >> "$NOTES_FILE"
echo "" >> "$NOTES_FILE"
echo "## Usage" >> "$NOTES_FILE"
echo "" >> "$NOTES_FILE"
echo '```bash' >> "$NOTES_FILE"
echo "# Run a task" >> "$NOTES_FILE"
echo 'roo "What is this project?"' >> "$NOTES_FILE"
echo "" >> "$NOTES_FILE"
echo "# See all options" >> "$NOTES_FILE"
echo "roo --help" >> "$NOTES_FILE"
echo '```' >> "$NOTES_FILE"
echo "" >> "$NOTES_FILE"
echo "## Platform Support" >> "$NOTES_FILE"
echo "" >> "$NOTES_FILE"
echo "This release includes binaries for:" >> "$NOTES_FILE"
echo '- `roo-cli-darwin-arm64.tar.gz` - macOS Apple Silicon (M1/M2/M3)' >> "$NOTES_FILE"
echo '- `roo-cli-linux-x64.tar.gz` - Linux x64' >> "$NOTES_FILE"
echo '- `roo-cli-linux-arm64.tar.gz` - Linux ARM64' >> "$NOTES_FILE"
echo "" >> "$NOTES_FILE"
echo "## Checksums" >> "$NOTES_FILE"
echo "" >> "$NOTES_FILE"
echo '```' >> "$NOTES_FILE"
echo "$CHECKSUMS" >> "$NOTES_FILE"
echo '```' >> "$NOTES_FILE"
gh release create "$TAG" \
--title "Roo Code CLI v$VERSION" \
--notes-file "$NOTES_FILE" \
--prerelease \
release/*
rm -f "$NOTES_FILE"
echo "Release created: https://github.com/${{ github.repository }}/releases/tag/$TAG"
# Summary job for dry runs
summary:
needs: build
runs-on: ubuntu-latest
if: ${{ inputs.dry_run }}
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Show build summary
run: |
echo "## Dry Run Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The following artifacts were built:" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
find artifacts -name "*.tar.gz" | while read f; do
SIZE=$(ls -lh "$f" | awk '{print $5}')
echo "- $(basename $f) ($SIZE)" >> $GITHUB_STEP_SUMMARY
done
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Checksums" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
cat artifacts/*/*.sha256 >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY

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,41 @@ All notable changes to the `@roo-code/cli` package will be documented in this fi
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.0.52] - 2026-02-09
### Added
- **Linux Support**: Added support for `linux-arm64`.
## [0.0.51] - 2026-02-06
### Changed
- **Default Model Update**: Changed the default model from Opus 4.5 to Opus 4.6 for improved performance and capabilities
## [0.0.50] - 2026-02-05
### Added
- **Linux Support**: The CLI now supports Linux platforms in addition to macOS
- **Roo Provider API Key Support**: Allow `--api-key` flag and `ROO_API_KEY` environment variable for the roo provider instead of requiring cloud auth token
- **Exit on Error**: New `--exit-on-error` flag to exit immediately on API request errors instead of retrying, useful for CI/CD pipelines
### Changed
- **Improved Dev Experience**: Dev scripts now use `tsx` for running directly from source without building first
- **Path Resolution Fixes**: Fixed path resolution in [`version.ts`](src/lib/utils/version.ts), [`extension.ts`](src/lib/utils/extension.ts), and [`extension-host.ts`](src/agent/extension-host.ts) to work from both source and bundled locations
- **Debug Logging**: Debug log file (`~/.roo/cli-debug.log`) is now disabled by default unless `--debug` flag is passed
- Updated README with complete environment variable table and dev workflow documentation
### Fixed
- Corrected example in install script
### Removed
- Dropped macOS 13 support
## [0.0.49] - 2026-01-18
### Added

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.52",
"description": "Roo Code CLI - Run the Roo Code agent from the command line",
"private": true,
"type": "module",
@ -15,11 +15,8 @@
"test": "vitest run",
"build": "tsup",
"build:extension": "pnpm --filter roo-cline bundle",
"build:all": "pnpm --filter roo-cline bundle && tsup",
"dev": "tsup --watch",
"start": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy node dist/index.js",
"start:production": "node dist/index.js",
"release": "scripts/release.sh",
"dev": "ROO_AUTH_BASE_URL=https://app.roocode.com ROO_SDK_BASE_URL=https://cloud-api.roocode.com ROO_CODE_PROVIDER_URL=https://api.roocode.com/proxy tsx src/index.ts -y",
"dev:local": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy tsx src/index.ts",
"clean": "rimraf dist .turbo"
},
"dependencies": {

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

@ -48,18 +48,10 @@ function getModelIdForProvider(config: ProviderSettings): string | undefined {
return config.requestyModelId
case "litellm":
return config.litellmModelId
case "deepinfra":
return config.deepInfraModelId
case "huggingface":
return config.huggingFaceModelId
case "unbound":
return config.unboundModelId
case "vercel-ai-gateway":
return config.vercelAiGatewayModelId
case "io-intelligence":
return config.ioIntelligenceModelId
default:
// For anthropic, bedrock, vertex, gemini, xai, groq, etc.
// For anthropic, bedrock, vertex, gemini, xai, etc.
return config.apiModelId
}
}

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

@ -27,7 +27,7 @@
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@roo-code/evals": "workspace:^",
"@roo-code/types": "^1.108.0",
"@roo-code/types": "workspace:^",
"@tanstack/react-query": "^5.69.0",
"archiver": "^7.0.1",
"class-variance-authority": "^0.7.1",

View file

@ -1,74 +0,0 @@
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import * as fs from "node:fs/promises"
import * as path from "node:path"
import { findTask, findRun } from "@roo-code/evals"
export const dynamic = "force-dynamic"
const LOG_BASE_PATH = "/tmp/evals/runs"
// Sanitize path components to prevent path traversal attacks
function sanitizePathComponent(component: string): string {
// Remove any path separators, null bytes, and other dangerous characters
return component.replace(/[/\\:\0*?"<>|]/g, "_")
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string; taskId: string }> }) {
const { id, taskId } = await params
try {
const runId = Number(id)
const taskIdNum = Number(taskId)
if (isNaN(runId) || isNaN(taskIdNum)) {
return NextResponse.json({ error: "Invalid run ID or task ID" }, { status: 400 })
}
// Verify the run exists
await findRun(runId)
// Get the task to find its language and exercise
const task = await findTask(taskIdNum)
// Verify the task belongs to this run
if (task.runId !== runId) {
return NextResponse.json({ error: "Task does not belong to this run" }, { status: 404 })
}
// Sanitize language and exercise to prevent path traversal
const safeLanguage = sanitizePathComponent(task.language)
const safeExercise = sanitizePathComponent(task.exercise)
// Construct the log file path
const logFileName = `${safeLanguage}-${safeExercise}.log`
const logFilePath = path.join(LOG_BASE_PATH, String(runId), logFileName)
// Verify the resolved path is within the expected directory (defense in depth)
const resolvedPath = path.resolve(logFilePath)
const expectedBase = path.resolve(LOG_BASE_PATH)
if (!resolvedPath.startsWith(expectedBase)) {
return NextResponse.json({ error: "Invalid log path" }, { status: 400 })
}
// Check if the log file exists and read it (async)
try {
const logContent = await fs.readFile(logFilePath, "utf-8")
return NextResponse.json({ logContent })
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return NextResponse.json({ error: "Log file not found", logContent: null }, { status: 200 })
}
throw err
}
} catch (error) {
console.error("Error reading task log:", error)
if (error instanceof Error && error.name === "RecordNotFoundError") {
return NextResponse.json({ error: "Task or run not found" }, { status: 404 })
}
return NextResponse.json({ error: "Failed to read log file" }, { status: 500 })
}
}

View file

@ -1,147 +0,0 @@
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import * as fs from "node:fs"
import * as path from "node:path"
import archiver from "archiver"
import { findRun, getTasks } from "@roo-code/evals"
export const dynamic = "force-dynamic"
const LOG_BASE_PATH = "/tmp/evals/runs"
// Sanitize path components to prevent path traversal attacks
function sanitizePathComponent(component: string): string {
// Remove any path separators, null bytes, and other dangerous characters
return component.replace(/[/\\:\0*?"<>|]/g, "_")
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
try {
const runId = Number(id)
if (isNaN(runId)) {
return NextResponse.json({ error: "Invalid run ID" }, { status: 400 })
}
// Verify the run exists
await findRun(runId)
// Get all tasks for this run
const tasks = await getTasks(runId)
// Filter for failed tasks only
const failedTasks = tasks.filter((task) => task.passed === false)
if (failedTasks.length === 0) {
return NextResponse.json({ error: "No failed tasks to export" }, { status: 400 })
}
// Create a zip archive
const archive = archiver("zip", { zlib: { level: 9 } })
// Collect chunks to build the response
const chunks: Buffer[] = []
archive.on("data", (chunk: Buffer) => {
chunks.push(chunk)
})
// Track archive errors
let archiveError: Error | null = null
archive.on("error", (err: Error) => {
archiveError = err
})
// Set up the end promise before finalizing (proper event listener ordering)
const archiveEndPromise = new Promise<void>((resolve, reject) => {
archive.on("end", resolve)
archive.on("error", reject)
})
// Add each failed task's log file and history files to the archive
const logDir = path.join(LOG_BASE_PATH, String(runId))
let filesAdded = 0
for (const task of failedTasks) {
// Sanitize language and exercise to prevent path traversal
const safeLanguage = sanitizePathComponent(task.language)
const safeExercise = sanitizePathComponent(task.exercise)
const expectedBase = path.resolve(LOG_BASE_PATH)
// Add the log file
const logFileName = `${safeLanguage}-${safeExercise}.log`
const logFilePath = path.join(logDir, logFileName)
// Verify the resolved path is within the expected directory (defense in depth)
const resolvedLogPath = path.resolve(logFilePath)
if (resolvedLogPath.startsWith(expectedBase) && fs.existsSync(logFilePath)) {
archive.file(logFilePath, { name: logFileName })
filesAdded++
}
// Add the API conversation history file
// Format: {language}-{exercise}.{iteration}_api_conversation_history.json
const apiHistoryFileName = `${safeLanguage}-${safeExercise}.${task.iteration}_api_conversation_history.json`
const apiHistoryFilePath = path.join(logDir, apiHistoryFileName)
const resolvedApiHistoryPath = path.resolve(apiHistoryFilePath)
if (resolvedApiHistoryPath.startsWith(expectedBase) && fs.existsSync(apiHistoryFilePath)) {
archive.file(apiHistoryFilePath, { name: apiHistoryFileName })
filesAdded++
}
// Add the UI messages file
// Format: {language}-{exercise}.{iteration}_ui_messages.json
const uiMessagesFileName = `${safeLanguage}-${safeExercise}.${task.iteration}_ui_messages.json`
const uiMessagesFilePath = path.join(logDir, uiMessagesFileName)
const resolvedUiMessagesPath = path.resolve(uiMessagesFilePath)
if (resolvedUiMessagesPath.startsWith(expectedBase) && fs.existsSync(uiMessagesFilePath)) {
archive.file(uiMessagesFilePath, { name: uiMessagesFileName })
filesAdded++
}
}
// Check if any files were actually added
if (filesAdded === 0) {
archive.abort()
return NextResponse.json(
{ error: "No log files found - they may have been cleared from disk" },
{ status: 404 },
)
}
// Finalize the archive
await archive.finalize()
// Wait for all data to be collected
await archiveEndPromise
// Check for archive errors
if (archiveError) {
throw archiveError
}
// Combine all chunks into a single buffer
const zipBuffer = Buffer.concat(chunks)
// Return the zip file
return new NextResponse(zipBuffer, {
status: 200,
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="run-${runId}-failed-logs.zip"`,
"Content-Length": String(zipBuffer.length),
},
})
} catch (error) {
console.error("Error exporting failed logs:", error)
if (error instanceof Error && error.name === "RecordNotFoundError") {
return NextResponse.json({ error: "Run not found" }, { status: 404 })
}
return NextResponse.json({ error: "Failed to export logs" }, { status: 500 })
}
}

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

@ -264,7 +264,7 @@ export const runTaskWithCli = async ({ run, task, publish, logger, jobToken }: R
if (rooTaskId && !isClientDisconnected) {
logger.info("cancelling task")
client.sendCommand({ commandName: TaskCommandName.CancelTask, data: rooTaskId })
client.sendCommand({ commandName: TaskCommandName.CancelTask })
await new Promise((resolve) => setTimeout(resolve, 5_000))
}
@ -289,7 +289,7 @@ export const runTaskWithCli = async ({ run, task, publish, logger, jobToken }: R
if (rooTaskId && !isClientDisconnected) {
logger.info("closing task")
client.sendCommand({ commandName: TaskCommandName.CloseTask, data: rooTaskId })
client.sendCommand({ commandName: TaskCommandName.CloseTask })
await new Promise((resolve) => setTimeout(resolve, 2_000))
}

View file

@ -270,7 +270,7 @@ export const runTaskInVscode = async ({ run, task, publish, logger, jobToken }:
if (rooTaskId && !isClientDisconnected) {
logger.info("cancelling task")
client.sendCommand({ commandName: TaskCommandName.CancelTask, data: rooTaskId })
client.sendCommand({ commandName: TaskCommandName.CancelTask })
await new Promise((resolve) => setTimeout(resolve, 5_000)) // Allow some time for the task to cancel.
}
@ -289,7 +289,7 @@ export const runTaskInVscode = async ({ run, task, publish, logger, jobToken }:
if (rooTaskId && !isClientDisconnected) {
logger.info("closing task")
client.sendCommand({ commandName: TaskCommandName.CloseTask, data: rooTaskId })
client.sendCommand({ commandName: TaskCommandName.CloseTask })
await new Promise((resolve) => setTimeout(resolve, 2_000)) // Allow some time for the window to close.
}

View file

@ -1,6 +1,6 @@
{
"name": "@roo-code/types",
"version": "1.106.0",
"version": "1.110.0",
"description": "TypeScript type definitions for Roo Code.",
"publishConfig": {
"access": "public",

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

@ -27,7 +27,7 @@ describe("IPC Types", () => {
const result = taskCommandSchema.safeParse(resumeTaskCommand)
expect(result.success).toBe(true)
if (result.success) {
if (result.success && result.data.commandName === TaskCommandName.ResumeTask) {
expect(result.data.commandName).toBe("ResumeTask")
expect(result.data.data).toBe("non-existent-task-id")
}
@ -45,7 +45,7 @@ describe("IPC Types", () => {
const result = taskCommandSchema.safeParse(resumeTaskCommand)
expect(result.success).toBe(true)
if (result.success) {
if (result.success && result.data.commandName === TaskCommandName.ResumeTask) {
expect(result.data.commandName).toBe("ResumeTask")
expect(result.data.data).toBe("task-123")
}

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, tokenUsageSchema } from "./message.js"
import { clineMessageSchema, queuedMessageSchema, tokenUsageSchema } from "./message.js"
import { modelInfoSchema } from "./model.js"
import { toolNamesSchema, toolUsageSchema } from "./tool.js"
/**
@ -35,6 +36,7 @@ export enum RooCodeEventName {
TaskModeSwitched = "taskModeSwitched",
TaskAskResponded = "taskAskResponded",
TaskUserMessage = "taskUserMessage",
QueuedMessagesUpdated = "queuedMessagesUpdated",
// Task Analytics
TaskTokenUsageUpdated = "taskTokenUsageUpdated",
@ -44,6 +46,11 @@ export enum RooCodeEventName {
ModeChanged = "modeChanged",
ProviderProfileChanged = "providerProfileChanged",
// Query Responses
CommandsResponse = "commandsResponse",
ModesResponse = "modesResponse",
ModelsResponse = "modelsResponse",
// Evals
EvalPass = "evalPass",
EvalFail = "evalFail",
@ -100,12 +107,27 @@ export const rooCodeEventsSchema = z.object({
[RooCodeEventName.TaskModeSwitched]: z.tuple([z.string(), z.string()]),
[RooCodeEventName.TaskAskResponded]: z.tuple([z.string()]),
[RooCodeEventName.TaskUserMessage]: z.tuple([z.string()]),
[RooCodeEventName.QueuedMessagesUpdated]: z.tuple([z.string(), z.array(queuedMessageSchema)]),
[RooCodeEventName.TaskToolFailed]: z.tuple([z.string(), toolNamesSchema, z.string()]),
[RooCodeEventName.TaskTokenUsageUpdated]: z.tuple([z.string(), tokenUsageSchema, toolUsageSchema]),
[RooCodeEventName.ModeChanged]: z.tuple([z.string()]),
[RooCodeEventName.ProviderProfileChanged]: z.tuple([z.object({ name: z.string(), provider: z.string() })]),
[RooCodeEventName.CommandsResponse]: z.tuple([
z.array(
z.object({
name: z.string(),
source: z.enum(["global", "project", "built-in"]),
filePath: z.string().optional(),
description: z.string().optional(),
argumentHint: z.string().optional(),
}),
),
]),
[RooCodeEventName.ModesResponse]: z.tuple([z.array(z.object({ slug: z.string(), name: z.string() }))]),
[RooCodeEventName.ModelsResponse]: z.tuple([z.record(z.string(), modelInfoSchema)]),
})
export type RooCodeEvents = z.infer<typeof rooCodeEventsSchema>
@ -217,6 +239,11 @@ export const taskEventSchema = z.discriminatedUnion("eventName", [
payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskAskResponded],
taskId: z.number().optional(),
}),
z.object({
eventName: z.literal(RooCodeEventName.QueuedMessagesUpdated),
payload: rooCodeEventsSchema.shape[RooCodeEventName.QueuedMessagesUpdated],
taskId: z.number().optional(),
}),
// Task Analytics
z.object({
@ -230,6 +257,23 @@ export const taskEventSchema = z.discriminatedUnion("eventName", [
taskId: z.number().optional(),
}),
// Query Responses
z.object({
eventName: z.literal(RooCodeEventName.CommandsResponse),
payload: rooCodeEventsSchema.shape[RooCodeEventName.CommandsResponse],
taskId: z.number().optional(),
}),
z.object({
eventName: z.literal(RooCodeEventName.ModesResponse),
payload: rooCodeEventsSchema.shape[RooCodeEventName.ModesResponse],
taskId: z.number().optional(),
}),
z.object({
eventName: z.literal(RooCodeEventName.ModelsResponse),
payload: rooCodeEventsSchema.shape[RooCodeEventName.ModelsResponse],
taskId: z.number().optional(),
}),
// Evals
z.object({
eventName: z.literal(RooCodeEventName.EvalPass),

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"
/**
@ -166,6 +167,7 @@ export const globalSettingsSchema = z.object({
ttsSpeed: z.number().optional(),
soundEnabled: z.boolean().optional(),
soundVolume: z.number().optional(),
taskHeaderHighlightEnabled: z.boolean().optional(),
maxOpenTabsContext: z.number().optional(),
maxWorkspaceFiles: z.number().optional(),
@ -238,6 +240,12 @@ export const globalSettingsSchema = z.object({
* @default true
*/
showWorktreesInHomeScreen: z.boolean().optional(),
/**
* List of native tool names to globally disable.
* Tools in this list will be excluded from prompt generation and rejected at execution time.
*/
disabledTools: z.array(toolNamesSchema).optional(),
})
export type GlobalSettings = z.infer<typeof globalSettingsSchema>
@ -266,19 +274,13 @@ export const SECRET_STATE_KEYS = [
"ollamaApiKey",
"geminiApiKey",
"openAiNativeApiKey",
"cerebrasApiKey",
"deepSeekApiKey",
"doubaoApiKey",
"moonshotApiKey",
"mistralApiKey",
"minimaxApiKey",
"unboundApiKey",
"requestyApiKey",
"xaiApiKey",
"groqApiKey",
"chutesApiKey",
"litellmApiKey",
"deepInfraApiKey",
"codeIndexOpenAiKey",
"codeIndexQdrantApiKey",
"codebaseIndexOpenAiCompatibleApiKey",
@ -286,14 +288,12 @@ export const SECRET_STATE_KEYS = [
"codebaseIndexMistralApiKey",
"codebaseIndexVercelAiGatewayApiKey",
"codebaseIndexOpenRouterApiKey",
"huggingFaceApiKey",
"sambaNovaApiKey",
"zaiApiKey",
"fireworksApiKey",
"featherlessApiKey",
"ioIntelligenceApiKey",
"vercelAiGatewayApiKey",
"basetenApiKey",
"azureApiKey",
] as const
// Global secrets that are part of GlobalSettings (not ProviderSettings)
@ -367,6 +367,7 @@ export const EVALS_SETTINGS: RooCodeSettings = {
ttsSpeed: 1,
soundEnabled: false,
soundVolume: 0.5,
taskHeaderHighlightEnabled: false,
terminalShellIntegrationTimeout: 30000,
terminalCommandDelay: 0,

View file

@ -46,6 +46,9 @@ export enum TaskCommandName {
CloseTask = "CloseTask",
ResumeTask = "ResumeTask",
SendMessage = "SendMessage",
GetCommands = "GetCommands",
GetModes = "GetModes",
GetModels = "GetModels",
}
/**
@ -64,11 +67,9 @@ export const taskCommandSchema = z.discriminatedUnion("commandName", [
}),
z.object({
commandName: z.literal(TaskCommandName.CancelTask),
data: z.string(),
}),
z.object({
commandName: z.literal(TaskCommandName.CloseTask),
data: z.string(),
}),
z.object({
commandName: z.literal(TaskCommandName.ResumeTask),
@ -81,6 +82,15 @@ export const taskCommandSchema = z.discriminatedUnion("commandName", [
images: z.array(z.string()).optional(),
}),
}),
z.object({
commandName: z.literal(TaskCommandName.GetCommands),
}),
z.object({
commandName: z.literal(TaskCommandName.GetModes),
}),
z.object({
commandName: z.literal(TaskCommandName.GetModels),
}),
])
export type TaskCommand = z.infer<typeof taskCommandSchema>

View file

@ -6,14 +6,9 @@ import {
anthropicModels,
basetenModels,
bedrockModels,
cerebrasModels,
deepSeekModels,
doubaoModels,
featherlessModels,
fireworksModels,
geminiModels,
groqModels,
ioIntelligenceModels,
mistralModels,
moonshotModels,
openAiCodexModels,
@ -39,18 +34,7 @@ export const DEFAULT_CONSECUTIVE_MISTAKE_LIMIT = 3
* Dynamic provider requires external API calls in order to get the model list.
*/
export const dynamicProviders = [
"openrouter",
"vercel-ai-gateway",
"huggingface",
"litellm",
"deepinfra",
"io-intelligence",
"requesty",
"unbound",
"roo",
"chutes",
] as const
export const dynamicProviders = ["openrouter", "vercel-ai-gateway", "litellm", "requesty", "roo"] as const
export type DynamicProvider = (typeof dynamicProviders)[number]
@ -119,16 +103,13 @@ export const providerNames = [
...customProviders,
...fauxProviders,
"anthropic",
"azure",
"bedrock",
"baseten",
"cerebras",
"doubao",
"deepseek",
"featherless",
"fireworks",
"gemini",
"gemini-cli",
"groq",
"mistral",
"moonshot",
"minimax",
@ -149,6 +130,33 @@ export type ProviderName = z.infer<typeof providerNamesSchema>
export const isProviderName = (key: unknown): key is ProviderName =>
typeof key === "string" && providerNames.includes(key as ProviderName)
/**
* RetiredProviderName
*/
export const retiredProviderNames = [
"cerebras",
"chutes",
"deepinfra",
"doubao",
"featherless",
"groq",
"huggingface",
"io-intelligence",
"unbound",
] as const
export const retiredProviderNamesSchema = z.enum(retiredProviderNames)
export type RetiredProviderName = z.infer<typeof retiredProviderNamesSchema>
export const isRetiredProvider = (value: string): value is RetiredProviderName =>
retiredProviderNames.includes(value as RetiredProviderName)
export const providerNamesWithRetiredSchema = z.union([providerNamesSchema, retiredProviderNamesSchema])
export type ProviderNameWithRetired = z.infer<typeof providerNamesWithRetiredSchema>
/**
* ProviderSettingsEntry
*/
@ -156,7 +164,7 @@ export const isProviderName = (key: unknown): key is ProviderName =>
export const providerSettingsEntrySchema = z.object({
id: z.string(),
name: z.string(),
apiProvider: providerNamesSchema.optional(),
apiProvider: providerNamesWithRetiredSchema.optional(),
modelId: z.string().optional(),
})
@ -227,8 +235,6 @@ const vertexSchema = apiModelIdProviderModelSchema.extend({
vertexJsonCredentials: z.string().optional(),
vertexProjectId: z.string().optional(),
vertexRegion: z.string().optional(),
enableUrlContext: z.boolean().optional(),
enableGrounding: z.boolean().optional(),
vertex1MContext: z.boolean().optional(), // Enable 'context-1m-2025-08-07' beta for 1M context window.
})
@ -273,8 +279,6 @@ const lmStudioSchema = baseProviderSettingsSchema.extend({
const geminiSchema = apiModelIdProviderModelSchema.extend({
geminiApiKey: z.string().optional(),
googleGeminiBaseUrl: z.string().optional(),
enableUrlContext: z.boolean().optional(),
enableGrounding: z.boolean().optional(),
})
const geminiCliSchema = apiModelIdProviderModelSchema.extend({
@ -304,17 +308,6 @@ const deepSeekSchema = apiModelIdProviderModelSchema.extend({
deepSeekApiKey: z.string().optional(),
})
const deepInfraSchema = apiModelIdProviderModelSchema.extend({
deepInfraBaseUrl: z.string().optional(),
deepInfraApiKey: z.string().optional(),
deepInfraModelId: z.string().optional(),
})
const doubaoSchema = apiModelIdProviderModelSchema.extend({
doubaoBaseUrl: z.string().optional(),
doubaoApiKey: z.string().optional(),
})
const moonshotSchema = apiModelIdProviderModelSchema.extend({
moonshotBaseUrl: z
.union([z.literal("https://api.moonshot.ai/v1"), z.literal("https://api.moonshot.cn/v1")])
@ -329,11 +322,6 @@ const minimaxSchema = apiModelIdProviderModelSchema.extend({
minimaxApiKey: z.string().optional(),
})
const unboundSchema = baseProviderSettingsSchema.extend({
unboundApiKey: z.string().optional(),
unboundModelId: z.string().optional(),
})
const requestySchema = baseProviderSettingsSchema.extend({
requestyBaseUrl: z.string().optional(),
requestyApiKey: z.string().optional(),
@ -348,20 +336,6 @@ const xaiSchema = apiModelIdProviderModelSchema.extend({
xaiApiKey: z.string().optional(),
})
const groqSchema = apiModelIdProviderModelSchema.extend({
groqApiKey: z.string().optional(),
})
const huggingFaceSchema = baseProviderSettingsSchema.extend({
huggingFaceApiKey: z.string().optional(),
huggingFaceModelId: z.string().optional(),
huggingFaceInferenceProvider: z.string().optional(),
})
const chutesSchema = apiModelIdProviderModelSchema.extend({
chutesApiKey: z.string().optional(),
})
const litellmSchema = baseProviderSettingsSchema.extend({
litellmBaseUrl: z.string().optional(),
litellmApiKey: z.string().optional(),
@ -369,10 +343,6 @@ const litellmSchema = baseProviderSettingsSchema.extend({
litellmUsePromptCache: z.boolean().optional(),
})
const cerebrasSchema = apiModelIdProviderModelSchema.extend({
cerebrasApiKey: z.string().optional(),
})
const sambaNovaSchema = apiModelIdProviderModelSchema.extend({
sambaNovaApiKey: z.string().optional(),
})
@ -390,15 +360,6 @@ const fireworksSchema = apiModelIdProviderModelSchema.extend({
fireworksApiKey: z.string().optional(),
})
const featherlessSchema = apiModelIdProviderModelSchema.extend({
featherlessApiKey: z.string().optional(),
})
const ioIntelligenceSchema = apiModelIdProviderModelSchema.extend({
ioIntelligenceModelId: z.string().optional(),
ioIntelligenceApiKey: z.string().optional(),
})
const qwenCodeSchema = apiModelIdProviderModelSchema.extend({
qwenCodeOauthPath: z.string().optional(),
})
@ -417,12 +378,20 @@ const basetenSchema = apiModelIdProviderModelSchema.extend({
basetenApiKey: z.string().optional(),
})
const azureSchema = apiModelIdProviderModelSchema.extend({
azureApiKey: z.string().optional(),
azureResourceName: z.string().optional(),
azureDeploymentName: z.string().optional(),
azureApiVersion: z.string().optional(),
})
const defaultSchema = z.object({
apiProvider: z.undefined(),
})
export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProvider", [
anthropicSchema.merge(z.object({ apiProvider: z.literal("anthropic") })),
azureSchema.merge(z.object({ apiProvider: z.literal("azure") })),
openRouterSchema.merge(z.object({ apiProvider: z.literal("openrouter") })),
bedrockSchema.merge(z.object({ apiProvider: z.literal("bedrock") })),
vertexSchema.merge(z.object({ apiProvider: z.literal("vertex") })),
@ -436,25 +405,16 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
openAiNativeSchema.merge(z.object({ apiProvider: z.literal("openai-native") })),
mistralSchema.merge(z.object({ apiProvider: z.literal("mistral") })),
deepSeekSchema.merge(z.object({ apiProvider: z.literal("deepseek") })),
deepInfraSchema.merge(z.object({ apiProvider: z.literal("deepinfra") })),
doubaoSchema.merge(z.object({ apiProvider: z.literal("doubao") })),
moonshotSchema.merge(z.object({ apiProvider: z.literal("moonshot") })),
minimaxSchema.merge(z.object({ apiProvider: z.literal("minimax") })),
unboundSchema.merge(z.object({ apiProvider: z.literal("unbound") })),
requestySchema.merge(z.object({ apiProvider: z.literal("requesty") })),
fakeAiSchema.merge(z.object({ apiProvider: z.literal("fake-ai") })),
xaiSchema.merge(z.object({ apiProvider: z.literal("xai") })),
groqSchema.merge(z.object({ apiProvider: z.literal("groq") })),
basetenSchema.merge(z.object({ apiProvider: z.literal("baseten") })),
huggingFaceSchema.merge(z.object({ apiProvider: z.literal("huggingface") })),
chutesSchema.merge(z.object({ apiProvider: z.literal("chutes") })),
litellmSchema.merge(z.object({ apiProvider: z.literal("litellm") })),
cerebrasSchema.merge(z.object({ apiProvider: z.literal("cerebras") })),
sambaNovaSchema.merge(z.object({ apiProvider: z.literal("sambanova") })),
zaiSchema.merge(z.object({ apiProvider: z.literal("zai") })),
fireworksSchema.merge(z.object({ apiProvider: z.literal("fireworks") })),
featherlessSchema.merge(z.object({ apiProvider: z.literal("featherless") })),
ioIntelligenceSchema.merge(z.object({ apiProvider: z.literal("io-intelligence") })),
qwenCodeSchema.merge(z.object({ apiProvider: z.literal("qwen-code") })),
rooSchema.merge(z.object({ apiProvider: z.literal("roo") })),
vercelAiGatewaySchema.merge(z.object({ apiProvider: z.literal("vercel-ai-gateway") })),
@ -462,8 +422,9 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
])
export const providerSettingsSchema = z.object({
apiProvider: providerNamesSchema.optional(),
apiProvider: providerNamesWithRetiredSchema.optional(),
...anthropicSchema.shape,
...azureSchema.shape,
...openRouterSchema.shape,
...bedrockSchema.shape,
...vertexSchema.shape,
@ -477,25 +438,16 @@ export const providerSettingsSchema = z.object({
...openAiNativeSchema.shape,
...mistralSchema.shape,
...deepSeekSchema.shape,
...deepInfraSchema.shape,
...doubaoSchema.shape,
...moonshotSchema.shape,
...minimaxSchema.shape,
...unboundSchema.shape,
...requestySchema.shape,
...fakeAiSchema.shape,
...xaiSchema.shape,
...groqSchema.shape,
...basetenSchema.shape,
...huggingFaceSchema.shape,
...chutesSchema.shape,
...litellmSchema.shape,
...cerebrasSchema.shape,
...sambaNovaSchema.shape,
...zaiSchema.shape,
...fireworksSchema.shape,
...featherlessSchema.shape,
...ioIntelligenceSchema.shape,
...qwenCodeSchema.shape,
...rooSchema.shape,
...vercelAiGatewaySchema.shape,
@ -525,13 +477,9 @@ export const modelIdKeys = [
"ollamaModelId",
"lmStudioModelId",
"lmStudioDraftModelId",
"unboundModelId",
"requestyModelId",
"litellmModelId",
"huggingFaceModelId",
"ioIntelligenceModelId",
"vercelAiGatewayModelId",
"deepInfraModelId",
] as const satisfies readonly (keyof ProviderSettings)[]
export type ModelIdKey = (typeof modelIdKeys)[number]
@ -552,6 +500,7 @@ export const isTypicalProvider = (key: unknown): key is TypicalProvider =>
export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
anthropic: "apiModelId",
azure: "apiModelId",
openrouter: "openRouterModelId",
bedrock: "apiModelId",
vertex: "apiModelId",
@ -565,23 +514,14 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
moonshot: "apiModelId",
minimax: "apiModelId",
deepseek: "apiModelId",
deepinfra: "deepInfraModelId",
doubao: "apiModelId",
"qwen-code": "apiModelId",
unbound: "unboundModelId",
requesty: "requestyModelId",
xai: "apiModelId",
groq: "apiModelId",
baseten: "apiModelId",
chutes: "apiModelId",
litellm: "litellmModelId",
huggingface: "huggingFaceModelId",
cerebras: "apiModelId",
sambanova: "apiModelId",
zai: "apiModelId",
fireworks: "apiModelId",
featherless: "apiModelId",
"io-intelligence": "ioIntelligenceModelId",
roo: "apiModelId",
"vercel-ai-gateway": "vercelAiGatewayModelId",
}
@ -628,27 +568,22 @@ export const MODELS_BY_PROVIDER: Record<
label: "Anthropic",
models: Object.keys(anthropicModels),
},
azure: {
id: "azure",
label: "Azure AI Foundry",
// Azure uses deployment names configured by the user (not a fixed upstream model ID list)
models: [],
},
bedrock: {
id: "bedrock",
label: "Amazon Bedrock",
models: Object.keys(bedrockModels),
},
cerebras: {
id: "cerebras",
label: "Cerebras",
models: Object.keys(cerebrasModels),
},
deepseek: {
id: "deepseek",
label: "DeepSeek",
models: Object.keys(deepSeekModels),
},
doubao: { id: "doubao", label: "Doubao", models: Object.keys(doubaoModels) },
featherless: {
id: "featherless",
label: "Featherless",
models: Object.keys(featherlessModels),
},
fireworks: {
id: "fireworks",
label: "Fireworks",
@ -659,12 +594,6 @@ export const MODELS_BY_PROVIDER: Record<
label: "Google Gemini",
models: Object.keys(geminiModels),
},
groq: { id: "groq", label: "Groq", models: Object.keys(groqModels) },
"io-intelligence": {
id: "io-intelligence",
label: "IO Intelligence",
models: Object.keys(ioIntelligenceModels),
},
mistral: {
id: "mistral",
label: "Mistral",
@ -712,14 +641,10 @@ export const MODELS_BY_PROVIDER: Record<
baseten: { id: "baseten", label: "Baseten", models: Object.keys(basetenModels) },
// Dynamic providers; models pulled from remote APIs.
huggingface: { id: "huggingface", label: "Hugging Face", models: [] },
litellm: { id: "litellm", label: "LiteLLM", models: [] },
openrouter: { id: "openrouter", label: "OpenRouter", models: [] },
requesty: { id: "requesty", label: "Requesty", models: [] },
unbound: { id: "unbound", label: "Unbound", models: [] },
deepinfra: { id: "deepinfra", label: "DeepInfra", models: [] },
"vercel-ai-gateway": { id: "vercel-ai-gateway", label: "Vercel AI Gateway", models: [] },
chutes: { id: "chutes", label: "Chutes AI", models: [] },
// Local providers; models discovered from localhost endpoints.
lmstudio: { id: "lmstudio", label: "LM Studio", models: [] },

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

@ -0,0 +1,403 @@
import type { ModelInfo } from "../model.js"
/**
* Azure AI Foundry model metadata.
*
* NOTE:
* - Azure AI Foundry uses *deployment names* at runtime, but Roo still needs underlying model
* capabilities (maxTokens/contextWindow/etc.) for validation and parameter shaping.
* - This list is derived from https://models.dev/api.json (provider: "azure") and intentionally
* restricted to OpenAI/Azure OpenAI-style IDs (gpt-*, o*, codex-*).
*/
export const azureModels = {
"codex-mini": {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 1.5,
outputPrice: 6,
cacheReadsPrice: 0.375,
supportsTemperature: false,
description:
"Codex Mini: Cloud-based software engineering agent powered by codex-1, a version of o3 optimized for coding tasks",
},
"gpt-4": {
maxTokens: 8_192,
contextWindow: 8_192,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 60,
outputPrice: 120,
supportsTemperature: true,
description: "GPT-4",
},
"gpt-4-32k": {
maxTokens: 32_768,
contextWindow: 32_768,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 60,
outputPrice: 120,
supportsTemperature: true,
description: "GPT-4 32K",
},
"gpt-4-turbo": {
maxTokens: 4_096,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 10,
outputPrice: 30,
supportsTemperature: true,
description: "GPT-4 Turbo",
},
"gpt-4-turbo-vision": {
maxTokens: 4_096,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 10,
outputPrice: 30,
supportsTemperature: true,
description: "GPT-4 Turbo Vision",
},
"gpt-4.1": {
maxTokens: 32_768,
contextWindow: 1_047_576,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 2,
outputPrice: 8,
cacheReadsPrice: 0.5,
supportsTemperature: true,
description: "GPT-4.1",
},
"gpt-4.1-mini": {
maxTokens: 32_768,
contextWindow: 1_047_576,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0.4,
outputPrice: 1.6,
cacheReadsPrice: 0.1,
supportsTemperature: true,
description: "GPT-4.1 mini",
},
"gpt-4.1-nano": {
maxTokens: 32_768,
contextWindow: 1_047_576,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0.1,
outputPrice: 0.4,
cacheReadsPrice: 0.03,
supportsTemperature: true,
description: "GPT-4.1 nano",
},
"gpt-4o": {
maxTokens: 16_384,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 2.5,
outputPrice: 10,
cacheReadsPrice: 1.25,
supportsTemperature: true,
description: "GPT-4o",
},
"gpt-4o-mini": {
maxTokens: 16_384,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0.15,
outputPrice: 0.6,
cacheReadsPrice: 0.08,
supportsTemperature: true,
description: "GPT-4o mini",
},
"gpt-5": {
maxTokens: 128_000,
contextWindow: 272_000,
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
supportsPromptCache: true,
supportsReasoningEffort: ["minimal", "low", "medium", "high"],
reasoningEffort: "medium",
inputPrice: 1.25,
outputPrice: 10,
cacheReadsPrice: 0.13,
supportsVerbosity: true,
supportsTemperature: false,
description: "GPT-5: The best model for coding and agentic tasks across domains",
},
"gpt-5-codex": {
maxTokens: 128_000,
contextWindow: 400_000,
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
supportsPromptCache: true,
supportsReasoningEffort: ["low", "medium", "high"],
reasoningEffort: "medium",
inputPrice: 1.25,
outputPrice: 10,
cacheReadsPrice: 0.13,
supportsTemperature: false,
description: "GPT-5-Codex: A version of GPT-5 optimized for agentic coding in Codex",
},
"gpt-5-mini": {
maxTokens: 128_000,
contextWindow: 272_000,
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
supportsPromptCache: true,
supportsReasoningEffort: ["minimal", "low", "medium", "high"],
reasoningEffort: "medium",
inputPrice: 0.25,
outputPrice: 2,
cacheReadsPrice: 0.03,
supportsVerbosity: true,
supportsTemperature: false,
description: "GPT-5 Mini: A faster, more cost-efficient version of GPT-5 for well-defined tasks",
},
"gpt-5-nano": {
maxTokens: 128_000,
contextWindow: 272_000,
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
supportsPromptCache: true,
supportsReasoningEffort: ["minimal", "low", "medium", "high"],
reasoningEffort: "medium",
inputPrice: 0.05,
outputPrice: 0.4,
cacheReadsPrice: 0.01,
supportsVerbosity: true,
supportsTemperature: false,
description: "GPT-5 Nano: Fastest, most cost-efficient version of GPT-5",
},
"gpt-5-pro": {
maxTokens: 272_000,
contextWindow: 400_000,
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
supportsPromptCache: false,
supportsReasoningEffort: ["minimal", "low", "medium", "high"],
reasoningEffort: "medium",
inputPrice: 15,
outputPrice: 120,
supportsVerbosity: true,
supportsTemperature: false,
description: "GPT-5 Pro",
},
"gpt-5.1": {
maxTokens: 128_000,
contextWindow: 272_000,
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
supportsPromptCache: true,
promptCacheRetention: "24h",
supportsReasoningEffort: ["none", "low", "medium", "high"],
reasoningEffort: "medium",
inputPrice: 1.25,
outputPrice: 10,
cacheReadsPrice: 0.125,
supportsVerbosity: true,
supportsTemperature: false,
description: "GPT-5.1: The best model for coding and agentic tasks across domains",
},
"gpt-5.1-chat": {
maxTokens: 16_384,
contextWindow: 128_000,
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
supportsPromptCache: true,
promptCacheRetention: "24h",
inputPrice: 1.25,
outputPrice: 10,
cacheReadsPrice: 0.125,
supportsTemperature: false,
description: "GPT-5.1 Chat: Optimized for conversational AI and chat use cases",
},
"gpt-5.1-codex": {
maxTokens: 128_000,
contextWindow: 400_000,
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
supportsPromptCache: true,
promptCacheRetention: "24h",
supportsReasoningEffort: ["low", "medium", "high"],
reasoningEffort: "medium",
inputPrice: 1.25,
outputPrice: 10,
cacheReadsPrice: 0.125,
supportsTemperature: false,
description: "GPT-5.1 Codex: A version of GPT-5.1 optimized for agentic coding in Codex",
},
"gpt-5.1-codex-max": {
maxTokens: 128_000,
contextWindow: 400_000,
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
supportsPromptCache: true,
promptCacheRetention: "24h",
supportsReasoningEffort: ["low", "medium", "high", "xhigh"],
reasoningEffort: "medium",
inputPrice: 1.25,
outputPrice: 10,
cacheReadsPrice: 0.125,
supportsTemperature: false,
description:
"GPT-5.1 Codex Max: Our most intelligent coding model optimized for long-horizon, agentic coding tasks",
},
"gpt-5.1-codex-mini": {
maxTokens: 128_000,
contextWindow: 400_000,
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
supportsPromptCache: true,
promptCacheRetention: "24h",
supportsReasoningEffort: ["low", "medium", "high"],
reasoningEffort: "medium",
inputPrice: 0.25,
outputPrice: 2,
cacheReadsPrice: 0.025,
supportsTemperature: false,
description: "GPT-5.1 Codex mini: A version of GPT-5.1 optimized for agentic coding in Codex",
},
"gpt-5.2": {
maxTokens: 128_000,
contextWindow: 400_000,
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
supportsPromptCache: true,
promptCacheRetention: "24h",
supportsReasoningEffort: ["none", "low", "medium", "high", "xhigh"],
reasoningEffort: "medium",
inputPrice: 1.75,
outputPrice: 14,
cacheReadsPrice: 0.125,
supportsVerbosity: true,
supportsTemperature: false,
description: "GPT-5.2: Our flagship model for coding and agentic tasks across industries",
},
"gpt-5.2-chat": {
maxTokens: 16_384,
contextWindow: 128_000,
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.75,
outputPrice: 14,
cacheReadsPrice: 0.175,
supportsTemperature: false,
description: "GPT-5.2 Chat: Optimized for conversational AI and chat use cases",
},
"gpt-5.2-codex": {
maxTokens: 128_000,
contextWindow: 400_000,
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
supportsPromptCache: true,
promptCacheRetention: "24h",
supportsReasoningEffort: ["low", "medium", "high", "xhigh"],
reasoningEffort: "medium",
inputPrice: 1.75,
outputPrice: 14,
cacheReadsPrice: 0.175,
supportsTemperature: false,
description:
"GPT-5.2 Codex: Our most intelligent coding model optimized for long-horizon, agentic coding tasks",
},
o1: {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 15,
outputPrice: 60,
cacheReadsPrice: 7.5,
supportsTemperature: false,
description: "o1",
},
"o1-mini": {
maxTokens: 65_536,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.1,
outputPrice: 4.4,
cacheReadsPrice: 0.55,
supportsTemperature: false,
description: "o1-mini",
},
"o1-preview": {
maxTokens: 32_768,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 16.5,
outputPrice: 66,
cacheReadsPrice: 8.25,
supportsTemperature: false,
description: "o1-preview",
},
o3: {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoningEffort: ["low", "medium", "high"],
reasoningEffort: "medium",
inputPrice: 2,
outputPrice: 8,
cacheReadsPrice: 0.5,
supportsTemperature: false,
description: "o3",
},
"o3-mini": {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: true,
supportsReasoningEffort: ["low", "medium", "high"],
reasoningEffort: "medium",
inputPrice: 1.1,
outputPrice: 4.4,
cacheReadsPrice: 0.55,
supportsTemperature: false,
description: "o3-mini",
},
"o4-mini": {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoningEffort: ["low", "medium", "high"],
reasoningEffort: "medium",
inputPrice: 1.1,
outputPrice: 4.4,
cacheReadsPrice: 0.28,
supportsTemperature: false,
description: "o4-mini",
},
} as const satisfies Record<string, ModelInfo>
export type AzureModelId = keyof typeof azureModels
export const azureDefaultModelId: AzureModelId = "gpt-4o"
export const azureDefaultModelInfo: ModelInfo = azureModels[azureDefaultModelId]

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

@ -1,58 +0,0 @@
import type { ModelInfo } from "../model.js"
// https://inference-docs.cerebras.ai/api-reference/chat-completions
export type CerebrasModelId = keyof typeof cerebrasModels
export const cerebrasDefaultModelId: CerebrasModelId = "gpt-oss-120b"
export const cerebrasModels = {
"zai-glm-4.7": {
maxTokens: 16384, // Conservative default to avoid premature rate limiting (Cerebras reserves quota upfront)
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: true,
supportsTemperature: true,
defaultTemperature: 1.0,
inputPrice: 0,
outputPrice: 0,
description:
"Highly capable general-purpose model on Cerebras (up to 1,000 tokens/s), competitive with leading proprietary models on coding tasks.",
},
"qwen-3-235b-a22b-instruct-2507": {
maxTokens: 16384, // Conservative default to avoid premature rate limiting
contextWindow: 64000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Intelligent model with ~1400 tokens/s",
},
"llama-3.3-70b": {
maxTokens: 16384, // Conservative default to avoid premature rate limiting
contextWindow: 64000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Powerful model with ~2600 tokens/s",
},
"qwen-3-32b": {
maxTokens: 16384, // Conservative default to avoid premature rate limiting
contextWindow: 64000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "SOTA coding performance with ~2500 tokens/s",
},
"gpt-oss-120b": {
maxTokens: 16384, // Conservative default to avoid premature rate limiting
contextWindow: 64000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description:
"OpenAI GPT OSS model with ~2800 tokens/s\n\n• 64K context window\n• Excels at efficient reasoning across science, math, and coding",
},
} as const satisfies Record<string, ModelInfo>

View file

@ -1,421 +0,0 @@
import type { ModelInfo } from "../model.js"
// https://llm.chutes.ai/v1 (OpenAI compatible)
export type ChutesModelId =
| "deepseek-ai/DeepSeek-R1-0528"
| "deepseek-ai/DeepSeek-R1"
| "deepseek-ai/DeepSeek-V3"
| "deepseek-ai/DeepSeek-V3.1"
| "deepseek-ai/DeepSeek-V3.1-Terminus"
| "deepseek-ai/DeepSeek-V3.1-turbo"
| "deepseek-ai/DeepSeek-V3.2-Exp"
| "unsloth/Llama-3.3-70B-Instruct"
| "chutesai/Llama-4-Scout-17B-16E-Instruct"
| "unsloth/Mistral-Nemo-Instruct-2407"
| "unsloth/gemma-3-12b-it"
| "NousResearch/DeepHermes-3-Llama-3-8B-Preview"
| "unsloth/gemma-3-4b-it"
| "nvidia/Llama-3_3-Nemotron-Super-49B-v1"
| "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1"
| "chutesai/Llama-4-Maverick-17B-128E-Instruct-FP8"
| "deepseek-ai/DeepSeek-V3-Base"
| "deepseek-ai/DeepSeek-R1-Zero"
| "deepseek-ai/DeepSeek-V3-0324"
| "Qwen/Qwen3-235B-A22B"
| "Qwen/Qwen3-235B-A22B-Instruct-2507"
| "Qwen/Qwen3-32B"
| "Qwen/Qwen3-30B-A3B"
| "Qwen/Qwen3-14B"
| "Qwen/Qwen3-8B"
| "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8"
| "microsoft/MAI-DS-R1-FP8"
| "tngtech/DeepSeek-R1T-Chimera"
| "zai-org/GLM-4.5-Air"
| "zai-org/GLM-4.5-FP8"
| "zai-org/GLM-4.5-turbo"
| "zai-org/GLM-4.6-FP8"
| "zai-org/GLM-4.6-turbo"
| "meituan-longcat/LongCat-Flash-Thinking-FP8"
| "moonshotai/Kimi-K2-Instruct-75k"
| "moonshotai/Kimi-K2-Instruct-0905"
| "Qwen/Qwen3-235B-A22B-Thinking-2507"
| "Qwen/Qwen3-Next-80B-A3B-Instruct"
| "Qwen/Qwen3-Next-80B-A3B-Thinking"
| "Qwen/Qwen3-VL-235B-A22B-Thinking"
export const chutesDefaultModelId: ChutesModelId = "deepseek-ai/DeepSeek-R1-0528"
export const chutesModels = {
"deepseek-ai/DeepSeek-R1-0528": {
maxTokens: 32768,
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek R1 0528 model.",
},
"deepseek-ai/DeepSeek-R1": {
maxTokens: 32768,
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek R1 model.",
},
"deepseek-ai/DeepSeek-V3": {
maxTokens: 32768,
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek V3 model.",
},
"deepseek-ai/DeepSeek-V3.1": {
maxTokens: 32768,
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek V3.1 model.",
},
"deepseek-ai/DeepSeek-V3.1-Terminus": {
maxTokens: 163840,
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.23,
outputPrice: 0.9,
description:
"DeepSeekV3.1Terminus is an update to V3.1 that improves language consistency by reducing CN/EN mixups and eliminating random characters, while strengthening agent capabilities with notably better Code Agent and Search Agent performance.",
},
"deepseek-ai/DeepSeek-V3.1-turbo": {
maxTokens: 32768,
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 1.0,
outputPrice: 3.0,
description:
"DeepSeek-V3.1-turbo is an FP8, speculative-decoding turbo variant optimized for ultra-fast single-shot queries (~200 TPS), with outputs close to the originals and solid function calling/reasoning/structured output, priced at $1/M input and $3/M output tokens, using 2× quota per request and not intended for bulk workloads.",
},
"deepseek-ai/DeepSeek-V3.2-Exp": {
maxTokens: 163840,
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.25,
outputPrice: 0.35,
description:
"DeepSeek-V3.2-Exp is an experimental LLM that introduces DeepSeek Sparse Attention to improve longcontext training and inference efficiency while maintaining performance comparable to V3.1Terminus.",
},
"unsloth/Llama-3.3-70B-Instruct": {
maxTokens: 32768, // From Groq
contextWindow: 131072, // From Groq
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Unsloth Llama 3.3 70B Instruct model.",
},
"chutesai/Llama-4-Scout-17B-16E-Instruct": {
maxTokens: 32768,
contextWindow: 512000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "ChutesAI Llama 4 Scout 17B Instruct model, 512K context.",
},
"unsloth/Mistral-Nemo-Instruct-2407": {
maxTokens: 32768,
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Unsloth Mistral Nemo Instruct model.",
},
"unsloth/gemma-3-12b-it": {
maxTokens: 32768,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Unsloth Gemma 3 12B IT model.",
},
"NousResearch/DeepHermes-3-Llama-3-8B-Preview": {
maxTokens: 32768,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Nous DeepHermes 3 Llama 3 8B Preview model.",
},
"unsloth/gemma-3-4b-it": {
maxTokens: 32768,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Unsloth Gemma 3 4B IT model.",
},
"nvidia/Llama-3_3-Nemotron-Super-49B-v1": {
maxTokens: 32768,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Nvidia Llama 3.3 Nemotron Super 49B model.",
},
"nvidia/Llama-3_1-Nemotron-Ultra-253B-v1": {
maxTokens: 32768,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Nvidia Llama 3.1 Nemotron Ultra 253B model.",
},
"chutesai/Llama-4-Maverick-17B-128E-Instruct-FP8": {
maxTokens: 32768,
contextWindow: 256000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "ChutesAI Llama 4 Maverick 17B Instruct FP8 model.",
},
"deepseek-ai/DeepSeek-V3-Base": {
maxTokens: 32768,
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek V3 Base model.",
},
"deepseek-ai/DeepSeek-R1-Zero": {
maxTokens: 32768,
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek R1 Zero model.",
},
"deepseek-ai/DeepSeek-V3-0324": {
maxTokens: 32768,
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek V3 (0324) model.",
},
"Qwen/Qwen3-235B-A22B-Instruct-2507": {
maxTokens: 32768,
contextWindow: 262144,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Qwen3 235B A22B Instruct 2507 model with 262K context window.",
},
"Qwen/Qwen3-235B-A22B": {
maxTokens: 32768,
contextWindow: 40960,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Qwen3 235B A22B model.",
},
"Qwen/Qwen3-32B": {
maxTokens: 32768,
contextWindow: 40960,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Qwen3 32B model.",
},
"Qwen/Qwen3-30B-A3B": {
maxTokens: 32768,
contextWindow: 40960,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Qwen3 30B A3B model.",
},
"Qwen/Qwen3-14B": {
maxTokens: 32768,
contextWindow: 40960,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Qwen3 14B model.",
},
"Qwen/Qwen3-8B": {
maxTokens: 32768,
contextWindow: 40960,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Qwen3 8B model.",
},
"microsoft/MAI-DS-R1-FP8": {
maxTokens: 32768,
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Microsoft MAI-DS-R1 FP8 model.",
},
"tngtech/DeepSeek-R1T-Chimera": {
maxTokens: 32768,
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "TNGTech DeepSeek R1T Chimera model.",
},
"zai-org/GLM-4.5-Air": {
maxTokens: 32768,
contextWindow: 151329,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description:
"GLM-4.5-Air model with 151,329 token context window and 106B total parameters with 12B activated.",
},
"zai-org/GLM-4.5-FP8": {
maxTokens: 32768,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description:
"GLM-4.5-FP8 model with 128k token context window, optimized for agent-based applications with MoE architecture.",
},
"zai-org/GLM-4.5-turbo": {
maxTokens: 32768,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 1,
outputPrice: 3,
description: "GLM-4.5-turbo model with 128K token context window, optimized for fast inference.",
},
"zai-org/GLM-4.6-FP8": {
maxTokens: 32768,
contextWindow: 202752,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description:
"GLM-4.6 introduces major upgrades over GLM-4.5, including a longer 200K-token context window for complex tasks, stronger coding performance in benchmarks and real-world tools (such as Claude Code, Cline, Roo Code, and Kilo Code), improved reasoning with tool use during inference, more capable and efficient agent integration, and refined writing that better matches human style, readability, and natural role-play scenarios.",
},
"zai-org/GLM-4.6-turbo": {
maxTokens: 202752, // From Chutes /v1/models: max_output_length
contextWindow: 202752,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 1.15,
outputPrice: 3.25,
description: "GLM-4.6-turbo model with 200K-token context window, optimized for fast inference.",
},
"meituan-longcat/LongCat-Flash-Thinking-FP8": {
maxTokens: 32768,
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description:
"LongCat Flash Thinking FP8 model with 128K context window, optimized for complex reasoning and coding tasks.",
},
"Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": {
maxTokens: 32768,
contextWindow: 262144,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Qwen3 Coder 480B A35B Instruct FP8 model, optimized for coding tasks.",
},
"moonshotai/Kimi-K2-Instruct-75k": {
maxTokens: 32768,
contextWindow: 75000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.1481,
outputPrice: 0.5926,
description: "Moonshot AI Kimi K2 Instruct model with 75k context window.",
},
"moonshotai/Kimi-K2-Instruct-0905": {
maxTokens: 32768,
contextWindow: 262144,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.1999,
outputPrice: 0.8001,
description: "Moonshot AI Kimi K2 Instruct 0905 model with 256k context window.",
},
"Qwen/Qwen3-235B-A22B-Thinking-2507": {
maxTokens: 32768,
contextWindow: 262144,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.077968332,
outputPrice: 0.31202496,
description: "Qwen3 235B A22B Thinking 2507 model with 262K context window.",
},
"Qwen/Qwen3-Next-80B-A3B-Instruct": {
maxTokens: 32768,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description:
"Fast, stable instruction-tuned model optimized for complex tasks, RAG, and tool use without thinking traces.",
},
"Qwen/Qwen3-Next-80B-A3B-Thinking": {
maxTokens: 32768,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description:
"Reasoning-first model with structured thinking traces for multi-step problems, math proofs, and code synthesis.",
},
"Qwen/Qwen3-VL-235B-A22B-Thinking": {
maxTokens: 262144,
contextWindow: 262144,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0.16,
outputPrice: 0.65,
description:
"Qwen3VL235BA22BThinking is an openweight MoE visionlanguage model (235B total, ~22B activated) optimized for deliberate multistep reasoning with strong textimagevideo understanding and longcontext capabilities.",
},
} as const satisfies Record<string, ModelInfo>
export const chutesDefaultModelInfo: ModelInfo = chutesModels[chutesDefaultModelId]

View file

@ -1,14 +0,0 @@
import type { ModelInfo } from "../model.js"
// Default fallback values for DeepInfra when model metadata is not yet loaded.
export const deepInfraDefaultModelId = "Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo"
export const deepInfraDefaultModelInfo: ModelInfo = {
maxTokens: 16384,
contextWindow: 262144,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.3,
outputPrice: 1.2,
description: "Qwen 3 Coder 480B A35B Instruct Turbo model, 256K context.",
}

View file

@ -1,44 +0,0 @@
import type { ModelInfo } from "../model.js"
export const doubaoDefaultModelId = "doubao-seed-1-6-250615"
export const doubaoModels = {
"doubao-seed-1-6-250615": {
maxTokens: 32_768,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0.0001, // $0.0001 per million tokens (cache miss)
outputPrice: 0.0004, // $0.0004 per million tokens
cacheWritesPrice: 0.0001, // $0.0001 per million tokens (cache miss)
cacheReadsPrice: 0.00002, // $0.00002 per million tokens (cache hit)
description: `Doubao Seed 1.6 is a powerful model designed for high-performance tasks with extensive context handling.`,
},
"doubao-seed-1-6-thinking-250715": {
maxTokens: 32_768,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0.0002, // $0.0002 per million tokens
outputPrice: 0.0008, // $0.0008 per million tokens
cacheWritesPrice: 0.0002, // $0.0002 per million
cacheReadsPrice: 0.00004, // $0.00004 per million tokens (cache hit)
description: `Doubao Seed 1.6 Thinking is optimized for reasoning tasks, providing enhanced performance in complex problem-solving scenarios.`,
},
"doubao-seed-1-6-flash-250715": {
maxTokens: 32_768,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0.00015, // $0.00015 per million tokens
outputPrice: 0.0006, // $0.0006 per million tokens
cacheWritesPrice: 0.00015, // $0.00015 per million
cacheReadsPrice: 0.00003, // $0.00003 per million tokens (cache hit)
description: `Doubao Seed 1.6 Flash is tailored for speed and efficiency, making it ideal for applications requiring rapid responses.`,
},
} as const satisfies Record<string, ModelInfo>
export const doubaoDefaultModelInfo: ModelInfo = doubaoModels[doubaoDefaultModelId]
export const DOUBAO_API_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"
export const DOUBAO_API_CHAT_PATH = "/chat/completions"

View file

@ -1,58 +0,0 @@
import type { ModelInfo } from "../model.js"
export type FeatherlessModelId =
| "deepseek-ai/DeepSeek-V3-0324"
| "deepseek-ai/DeepSeek-R1-0528"
| "moonshotai/Kimi-K2-Instruct"
| "openai/gpt-oss-120b"
| "Qwen/Qwen3-Coder-480B-A35B-Instruct"
export const featherlessModels = {
"deepseek-ai/DeepSeek-V3-0324": {
maxTokens: 4096,
contextWindow: 32678,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek V3 0324 model.",
},
"deepseek-ai/DeepSeek-R1-0528": {
maxTokens: 4096,
contextWindow: 32678,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek R1 0528 model.",
},
"moonshotai/Kimi-K2-Instruct": {
maxTokens: 4096,
contextWindow: 32678,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Kimi K2 Instruct model.",
},
"openai/gpt-oss-120b": {
maxTokens: 4096,
contextWindow: 32678,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "GPT-OSS 120B model.",
},
"Qwen/Qwen3-Coder-480B-A35B-Instruct": {
maxTokens: 4096,
contextWindow: 32678,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Qwen3 Coder 480B A35B Instruct model.",
},
} as const satisfies Record<string, ModelInfo>
export const featherlessDefaultModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct"

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

@ -1,84 +0,0 @@
import type { ModelInfo } from "../model.js"
// https://console.groq.com/docs/models
export type GroqModelId =
| "llama-3.1-8b-instant"
| "llama-3.3-70b-versatile"
| "meta-llama/llama-4-scout-17b-16e-instruct"
| "qwen/qwen3-32b"
| "moonshotai/kimi-k2-instruct-0905"
| "openai/gpt-oss-120b"
| "openai/gpt-oss-20b"
export const groqDefaultModelId: GroqModelId = "moonshotai/kimi-k2-instruct-0905"
export const groqModels = {
// Models based on API response: https://api.groq.com/openai/v1/models
"llama-3.1-8b-instant": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.05,
outputPrice: 0.08,
description: "Meta Llama 3.1 8B Instant model, 128K context.",
},
"llama-3.3-70b-versatile": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.59,
outputPrice: 0.79,
description: "Meta Llama 3.3 70B Versatile model, 128K context.",
},
"meta-llama/llama-4-scout-17b-16e-instruct": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.11,
outputPrice: 0.34,
description: "Meta Llama 4 Scout 17B Instruct model, 128K context.",
},
"qwen/qwen3-32b": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.29,
outputPrice: 0.59,
description: "Alibaba Qwen 3 32B model, 128K context.",
},
"moonshotai/kimi-k2-instruct-0905": {
maxTokens: 16384,
contextWindow: 262144,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 0.6,
outputPrice: 2.5,
cacheReadsPrice: 0.15,
description:
"Kimi K2 model gets a new version update: Agentic coding: more accurate, better generalization across scaffolds. Frontend coding: improved aesthetics and functionalities on web, 3d, and other tasks. Context length: extended from 128k to 256k, providing better long-horizon support.",
},
"openai/gpt-oss-120b": {
maxTokens: 32766,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.15,
outputPrice: 0.75,
description:
"GPT-OSS 120B is OpenAI's flagship open source model, built on a Mixture-of-Experts (MoE) architecture with 20 billion parameters and 128 experts.",
},
"openai/gpt-oss-20b": {
maxTokens: 32768,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.1,
outputPrice: 0.5,
description:
"GPT-OSS 20B is OpenAI's flagship open source model, built on a Mixture-of-Experts (MoE) architecture with 20 billion parameters and 32 experts.",
},
} as const satisfies Record<string, ModelInfo>

View file

@ -1,17 +0,0 @@
/**
* HuggingFace provider constants
*/
// Default values for HuggingFace models
export const HUGGINGFACE_DEFAULT_MAX_TOKENS = 2048
export const HUGGINGFACE_MAX_TOKENS_FALLBACK = 8192
export const HUGGINGFACE_DEFAULT_CONTEXT_WINDOW = 128_000
// UI constants
export const HUGGINGFACE_SLIDER_STEP = 256
export const HUGGINGFACE_SLIDER_MIN = 1
export const HUGGINGFACE_TEMPERATURE_MAX_VALUE = 2
// API constants
export const HUGGINGFACE_API_URL = "https://router.huggingface.co/v1/models?collection=roocode"
export const HUGGINGFACE_CACHE_DURATION = 1000 * 60 * 60 // 1 hour

View file

@ -1,16 +1,10 @@
export * from "./anthropic.js"
export * from "./azure.js"
export * from "./baseten.js"
export * from "./bedrock.js"
export * from "./cerebras.js"
export * from "./chutes.js"
export * from "./deepseek.js"
export * from "./doubao.js"
export * from "./featherless.js"
export * from "./fireworks.js"
export * from "./gemini.js"
export * from "./groq.js"
export * from "./huggingface.js"
export * from "./io-intelligence.js"
export * from "./lite-llm.js"
export * from "./lm-studio.js"
export * from "./mistral.js"
@ -24,27 +18,20 @@ export * from "./qwen-code.js"
export * from "./requesty.js"
export * from "./roo.js"
export * from "./sambanova.js"
export * from "./unbound.js"
export * from "./vertex.js"
export * from "./vscode-llm.js"
export * from "./xai.js"
export * from "./vercel-ai-gateway.js"
export * from "./zai.js"
export * from "./deepinfra.js"
export * from "./minimax.js"
import { anthropicDefaultModelId } from "./anthropic.js"
import { azureDefaultModelId } from "./azure.js"
import { basetenDefaultModelId } from "./baseten.js"
import { bedrockDefaultModelId } from "./bedrock.js"
import { cerebrasDefaultModelId } from "./cerebras.js"
import { chutesDefaultModelId } from "./chutes.js"
import { deepSeekDefaultModelId } from "./deepseek.js"
import { doubaoDefaultModelId } from "./doubao.js"
import { featherlessDefaultModelId } from "./featherless.js"
import { fireworksDefaultModelId } from "./fireworks.js"
import { geminiDefaultModelId } from "./gemini.js"
import { groqDefaultModelId } from "./groq.js"
import { ioIntelligenceDefaultModelId } from "./io-intelligence.js"
import { litellmDefaultModelId } from "./lite-llm.js"
import { mistralDefaultModelId } from "./mistral.js"
import { moonshotDefaultModelId } from "./moonshot.js"
@ -54,13 +41,11 @@ import { qwenCodeDefaultModelId } from "./qwen-code.js"
import { requestyDefaultModelId } from "./requesty.js"
import { rooDefaultModelId } from "./roo.js"
import { sambaNovaDefaultModelId } from "./sambanova.js"
import { unboundDefaultModelId } from "./unbound.js"
import { vertexDefaultModelId } from "./vertex.js"
import { vscodeLlmDefaultModelId } from "./vscode-llm.js"
import { xaiDefaultModelId } from "./xai.js"
import { vercelAiGatewayDefaultModelId } from "./vercel-ai-gateway.js"
import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId } from "./zai.js"
import { deepInfraDefaultModelId } from "./deepinfra.js"
import { minimaxDefaultModelId } from "./minimax.js"
// Import the ProviderName type from provider-settings to avoid duplication
@ -80,18 +65,10 @@ export function getProviderDefaultModelId(
return openRouterDefaultModelId
case "requesty":
return requestyDefaultModelId
case "unbound":
return unboundDefaultModelId
case "litellm":
return litellmDefaultModelId
case "xai":
return xaiDefaultModelId
case "groq":
return groqDefaultModelId
case "huggingface":
return "meta-llama/Llama-3.3-70B-Instruct"
case "chutes":
return chutesDefaultModelId
case "baseten":
return basetenDefaultModelId
case "bedrock":
@ -102,8 +79,6 @@ export function getProviderDefaultModelId(
return geminiDefaultModelId
case "deepseek":
return deepSeekDefaultModelId
case "doubao":
return doubaoDefaultModelId
case "moonshot":
return moonshotDefaultModelId
case "minimax":
@ -122,26 +97,20 @@ export function getProviderDefaultModelId(
return "" // Ollama uses dynamic model selection
case "lmstudio":
return "" // LMStudio uses dynamic model selection
case "deepinfra":
return deepInfraDefaultModelId
case "vscode-lm":
return vscodeLlmDefaultModelId
case "cerebras":
return cerebrasDefaultModelId
case "sambanova":
return sambaNovaDefaultModelId
case "fireworks":
return fireworksDefaultModelId
case "featherless":
return featherlessDefaultModelId
case "io-intelligence":
return ioIntelligenceDefaultModelId
case "roo":
return rooDefaultModelId
case "qwen-code":
return qwenCodeDefaultModelId
case "vercel-ai-gateway":
return vercelAiGatewayDefaultModelId
case "azure":
return azureDefaultModelId
case "anthropic":
case "gemini-cli":
case "fake-ai":

View file

@ -1,44 +0,0 @@
import type { ModelInfo } from "../model.js"
export type IOIntelligenceModelId =
| "deepseek-ai/DeepSeek-R1-0528"
| "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8"
| "Intel/Qwen3-Coder-480B-A35B-Instruct-int4-mixed-ar"
| "openai/gpt-oss-120b"
export const ioIntelligenceDefaultModelId: IOIntelligenceModelId = "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8"
export const ioIntelligenceDefaultBaseUrl = "https://api.intelligence.io.solutions/api/v1"
export const IO_INTELLIGENCE_CACHE_DURATION = 1000 * 60 * 60 // 1 hour
export const ioIntelligenceModels = {
"deepseek-ai/DeepSeek-R1-0528": {
maxTokens: 8192,
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
description: "DeepSeek R1 reasoning model",
},
"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": {
maxTokens: 8192,
contextWindow: 430000,
supportsImages: true,
supportsPromptCache: false,
description: "Llama 4 Maverick 17B model",
},
"Intel/Qwen3-Coder-480B-A35B-Instruct-int4-mixed-ar": {
maxTokens: 8192,
contextWindow: 106000,
supportsImages: false,
supportsPromptCache: false,
description: "Qwen3 Coder 480B specialized for coding",
},
"openai/gpt-oss-120b": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
description: "OpenAI GPT-OSS 120B model",
},
} as const satisfies Record<string, ModelInfo>

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

@ -506,9 +506,8 @@ export const openAiModelInfoSaneDefaults: ModelInfo = {
outputPrice: 0,
}
// https://learn.microsoft.com/en-us/azure/ai-services/openai/api-version-deprecation
// https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#api-specs
export const azureOpenAiDefaultApiVersion = "2024-08-01-preview"
// https://learn.microsoft.com/en-us/azure/ai-foundry/openai/api-version-lifecycle
export const azureOpenAiDefaultApiVersion = "2025-04-01-preview"
export const OPENAI_NATIVE_DEFAULT_TEMPERATURE = 0

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

@ -1,14 +0,0 @@
import type { ModelInfo } from "../model.js"
export const unboundDefaultModelId = "anthropic/claude-sonnet-4-5"
export const unboundDefaultModelInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
}

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

@ -95,6 +95,9 @@ export interface CreateTaskOptions {
initialTodos?: TodoItem[]
/** Initial status for the task's history item (e.g., "active" for child tasks) */
initialStatus?: "active" | "delegated" | "completed"
/** Whether to start the task loop immediately (default: true).
* When false, the caller must invoke `task.start()` manually. */
startTask?: boolean
}
export enum TaskStatus {
@ -154,6 +157,7 @@ export type TaskEvents = {
[RooCodeEventName.TaskModeSwitched]: [taskId: string, mode: string]
[RooCodeEventName.TaskAskResponded]: []
[RooCodeEventName.TaskUserMessage]: [taskId: string]
[RooCodeEventName.QueuedMessagesUpdated]: [taskId: string, messages: QueuedMessage[]]
// Task Analytics
[RooCodeEventName.TaskToolFailed]: [taskId: string, tool: ToolName, error: string]

View file

@ -47,7 +47,6 @@ export interface ExtensionMessage {
| "ollamaModels"
| "lmStudioModels"
| "vsCodeLmModels"
| "huggingFaceModels"
| "vsCodeLmApiAvailable"
| "updatePrompt"
| "systemPrompt"
@ -144,23 +143,6 @@ export interface ExtensionMessage {
ollamaModels?: ModelRecord
lmStudioModels?: ModelRecord
vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]
huggingFaceModels?: Array<{
id: string
object: string
created: number
owned_by: string
providers: Array<{
provider: string
status: "live" | "staging" | "error"
supports_tools?: boolean
supports_structured_output?: boolean
context_length?: number
pricing?: {
input: number
output: number
}
}>
}>
mcpServers?: McpServer[]
commits?: GitCommit[]
listApiConfig?: ProviderSettingsEntry[]
@ -303,6 +285,7 @@ export type ExtensionState = Pick<
| "ttsSpeed"
| "soundEnabled"
| "soundVolume"
| "taskHeaderHighlightEnabled"
| "terminalOutputPreviewSize"
| "terminalShellIntegrationTimeout"
| "terminalShellIntegrationDisabled"
@ -335,7 +318,9 @@ export type ExtensionState = Pick<
| "maxGitStatusFiles"
| "requestDelaySeconds"
| "showWorktreesInHomeScreen"
| "disabledTools"
> & {
lockApiConfigAcrossModes?: boolean
version: string
clineMessages: ClineMessage[]
currentTaskItem?: HistoryItem
@ -467,7 +452,6 @@ export interface WebviewMessage {
| "requestRooModels"
| "requestRooCreditBalance"
| "requestVsCodeLmModels"
| "requestHuggingFaceModels"
| "openImage"
| "saveImage"
| "openFile"
@ -524,6 +508,7 @@ export interface WebviewMessage {
| "searchFiles"
| "toggleApiConfigPin"
| "hasOpenedModeSelector"
| "lockApiConfigAcrossModes"
| "clearCloudAuthSkipModel"
| "cloudButtonClicked"
| "rooCloudSignIn"
@ -606,6 +591,7 @@ export interface WebviewMessage {
| "createSkill"
| "deleteSkill"
| "moveSkill"
| "updateSkillModes"
| "openSkillFile"
text?: string
editedMessageContent?: string
@ -642,9 +628,15 @@ export interface WebviewMessage {
payload?: WebViewMessagePayload
source?: "global" | "project" | "built-in"
skillName?: string // For skill operations (createSkill, deleteSkill, moveSkill, openSkillFile)
/** @deprecated Use skillModeSlugs instead */
skillMode?: string // For skill operations (current mode restriction)
/** @deprecated Use newSkillModeSlugs instead */
newSkillMode?: string // For moveSkill (target mode)
skillDescription?: string // For createSkill (skill description)
/** Mode slugs for skill operations. undefined/empty = any mode */
skillModeSlugs?: string[] // For skill operations (mode restrictions)
/** Target mode slugs for updateSkillModes */
newSkillModeSlugs?: string[] // For updateSkillModes (new mode restrictions)
requestId?: string
ids?: string[]
hasSystemPromptOverride?: boolean
@ -839,6 +831,12 @@ export interface ClineSayTool {
startLine?: number
}>
}>
batchDirs?: Array<{
path: string
recursive: boolean
isOutsideWorkspace?: boolean
key: string
}>
question?: string
imageData?: string // Base64 encoded image data for generated images
// Properties for runSlashCommand tool

957
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

@ -387,6 +387,7 @@ describe("History resume delegation - parent metadata transitions", () => {
it("reopenParentFromDelegation emits events in correct order: TaskDelegationCompleted → TaskDelegationResumed", async () => {
const emitSpy = vi.fn()
const updateTaskHistory = vi.fn().mockResolvedValue([])
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
@ -411,7 +412,7 @@ describe("History resume delegation - parent metadata transitions", () => {
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
}),
updateTaskHistory: vi.fn().mockResolvedValue([]),
updateTaskHistory,
} as unknown as ClineProvider
vi.mocked(readTaskMessages).mockResolvedValue([])
@ -433,6 +434,92 @@ describe("History resume delegation - parent metadata transitions", () => {
const resumedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationResumed)
expect(completedIdx).toBeGreaterThanOrEqual(0)
expect(resumedIdx).toBeGreaterThan(completedIdx)
// RPD-05: verify parent metadata persistence happens before TaskDelegationCompleted emit
const parentUpdateCallIdx = updateTaskHistory.mock.calls.findIndex((call) => {
const item = call[0] as { id?: string; status?: string } | undefined
return item?.id === "p3" && item.status === "active"
})
expect(parentUpdateCallIdx).toBeGreaterThanOrEqual(0)
const parentUpdateCallOrder = updateTaskHistory.mock.invocationCallOrder[parentUpdateCallIdx]
const completedEmitCallOrder = emitSpy.mock.invocationCallOrder[completedIdx]
expect(parentUpdateCallOrder).toBeLessThan(completedEmitCallOrder)
})
it("reopenParentFromDelegation continues when overwrite operations fail and still resumes/emits (RPD-06)", async () => {
const emitSpy = vi.fn()
const parentInstance = {
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
overwriteClineMessages: vi.fn().mockRejectedValue(new Error("ui overwrite failed")),
overwriteApiConversationHistory: vi.fn().mockRejectedValue(new Error("api overwrite failed")),
}
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
getTaskWithId: vi.fn().mockImplementation(async (id: string) => {
if (id === "parent-rpd06") {
return {
historyItem: {
id: "parent-rpd06",
status: "delegated",
awaitingChildId: "child-rpd06",
childIds: ["child-rpd06"],
ts: 800,
task: "Parent RPD-06",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
},
}
}
return {
historyItem: {
id: "child-rpd06",
status: "active",
ts: 801,
task: "Child RPD-06",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
},
}
}),
emit: emitSpy,
getCurrentTask: vi.fn(() => ({ taskId: "child-rpd06" })),
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
createTaskWithHistoryItem: vi.fn().mockResolvedValue(parentInstance),
updateTaskHistory: vi.fn().mockResolvedValue([]),
} as unknown as ClineProvider
vi.mocked(readTaskMessages).mockResolvedValue([])
vi.mocked(readApiMessages).mockResolvedValue([])
await expect(
(ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, {
parentTaskId: "parent-rpd06",
childTaskId: "child-rpd06",
completionResultSummary: "Subtask finished despite overwrite failures",
}),
).resolves.toBeUndefined()
expect(parentInstance.overwriteClineMessages).toHaveBeenCalledTimes(1)
expect(parentInstance.overwriteApiConversationHistory).toHaveBeenCalledTimes(1)
expect(parentInstance.resumeAfterDelegation).toHaveBeenCalledTimes(1)
expect(emitSpy).toHaveBeenCalledWith(
RooCodeEventName.TaskDelegationCompleted,
"parent-rpd06",
"child-rpd06",
"Subtask finished despite overwrite failures",
)
expect(emitSpy).toHaveBeenCalledWith(RooCodeEventName.TaskDelegationResumed, "parent-rpd06", "child-rpd06")
const completedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationCompleted)
const resumedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationResumed)
expect(completedIdx).toBeGreaterThanOrEqual(0)
expect(resumedIdx).toBeGreaterThan(completedIdx)
})
it("reopenParentFromDelegation does NOT emit TaskPaused or TaskUnpaused (new flow only)", async () => {
@ -480,6 +567,162 @@ describe("History resume delegation - parent metadata transitions", () => {
expect(eventNames).not.toContain(RooCodeEventName.TaskSpawned)
})
it("reopenParentFromDelegation skips child close when current task differs and still reopens parent (RPD-02)", async () => {
const parentInstance = {
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
}
const updateTaskHistory = vi.fn().mockResolvedValue([])
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
const createTaskWithHistoryItem = vi.fn().mockResolvedValue(parentInstance)
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
getTaskWithId: vi.fn().mockImplementation(async (id: string) => {
if (id === "parent-rpd02") {
return {
historyItem: {
id: "parent-rpd02",
status: "delegated",
awaitingChildId: "child-rpd02",
childIds: ["child-rpd02"],
ts: 600,
task: "Parent RPD-02",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
},
}
}
return {
historyItem: {
id: "child-rpd02",
status: "active",
ts: 601,
task: "Child RPD-02",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
},
}
}),
emit: vi.fn(),
getCurrentTask: vi.fn(() => ({ taskId: "different-open-task" })),
removeClineFromStack,
createTaskWithHistoryItem,
updateTaskHistory,
} as unknown as ClineProvider
vi.mocked(readTaskMessages).mockResolvedValue([])
vi.mocked(readApiMessages).mockResolvedValue([])
await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, {
parentTaskId: "parent-rpd02",
childTaskId: "child-rpd02",
completionResultSummary: "Child done without being current",
})
expect(removeClineFromStack).not.toHaveBeenCalled()
expect(updateTaskHistory).toHaveBeenCalledWith(
expect.objectContaining({
id: "child-rpd02",
status: "completed",
}),
)
expect(createTaskWithHistoryItem).toHaveBeenCalledWith(
expect.objectContaining({
id: "parent-rpd02",
status: "active",
completedByChildId: "child-rpd02",
}),
{ startTask: false },
)
expect(parentInstance.resumeAfterDelegation).toHaveBeenCalledTimes(1)
})
it("reopenParentFromDelegation logs child status persistence failure and continues reopen flow (RPD-04)", async () => {
const logSpy = vi.fn()
const emitSpy = vi.fn()
const parentInstance = {
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
}
const updateTaskHistory = vi.fn().mockImplementation(async (historyItem: { id?: string }) => {
if (historyItem.id === "child-rpd04") {
throw new Error("child status persist failed")
}
return []
})
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
getTaskWithId: vi.fn().mockImplementation(async (id: string) => {
if (id === "parent-rpd04") {
return {
historyItem: {
id: "parent-rpd04",
status: "delegated",
awaitingChildId: "child-rpd04",
childIds: ["child-rpd04"],
ts: 700,
task: "Parent RPD-04",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
},
}
}
return {
historyItem: {
id: "child-rpd04",
status: "active",
ts: 701,
task: "Child RPD-04",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
},
}
}),
emit: emitSpy,
log: logSpy,
getCurrentTask: vi.fn(() => ({ taskId: "child-rpd04" })),
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
createTaskWithHistoryItem: vi.fn().mockResolvedValue(parentInstance),
updateTaskHistory,
} as unknown as ClineProvider
vi.mocked(readTaskMessages).mockResolvedValue([])
vi.mocked(readApiMessages).mockResolvedValue([])
await expect(
(ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, {
parentTaskId: "parent-rpd04",
childTaskId: "child-rpd04",
completionResultSummary: "Child completion with persistence failure",
}),
).resolves.toBeUndefined()
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining(
"[reopenParentFromDelegation] Failed to persist child completed status for child-rpd04:",
),
)
expect(updateTaskHistory).toHaveBeenCalledWith(
expect.objectContaining({
id: "parent-rpd04",
status: "active",
completedByChildId: "child-rpd04",
}),
)
expect(parentInstance.resumeAfterDelegation).toHaveBeenCalledTimes(1)
expect(emitSpy).toHaveBeenCalledWith(RooCodeEventName.TaskDelegationResumed, "parent-rpd04", "child-rpd04")
})
it("handles empty history gracefully when injecting synthetic messages", async () => {
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },

View file

@ -9,9 +9,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
const providerEmit = vi.fn()
const parentTask = { taskId: "parent-1", emit: vi.fn() } as any
const childStart = vi.fn()
const updateTaskHistory = vi.fn()
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
const createTask = vi.fn().mockResolvedValue({ taskId: "child-1" })
const createTask = vi.fn().mockResolvedValue({ taskId: "child-1", start: childStart })
const handleModeSwitch = vi.fn().mockResolvedValue(undefined)
const getTaskWithId = vi.fn().mockImplementation(async (id: string) => {
if (id === "parent-1") {
@ -62,10 +63,11 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
// Invariant: parent closed before child creation
expect(removeClineFromStack).toHaveBeenCalledTimes(1)
// Child task is created with initialStatus: "active" to avoid race conditions
// Child task is created with startTask: false and initialStatus: "active"
expect(createTask).toHaveBeenCalledWith("Do something", undefined, parentTask, {
initialTodos: [],
initialStatus: "active",
startTask: false,
})
// Metadata persistence - parent gets "delegated" status (child status is set at creation via initialStatus)
@ -83,10 +85,61 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
}),
)
// child.start() must be called AFTER parent metadata is persisted
expect(childStart).toHaveBeenCalledTimes(1)
// Event emission (provider-level)
expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1")
// Mode switch
expect(handleModeSwitch).toHaveBeenCalledWith("code")
})
it("calls child.start() only after parent metadata is persisted (no race condition)", async () => {
const callOrder: string[] = []
const parentTask = { taskId: "parent-1", emit: vi.fn() } as any
const childStart = vi.fn(() => callOrder.push("child.start"))
const updateTaskHistory = vi.fn(async () => {
callOrder.push("updateTaskHistory")
})
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
const createTask = vi.fn(async () => {
callOrder.push("createTask")
return { taskId: "child-1", start: childStart }
})
const handleModeSwitch = vi.fn().mockResolvedValue(undefined)
const getTaskWithId = vi.fn().mockResolvedValue({
historyItem: {
id: "parent-1",
task: "Parent",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
childIds: [],
},
})
const provider = {
emit: vi.fn(),
getCurrentTask: vi.fn(() => parentTask),
removeClineFromStack,
createTask,
getTaskWithId,
updateTaskHistory,
handleModeSwitch,
log: vi.fn(),
} as unknown as ClineProvider
await (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, {
parentTaskId: "parent-1",
message: "Do something",
initialTodos: [],
mode: "code",
})
// Verify ordering: createTask → updateTaskHistory → child.start
expect(callOrder).toEqual(["createTask", "updateTaskHistory", "child.start"])
})
})

View file

@ -1,14 +1,14 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import type { ProviderSettings, ModelInfo } from "@roo-code/types"
import { isRetiredProvider, type ProviderSettings, type ModelInfo } from "@roo-code/types"
import { ApiStream } from "./transform/stream"
import {
AnthropicHandler,
AwsBedrockHandler,
CerebrasHandler,
AzureHandler,
OpenRouterHandler,
VertexHandler,
AnthropicVertexHandler,
@ -21,24 +21,16 @@ import {
MoonshotHandler,
MistralHandler,
VsCodeLmHandler,
UnboundHandler,
RequestyHandler,
FakeAIHandler,
XAIHandler,
GroqHandler,
HuggingFaceHandler,
ChutesHandler,
LiteLLMHandler,
QwenCodeHandler,
SambaNovaHandler,
IOIntelligenceHandler,
DoubaoHandler,
ZAiHandler,
FireworksHandler,
RooHandler,
FeatherlessHandler,
VercelAiGatewayHandler,
DeepInfraHandler,
MiniMaxHandler,
BasetenHandler,
} from "./providers"
@ -51,16 +43,13 @@ export interface SingleCompletionHandler {
export interface ApiHandlerCreateMessageMetadata {
/**
* Task ID used for tracking and provider-specific features:
* - DeepInfra: Used as prompt_cache_key for caching
* - Roo: Sent as X-Roo-Task-ID header
* - Requesty: Sent as trace_id
* - Unbound: Sent in unbound_metadata
*/
taskId: string
/**
* Current mode slug for provider-specific tracking:
* - Requesty: Sent in extra metadata
* - Unbound: Sent in unbound_metadata
*/
mode?: string
suppressPreviousResponseId?: boolean
@ -117,14 +106,31 @@ export interface ApiHandler {
* @returns A promise resolving to the token count
*/
countTokens(content: Array<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 {
const { apiProvider, ...options } = configuration
if (apiProvider && isRetiredProvider(apiProvider)) {
throw new Error(
`Sorry, this provider is no longer supported. We saw very few Roo users actually using it and we need to reduce the surface area of our codebase so we can keep shipping fast and serving our community well in this space. It was a really hard decision but it lets us focus on what matters most to you. It sucks, we know.\n\nPlease select a different provider in your API profile settings.`,
)
}
switch (apiProvider) {
case "anthropic":
return new AnthropicHandler(options)
case "azure":
return new AzureHandler(options)
case "openrouter":
return new OpenRouterHandler(options)
case "bedrock":
@ -147,8 +153,6 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
return new OpenAiNativeHandler(options)
case "deepseek":
return new DeepSeekHandler(options)
case "doubao":
return new DoubaoHandler(options)
case "qwen-code":
return new QwenCodeHandler(options)
case "moonshot":
@ -157,40 +161,24 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
return new VsCodeLmHandler(options)
case "mistral":
return new MistralHandler(options)
case "unbound":
return new UnboundHandler(options)
case "requesty":
return new RequestyHandler(options)
case "fake-ai":
return new FakeAIHandler(options)
case "xai":
return new XAIHandler(options)
case "groq":
return new GroqHandler(options)
case "deepinfra":
return new DeepInfraHandler(options)
case "huggingface":
return new HuggingFaceHandler(options)
case "chutes":
return new ChutesHandler(options)
case "litellm":
return new LiteLLMHandler(options)
case "cerebras":
return new CerebrasHandler(options)
case "sambanova":
return new SambaNovaHandler(options)
case "zai":
return new ZAiHandler(options)
case "fireworks":
return new FireworksHandler(options)
case "io-intelligence":
return new IOIntelligenceHandler(options)
case "roo":
// Never throw exceptions from provider constructors
// The provider-proxy server will handle authentication and return appropriate error codes
return new RooHandler(options)
case "featherless":
return new FeatherlessHandler(options)
case "vercel-ai-gateway":
return new VercelAiGatewayHandler(options)
case "minimax":

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,431 @@
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
const { mockStreamText, mockGenerateText, mockCreateAzure } = vi.hoisted(() => ({
mockStreamText: vi.fn(),
mockGenerateText: vi.fn(),
mockCreateAzure: vi.fn(() => {
// Return a provider function that supports Responses API model creation
const mockProvider = vi.fn(() => ({
modelId: "gpt-4o",
provider: "azure",
}))
;(mockProvider as any).responses = vi.fn(() => ({
modelId: "gpt-4o",
provider: "azure.responses",
}))
return mockProvider
}),
}))
vi.mock("ai", async (importOriginal) => {
const actual = await importOriginal<typeof import("ai")>()
return {
...actual,
streamText: mockStreamText,
generateText: mockGenerateText,
}
})
vi.mock("@ai-sdk/azure", () => ({
createAzure: mockCreateAzure,
}))
import type { Anthropic } from "@anthropic-ai/sdk"
import type { ApiHandlerOptions } from "../../../shared/api"
import { AzureHandler } from "../azure"
describe("AzureHandler", () => {
let handler: AzureHandler
let mockOptions: ApiHandlerOptions
beforeEach(() => {
vi.clearAllMocks()
mockOptions = {
azureApiKey: "test-api-key",
azureResourceName: "test-resource",
azureDeploymentName: "gpt-4o",
azureApiVersion: "2024-08-01-preview",
}
handler = new AzureHandler(mockOptions)
})
describe("constructor", () => {
it("should initialize with provided options", () => {
expect(handler).toBeInstanceOf(AzureHandler)
expect(handler.getModel().id).toBe(mockOptions.azureDeploymentName)
})
it("should use apiModelId if azureDeploymentName is not provided", () => {
const handlerWithModelId = new AzureHandler({
...mockOptions,
azureDeploymentName: undefined,
apiModelId: "gpt-35-turbo",
})
expect(handlerWithModelId.getModel().id).toBe("gpt-35-turbo")
})
it("should use empty string if neither azureDeploymentName nor apiModelId is provided", () => {
const handlerWithoutModel = new AzureHandler({
...mockOptions,
azureDeploymentName: undefined,
apiModelId: undefined,
})
expect(handlerWithoutModel.getModel().id).toBe("")
})
it("should use default API version if not provided", () => {
const handlerWithoutVersion = new AzureHandler({
...mockOptions,
azureApiVersion: undefined,
})
expect(handlerWithoutVersion).toBeInstanceOf(AzureHandler)
expect(mockCreateAzure).toHaveBeenLastCalledWith(
expect.objectContaining({ apiVersion: "2025-04-01-preview" }),
)
})
it("should normalize query-style API version input", () => {
new AzureHandler({
...mockOptions,
azureApiVersion: " ?api-version=2024-10-21&foo=bar ",
})
expect(mockCreateAzure).toHaveBeenLastCalledWith(
expect.objectContaining({
apiVersion: "2024-10-21",
}),
)
})
it("should use default API version when configured value is blank", () => {
new AzureHandler({
...mockOptions,
azureApiVersion: " ",
})
expect(mockCreateAzure).toHaveBeenLastCalledWith(
expect.objectContaining({ apiVersion: "2025-04-01-preview" }),
)
})
})
describe("getModel", () => {
it("should return model info with deployment name as ID", () => {
const model = handler.getModel()
expect(model.id).toBe(mockOptions.azureDeploymentName)
expect(model.info).toBeDefined()
})
it("should include model parameters from getModelParams", () => {
const model = handler.getModel()
expect(model).toHaveProperty("temperature")
expect(model).toHaveProperty("maxTokens")
})
})
describe("isAiSdkProvider", () => {
it("should return true", () => {
expect(handler.isAiSdkProvider()).toBe(true)
})
})
describe("createMessage", () => {
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text" as const,
text: "Hello!",
},
],
},
]
it("should use the Responses API language model", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 1, outputTokens: 1 }),
providerMetadata: Promise.resolve({}),
})
const stream = handler.createMessage(systemPrompt, messages)
for await (const _chunk of stream) {
// exhaust stream
}
expect(mockStreamText).toHaveBeenCalled()
const requestOptions = mockStreamText.mock.calls[0][0]
expect((requestOptions.model as any).provider).toBe("azure.responses")
})
it("should handle streaming responses", async () => {
// Mock the fullStream async generator
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
// Mock usage and providerMetadata promises
const mockUsage = Promise.resolve({
inputTokens: 10,
outputTokens: 5,
})
const mockProviderMetadata = Promise.resolve({
azure: {
promptCacheHitTokens: 2,
promptCacheMissTokens: 8,
},
})
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: mockUsage,
providerMetadata: mockProviderMetadata,
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks.length).toBeGreaterThan(0)
const textChunks = chunks.filter((chunk) => chunk.type === "text")
expect(textChunks).toHaveLength(1)
expect(textChunks[0].text).toBe("Test response")
})
it("should include usage information", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
const mockUsage = Promise.resolve({
inputTokens: 10,
outputTokens: 5,
})
const mockProviderMetadata = Promise.resolve({
azure: {
promptCacheHitTokens: 2,
promptCacheMissTokens: 8,
},
})
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: mockUsage,
providerMetadata: mockProviderMetadata,
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks.length).toBeGreaterThan(0)
expect(usageChunks[0].inputTokens).toBe(10)
expect(usageChunks[0].outputTokens).toBe(5)
})
it("should include cache metrics in usage information from providerMetadata", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
const mockUsage = Promise.resolve({
inputTokens: 10,
outputTokens: 5,
})
// Azure provides cache metrics via providerMetadata
const mockProviderMetadata = Promise.resolve({
azure: {
promptCacheHitTokens: 2,
promptCacheMissTokens: 8,
},
})
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: mockUsage,
providerMetadata: mockProviderMetadata,
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks.length).toBeGreaterThan(0)
expect(usageChunks[0].cacheWriteTokens).toBeUndefined()
expect(usageChunks[0].cacheReadTokens).toBe(2) // promptCacheHitTokens
})
it("should handle tool calls via tool-input-start/delta/end events", async () => {
async function* mockFullStream() {
yield { type: "tool-input-start", id: "tool-1", toolName: "test_tool" }
yield { type: "tool-input-delta", id: "tool-1", delta: '{"arg":' }
yield { type: "tool-input-delta", id: "tool-1", delta: '"value"}' }
yield { type: "tool-input-end", id: "tool-1" }
}
const mockUsage = Promise.resolve({
inputTokens: 10,
outputTokens: 5,
})
const mockProviderMetadata = Promise.resolve({})
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: mockUsage,
providerMetadata: mockProviderMetadata,
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const toolStartChunks = chunks.filter((chunk) => chunk.type === "tool_call_start")
expect(toolStartChunks).toHaveLength(1)
expect(toolStartChunks[0].id).toBe("tool-1")
expect(toolStartChunks[0].name).toBe("test_tool")
const toolDeltaChunks = chunks.filter((chunk) => chunk.type === "tool_call_delta")
expect(toolDeltaChunks).toHaveLength(2)
const toolEndChunks = chunks.filter((chunk) => chunk.type === "tool_call_end")
expect(toolEndChunks).toHaveLength(1)
})
it("should handle errors from AI SDK", async () => {
const mockError = new Error("API Error")
;(mockError as any).name = "AI_APICallError"
;(mockError as any).status = 500
async function* mockFullStream(): AsyncGenerator<any> {
yield { type: "text-delta", text: "" }
throw mockError
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({}),
providerMetadata: Promise.resolve({}),
})
const stream = handler.createMessage(systemPrompt, messages)
await expect(async () => {
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
}).rejects.toThrow("Azure AI Foundry")
})
})
describe("completePrompt", () => {
it("should complete a prompt using generateText", async () => {
mockGenerateText.mockResolvedValue({
text: "Test completion",
})
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Test completion")
expect(mockGenerateText).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "Test prompt",
}),
)
})
it("should use configured temperature", async () => {
const handlerWithTemp = new AzureHandler({
...mockOptions,
modelTemperature: 0.7,
})
mockGenerateText.mockResolvedValue({
text: "Test completion",
})
await handlerWithTemp.completePrompt("Test prompt")
expect(mockGenerateText).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.7,
}),
)
})
})
describe("tools", () => {
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [{ type: "text" as const, text: "Use a tool" }],
},
]
it("should pass tools to streamText", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Using tool" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
providerMetadata: Promise.resolve({}),
})
const tools = [
{
type: "function" as const,
function: {
name: "test_tool",
description: "A test tool",
parameters: {
type: "object",
properties: {
arg: { type: "string" },
},
required: ["arg"],
},
},
},
]
const stream = handler.createMessage(systemPrompt, messages, {
taskId: "test-task",
tools,
})
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(mockStreamText).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.any(Object),
}),
)
})
})
})

View file

@ -1,3 +1,5 @@
// npx vitest run src/api/providers/__tests__/baseten.spec.ts
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
mockStreamText: vi.fn(),
@ -13,83 +15,81 @@ vi.mock("ai", async (importOriginal) => {
}
})
vi.mock("@ai-sdk/cerebras", () => ({
createCerebras: vi.fn(() => {
// Return a function that returns a mock language model
vi.mock("@ai-sdk/baseten", () => ({
createBaseten: vi.fn(() => {
return vi.fn(() => ({
modelId: "llama-3.3-70b",
provider: "cerebras",
modelId: "zai-org/GLM-4.6",
provider: "baseten",
}))
}),
}))
import type { Anthropic } from "@anthropic-ai/sdk"
import { cerebrasDefaultModelId, cerebrasModels, type CerebrasModelId } from "@roo-code/types"
import { basetenDefaultModelId, basetenModels, type BasetenModelId } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../../shared/api"
import { CerebrasHandler } from "../cerebras"
import { BasetenHandler } from "../baseten"
describe("CerebrasHandler", () => {
let handler: CerebrasHandler
describe("BasetenHandler", () => {
let handler: BasetenHandler
let mockOptions: ApiHandlerOptions
beforeEach(() => {
mockOptions = {
cerebrasApiKey: "test-api-key",
apiModelId: "llama-3.3-70b" as CerebrasModelId,
basetenApiKey: "test-baseten-api-key",
apiModelId: "zai-org/GLM-4.6",
}
handler = new CerebrasHandler(mockOptions)
handler = new BasetenHandler(mockOptions)
vi.clearAllMocks()
})
describe("constructor", () => {
it("should initialize with provided options", () => {
expect(handler).toBeInstanceOf(CerebrasHandler)
expect(handler).toBeInstanceOf(BasetenHandler)
expect(handler.getModel().id).toBe(mockOptions.apiModelId)
})
it("should use default model ID if not provided", () => {
const handlerWithoutModel = new CerebrasHandler({
const handlerWithoutModel = new BasetenHandler({
...mockOptions,
apiModelId: undefined,
})
expect(handlerWithoutModel.getModel().id).toBe(cerebrasDefaultModelId)
expect(handlerWithoutModel.getModel().id).toBe(basetenDefaultModelId)
})
})
describe("getModel", () => {
it("should return model info for valid model ID", () => {
const model = handler.getModel()
expect(model.id).toBe(mockOptions.apiModelId)
expect(model.info).toBeDefined()
expect(model.info.maxTokens).toBe(16384)
expect(model.info.contextWindow).toBe(64000)
expect(model.info.supportsImages).toBe(false)
expect(model.info.supportsPromptCache).toBe(false)
it("should return default model when no model is specified", () => {
const handlerWithoutModel = new BasetenHandler({
basetenApiKey: "test-baseten-api-key",
})
const model = handlerWithoutModel.getModel()
expect(model.id).toBe(basetenDefaultModelId)
expect(model.info).toEqual(basetenModels[basetenDefaultModelId])
})
it("should return specified model when valid model is provided", () => {
const testModelId: BasetenModelId = "deepseek-ai/DeepSeek-R1"
const handlerWithModel = new BasetenHandler({
apiModelId: testModelId,
basetenApiKey: "test-baseten-api-key",
})
const model = handlerWithModel.getModel()
expect(model.id).toBe(testModelId)
expect(model.info).toEqual(basetenModels[testModelId])
})
it("should return provided model ID with default model info if model does not exist", () => {
const handlerWithInvalidModel = new CerebrasHandler({
const handlerWithInvalidModel = new BasetenHandler({
...mockOptions,
apiModelId: "invalid-model",
})
const model = handlerWithInvalidModel.getModel()
expect(model.id).toBe("invalid-model") // Returns provided ID
expect(model.info).toBeDefined()
// Should have the same base properties as default model
expect(model.info.contextWindow).toBe(cerebrasModels[cerebrasDefaultModelId].contextWindow)
})
it("should return default model if no model ID is provided", () => {
const handlerWithoutModel = new CerebrasHandler({
...mockOptions,
apiModelId: undefined,
})
const model = handlerWithoutModel.getModel()
expect(model.id).toBe(cerebrasDefaultModelId)
expect(model.id).toBe("invalid-model")
expect(model.info).toBeDefined()
expect(model.info).toBe(basetenModels[basetenDefaultModelId])
})
it("should include model parameters from getModelParams", () => {
@ -114,12 +114,10 @@ describe("CerebrasHandler", () => {
]
it("should handle streaming responses", async () => {
// Mock the fullStream async generator
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
yield { type: "text-delta", text: "Test response from Baseten" }
}
// Mock usage promise
const mockUsage = Promise.resolve({
inputTokens: 10,
outputTokens: 5,
@ -139,7 +137,7 @@ describe("CerebrasHandler", () => {
expect(chunks.length).toBeGreaterThan(0)
const textChunks = chunks.filter((chunk) => chunk.type === "text")
expect(textChunks).toHaveLength(1)
expect(textChunks[0].text).toBe("Test response")
expect(textChunks[0].text).toBe("Test response from Baseten")
})
it("should include usage information", async () => {
@ -149,7 +147,7 @@ describe("CerebrasHandler", () => {
const mockUsage = Promise.resolve({
inputTokens: 10,
outputTokens: 5,
outputTokens: 20,
})
mockStreamText.mockReturnValue({
@ -166,28 +164,73 @@ describe("CerebrasHandler", () => {
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks.length).toBeGreaterThan(0)
expect(usageChunks[0].inputTokens).toBe(10)
expect(usageChunks[0].outputTokens).toBe(5)
expect(usageChunks[0].outputTokens).toBe(20)
})
it("should handle reasoning content in streaming responses", async () => {
// Mock the fullStream async generator with reasoning content
it("should pass correct temperature (0.5 default) to streamText", async () => {
async function* mockFullStream() {
yield { type: "reasoning", text: "Let me think about this..." }
yield { type: "reasoning", text: " I'll analyze step by step." }
yield { type: "text-delta", text: "Test response" }
yield { type: "text-delta", text: "Test" }
}
const mockUsage = Promise.resolve({
inputTokens: 10,
outputTokens: 5,
details: {
reasoningTokens: 15,
},
})
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: mockUsage,
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
})
const handlerWithDefaultTemp = new BasetenHandler({
basetenApiKey: "test-key",
apiModelId: "zai-org/GLM-4.6",
})
const stream = handlerWithDefaultTemp.createMessage(systemPrompt, messages)
for await (const _ of stream) {
// consume stream
}
expect(mockStreamText).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.5,
}),
)
})
it("should use user-specified temperature over default", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
})
const handlerWithCustomTemp = new BasetenHandler({
basetenApiKey: "test-key",
apiModelId: "zai-org/GLM-4.6",
modelTemperature: 0.9,
})
const stream = handlerWithCustomTemp.createMessage(systemPrompt, messages)
for await (const _ of stream) {
// consume stream
}
expect(mockStreamText).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.9,
}),
)
})
it("should handle stream with multiple chunks", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Hello" }
yield { type: "text-delta", text: " world" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 5, outputTokens: 10 }),
})
const stream = handler.createMessage(systemPrompt, messages)
@ -196,133 +239,43 @@ describe("CerebrasHandler", () => {
chunks.push(chunk)
}
// Should have reasoning chunks
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
expect(reasoningChunks.length).toBe(2)
expect(reasoningChunks[0].text).toBe("Let me think about this...")
expect(reasoningChunks[1].text).toBe(" I'll analyze step by step.")
const textChunks = chunks.filter((c) => c.type === "text")
expect(textChunks[0]).toEqual({ type: "text", text: "Hello" })
expect(textChunks[1]).toEqual({ type: "text", text: " world" })
// Should also have text chunks
const textChunks = chunks.filter((chunk) => chunk.type === "text")
expect(textChunks.length).toBe(1)
expect(textChunks[0].text).toBe("Test response")
const usageChunks = chunks.filter((c) => c.type === "usage")
expect(usageChunks[0]).toMatchObject({ type: "usage", inputTokens: 5, outputTokens: 10 })
})
})
describe("completePrompt", () => {
it("should complete a prompt using generateText", async () => {
mockGenerateText.mockResolvedValue({
text: "Test completion",
text: "Test completion from Baseten",
})
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Test completion")
expect(result).toBe("Test completion from Baseten")
expect(mockGenerateText).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "Test prompt",
}),
)
})
})
describe("processUsageMetrics", () => {
it("should correctly process usage metrics", () => {
// We need to access the protected method, so we'll create a test subclass
class TestCerebrasHandler extends CerebrasHandler {
public testProcessUsageMetrics(usage: any) {
return this.processUsageMetrics(usage)
}
}
const testHandler = new TestCerebrasHandler(mockOptions)
const usage = {
inputTokens: 100,
outputTokens: 50,
details: {
cachedInputTokens: 20,
reasoningTokens: 30,
},
}
const result = testHandler.testProcessUsageMetrics(usage)
expect(result.type).toBe("usage")
expect(result.inputTokens).toBe(100)
expect(result.outputTokens).toBe(50)
expect(result.cacheReadTokens).toBe(20)
expect(result.reasoningTokens).toBe(30)
})
it("should handle missing cache metrics gracefully", () => {
class TestCerebrasHandler extends CerebrasHandler {
public testProcessUsageMetrics(usage: any) {
return this.processUsageMetrics(usage)
}
}
const testHandler = new TestCerebrasHandler(mockOptions)
const usage = {
inputTokens: 100,
outputTokens: 50,
}
const result = testHandler.testProcessUsageMetrics(usage)
expect(result.type).toBe("usage")
expect(result.inputTokens).toBe(100)
expect(result.outputTokens).toBe(50)
expect(result.cacheReadTokens).toBeUndefined()
expect(result.reasoningTokens).toBeUndefined()
})
})
describe("getMaxOutputTokens", () => {
it("should return maxTokens from model info", () => {
class TestCerebrasHandler extends CerebrasHandler {
public testGetMaxOutputTokens() {
return this.getMaxOutputTokens()
}
}
const testHandler = new TestCerebrasHandler(mockOptions)
const result = testHandler.testGetMaxOutputTokens()
// llama-3.3-70b maxTokens is 16384
expect(result).toBe(16384)
})
it("should use modelMaxTokens when provided", () => {
class TestCerebrasHandler extends CerebrasHandler {
public testGetMaxOutputTokens() {
return this.getMaxOutputTokens()
}
}
const customMaxTokens = 5000
const testHandler = new TestCerebrasHandler({
...mockOptions,
modelMaxTokens: customMaxTokens,
it("should use default temperature in completePrompt", async () => {
mockGenerateText.mockResolvedValue({
text: "Test completion",
})
const result = testHandler.testGetMaxOutputTokens()
expect(result).toBe(customMaxTokens)
})
await handler.completePrompt("Test prompt")
it("should fall back to modelInfo.maxTokens when modelMaxTokens is not provided", () => {
class TestCerebrasHandler extends CerebrasHandler {
public testGetMaxOutputTokens() {
return this.getMaxOutputTokens()
}
}
const testHandler = new TestCerebrasHandler(mockOptions)
const result = testHandler.testGetMaxOutputTokens()
// llama-3.3-70b has maxTokens of 16384
expect(result).toBe(16384)
expect(mockGenerateText).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.5,
}),
)
})
})
@ -402,9 +355,6 @@ describe("CerebrasHandler", () => {
})
it("should ignore tool-call events to prevent duplicate tools in UI", async () => {
// tool-call events are intentionally ignored because tool-input-start/delta/end
// already provide complete tool call information. Emitting tool-call would cause
// duplicate tools in the UI for AI SDK providers (e.g., DeepSeek, Moonshot, Cerebras).
async function* mockFullStream() {
yield {
type: "tool-call",
@ -424,32 +374,73 @@ describe("CerebrasHandler", () => {
usage: mockUsage,
})
const stream = handler.createMessage(systemPrompt, messages, {
taskId: "test-task",
tools: [
{
type: "function",
function: {
name: "read_file",
description: "Read a file",
parameters: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
},
},
},
],
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// tool-call events are ignored, so no tool_call chunks should be emitted
const toolCallChunks = chunks.filter((c) => c.type === "tool_call")
const toolCallChunks = chunks.filter(
(c) => c.type === "tool_call_start" || c.type === "tool_call_delta" || c.type === "tool_call_end",
)
expect(toolCallChunks.length).toBe(0)
})
})
describe("error handling", () => {
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [{ type: "text" as const, text: "Hello!" }],
},
]
it("should handle AI SDK errors with handleAiSdkError", async () => {
// eslint-disable-next-line require-yield
async function* mockFullStream(): AsyncGenerator<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 +0,0 @@
// npx vitest run api/providers/__tests__/chutes.spec.ts
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { chutesDefaultModelId, chutesDefaultModelInfo, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types"
import { ChutesHandler } from "../chutes"
// Create mock functions
const mockCreate = vi.fn()
const mockFetchModel = vi.fn()
// Mock OpenAI module
vi.mock("openai", () => ({
default: vi.fn(() => ({
chat: {
completions: {
create: mockCreate,
},
},
})),
}))
describe("ChutesHandler", () => {
let handler: ChutesHandler
beforeEach(() => {
vi.clearAllMocks()
// Set up default mock implementation
mockCreate.mockImplementation(async () => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: { content: "Test response" },
index: 0,
},
],
usage: null,
}
yield {
choices: [
{
delta: {},
index: 0,
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
},
}
},
}))
handler = new ChutesHandler({ chutesApiKey: "test-key" })
// Mock fetchModel to return default model
mockFetchModel.mockResolvedValue({
id: chutesDefaultModelId,
info: chutesDefaultModelInfo,
})
handler.fetchModel = mockFetchModel
})
afterEach(() => {
vi.restoreAllMocks()
})
it("should use the correct Chutes base URL", () => {
new ChutesHandler({ chutesApiKey: "test-chutes-api-key" })
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://llm.chutes.ai/v1" }))
})
it("should use the provided API key", () => {
const chutesApiKey = "test-chutes-api-key"
new ChutesHandler({ chutesApiKey })
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: chutesApiKey }))
})
it("should handle DeepSeek R1 reasoning format", async () => {
// Override the mock for this specific test
mockCreate.mockImplementationOnce(async () => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: { content: "<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" }]
mockFetchModel.mockResolvedValueOnce({
id: "deepseek-ai/DeepSeek-R1-0528",
info: { maxTokens: 1024, temperature: 0.7 },
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks).toEqual([
{ type: "reasoning", text: "Thinking..." },
{ type: "text", text: "Hello" },
{ type: "usage", inputTokens: 10, outputTokens: 5 },
])
})
it("should handle non-DeepSeek models", async () => {
// Use default mock implementation which returns text content
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }]
mockFetchModel.mockResolvedValueOnce({
id: "some-other-model",
info: { maxTokens: 1024, temperature: 0.7 },
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks).toEqual([
{ type: "text", text: "Test response" },
{ type: "usage", inputTokens: 10, outputTokens: 5 },
])
})
it("should return default model when no model is specified", async () => {
const model = await handler.fetchModel()
expect(model.id).toBe(chutesDefaultModelId)
expect(model.info).toEqual(expect.objectContaining(chutesDefaultModelInfo))
})
it("should return specified model when valid model is provided", async () => {
const testModelId = "deepseek-ai/DeepSeek-R1"
const handlerWithModel = new ChutesHandler({
apiModelId: testModelId,
chutesApiKey: "test-chutes-api-key",
})
// Mock fetchModel for this handler to return the test model from dynamic fetch
handlerWithModel.fetchModel = vi.fn().mockResolvedValue({
id: testModelId,
info: { maxTokens: 32768, contextWindow: 163840, supportsImages: false, supportsPromptCache: false },
})
const model = await handlerWithModel.fetchModel()
expect(model.id).toBe(testModelId)
})
it("completePrompt method should return text from Chutes API", async () => {
const expectedResponse = "This is a test response from Chutes"
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] })
const result = await handler.completePrompt("test prompt")
expect(result).toBe(expectedResponse)
})
it("should handle errors in completePrompt", async () => {
const errorMessage = "Chutes API error"
mockCreate.mockRejectedValueOnce(new Error(errorMessage))
await expect(handler.completePrompt("test prompt")).rejects.toThrow(`Chutes completion error: ${errorMessage}`)
})
it("createMessage should yield text content from stream", async () => {
const testContent = "This is test content from Chutes stream"
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: { choices: [{ delta: { content: testContent } }] },
})
.mockResolvedValueOnce({ done: true }),
}),
}
})
const stream = handler.createMessage("system prompt", [])
const firstChunk = await stream.next()
expect(firstChunk.done).toBe(false)
expect(firstChunk.value).toEqual({ type: "text", text: testContent })
})
it("createMessage should yield usage data from stream", async () => {
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } },
})
.mockResolvedValueOnce({ done: true }),
}),
}
})
const stream = handler.createMessage("system prompt", [])
const firstChunk = await stream.next()
expect(firstChunk.done).toBe(false)
expect(firstChunk.value).toEqual({ type: "usage", inputTokens: 10, outputTokens: 20 })
})
it("createMessage should yield tool_call_partial from stream", async () => {
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: {
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: "call_123",
function: { name: "test_tool", arguments: '{"arg":"value"}' },
},
],
},
},
],
},
})
.mockResolvedValueOnce({ done: true }),
}),
}
})
const stream = handler.createMessage("system prompt", [])
const firstChunk = await stream.next()
expect(firstChunk.done).toBe(false)
expect(firstChunk.value).toEqual({
type: "tool_call_partial",
index: 0,
id: "call_123",
name: "test_tool",
arguments: '{"arg":"value"}',
})
})
it("createMessage should pass tools and tool_choice to API", async () => {
const tools = [
{
type: "function" as const,
function: {
name: "test_tool",
description: "A test tool",
parameters: { type: "object", properties: {} },
},
},
]
const tool_choice = "auto" as const
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vi.fn().mockResolvedValueOnce({ done: true }),
}),
}
})
const stream = handler.createMessage("system prompt", [], { tools, tool_choice, taskId: "test-task-id" })
// Consume stream
for await (const _ of stream) {
// noop
}
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
tools,
tool_choice,
}),
)
})
it("should apply DeepSeek default temperature for R1 models", () => {
const testModelId = "deepseek-ai/DeepSeek-R1"
const handlerWithModel = new ChutesHandler({
apiModelId: testModelId,
chutesApiKey: "test-chutes-api-key",
})
const model = handlerWithModel.getModel()
expect(model.info.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE)
})
it("should use default temperature for non-DeepSeek models", () => {
const testModelId = "unsloth/Llama-3.3-70B-Instruct"
const handlerWithModel = new ChutesHandler({
apiModelId: testModelId,
chutesApiKey: "test-chutes-api-key",
})
// Note: getModel() returns fallback default without calling fetchModel
// Since we haven't called fetchModel, it returns the default chutesDefaultModelId
// which is DeepSeek-R1-0528, therefore temperature will be DEEP_SEEK_DEFAULT_TEMPERATURE
const model = handlerWithModel.getModel()
// The default model is DeepSeek-R1, so it returns DEEP_SEEK_DEFAULT_TEMPERATURE
expect(model.info.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE)
})
})

View file

@ -1,386 +0,0 @@
// npx vitest api/providers/__tests__/deepinfra.spec.ts
import { deepInfraDefaultModelId, deepInfraDefaultModelInfo } from "@roo-code/types"
const mockCreate = vitest.fn()
const mockWithResponse = vitest.fn()
vitest.mock("openai", () => {
const mockConstructor = vitest.fn()
return {
__esModule: true,
default: mockConstructor.mockImplementation(() => ({
chat: {
completions: {
create: mockCreate.mockImplementation(() => ({
withResponse: mockWithResponse,
})),
},
},
})),
}
})
vitest.mock("../fetchers/modelCache", () => ({
getModels: vitest.fn().mockResolvedValue({
[deepInfraDefaultModelId]: deepInfraDefaultModelInfo,
}),
getModelsFromCache: vitest.fn().mockReturnValue(undefined),
}))
import OpenAI from "openai"
import { DeepInfraHandler } from "../deepinfra"
describe("DeepInfraHandler", () => {
let handler: DeepInfraHandler
beforeEach(() => {
vi.clearAllMocks()
mockCreate.mockClear()
mockWithResponse.mockClear()
handler = new DeepInfraHandler({})
})
it("should use the correct DeepInfra base URL", () => {
expect(OpenAI).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://api.deepinfra.com/v1/openai",
}),
)
})
it("should use the provided API key", () => {
vi.clearAllMocks()
const deepInfraApiKey = "test-api-key"
new DeepInfraHandler({ deepInfraApiKey })
expect(OpenAI).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: deepInfraApiKey,
}),
)
})
it("should return default model when no model is specified", () => {
const model = handler.getModel()
expect(model.id).toBe(deepInfraDefaultModelId)
expect(model.info).toEqual(deepInfraDefaultModelInfo)
})
it("createMessage should yield text content from stream", async () => {
const testContent = "This is test content"
mockWithResponse.mockResolvedValueOnce({
data: {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: {
choices: [{ delta: { content: testContent } }],
},
})
.mockResolvedValueOnce({ done: true }),
}),
},
})
const stream = handler.createMessage("system prompt", [])
const firstChunk = await stream.next()
expect(firstChunk.done).toBe(false)
expect(firstChunk.value).toEqual({
type: "text",
text: testContent,
})
})
it("createMessage should yield reasoning content from stream", async () => {
const testReasoning = "Test reasoning content"
mockWithResponse.mockResolvedValueOnce({
data: {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: {
choices: [{ delta: { reasoning_content: testReasoning } }],
},
})
.mockResolvedValueOnce({ done: true }),
}),
},
})
const stream = handler.createMessage("system prompt", [])
const firstChunk = await stream.next()
expect(firstChunk.done).toBe(false)
expect(firstChunk.value).toEqual({
type: "reasoning",
text: testReasoning,
})
})
it("createMessage should yield usage data from stream", async () => {
mockWithResponse.mockResolvedValueOnce({
data: {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: {
choices: [{ delta: {} }],
usage: {
prompt_tokens: 10,
completion_tokens: 20,
prompt_tokens_details: {
cache_write_tokens: 15,
cached_tokens: 5,
},
},
},
})
.mockResolvedValueOnce({ done: true }),
}),
},
})
const stream = handler.createMessage("system prompt", [])
const firstChunk = await stream.next()
expect(firstChunk.done).toBe(false)
expect(firstChunk.value).toEqual({
type: "usage",
inputTokens: 10,
outputTokens: 20,
cacheWriteTokens: 15,
cacheReadTokens: 5,
totalCost: expect.any(Number),
})
})
describe("Native Tool Calling", () => {
const testTools = [
{
type: "function" as const,
function: {
name: "test_tool",
description: "A test tool",
parameters: {
type: "object",
properties: {
arg1: { type: "string", description: "First argument" },
},
required: ["arg1"],
},
},
},
]
it("should include tools in request when model supports native tools and tools are provided", async () => {
mockWithResponse.mockResolvedValueOnce({
data: {
[Symbol.asyncIterator]: () => ({
async next() {
return { done: true }
},
}),
},
})
const messageGenerator = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
})
await messageGenerator.next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.arrayContaining([
expect.objectContaining({
type: "function",
function: expect.objectContaining({
name: "test_tool",
}),
}),
]),
}),
)
// parallel_tool_calls should be true by default when not explicitly set
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs).toHaveProperty("parallel_tool_calls", true)
})
it("should include tool_choice when provided", async () => {
mockWithResponse.mockResolvedValueOnce({
data: {
[Symbol.asyncIterator]: () => ({
async next() {
return { done: true }
},
}),
},
})
const messageGenerator = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
tool_choice: "auto",
})
await messageGenerator.next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
tool_choice: "auto",
}),
)
})
it("should always include tools and tool_choice in request (tools are always present after PR #10841)", async () => {
mockWithResponse.mockResolvedValueOnce({
data: {
[Symbol.asyncIterator]: () => ({
async next() {
return { done: true }
},
}),
},
})
const messageGenerator = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
})
await messageGenerator.next()
const callArgs = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0]
// Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
expect(callArgs).toHaveProperty("tools")
expect(callArgs).toHaveProperty("tool_choice")
// parallel_tool_calls should be true by default when not explicitly set
expect(callArgs).toHaveProperty("parallel_tool_calls", true)
})
it("should yield tool_call_partial chunks during streaming", async () => {
mockWithResponse.mockResolvedValueOnce({
data: {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: {
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: "call_123",
function: {
name: "test_tool",
arguments: '{"arg1":',
},
},
],
},
},
],
},
})
.mockResolvedValueOnce({
done: false,
value: {
choices: [
{
delta: {
tool_calls: [
{
index: 0,
function: {
arguments: '"value"}',
},
},
],
},
},
],
},
})
.mockResolvedValueOnce({ done: true }),
}),
},
})
const stream = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
})
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks).toContainEqual({
type: "tool_call_partial",
index: 0,
id: "call_123",
name: "test_tool",
arguments: '{"arg1":',
})
expect(chunks).toContainEqual({
type: "tool_call_partial",
index: 0,
id: undefined,
name: undefined,
arguments: '"value"}',
})
})
it("should set parallel_tool_calls based on metadata", async () => {
mockWithResponse.mockResolvedValueOnce({
data: {
[Symbol.asyncIterator]: () => ({
async next() {
return { done: true }
},
}),
},
})
const messageGenerator = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
parallelToolCalls: true,
})
await messageGenerator.next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
parallel_tool_calls: true,
}),
)
})
})
describe("completePrompt", () => {
it("should return text from API", async () => {
const expectedResponse = "This is a test response"
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: expectedResponse } }],
})
const result = await handler.completePrompt("test prompt")
expect(result).toBe(expectedResponse)
})
})
})

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 +0,0 @@
// npx vitest run api/providers/__tests__/featherless.spec.ts
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { type FeatherlessModelId, featherlessDefaultModelId, featherlessModels } from "@roo-code/types"
import { FeatherlessHandler } from "../featherless"
// Create mock functions
const mockCreate = vi.fn()
// Mock OpenAI module
vi.mock("openai", () => ({
default: vi.fn(() => ({
chat: {
completions: {
create: mockCreate,
},
},
})),
}))
describe("FeatherlessHandler", () => {
let handler: FeatherlessHandler
beforeEach(() => {
vi.clearAllMocks()
// Set up default mock implementation
mockCreate.mockImplementation(async () => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: { content: "Test response" },
index: 0,
},
],
usage: null,
}
yield {
choices: [
{
delta: {},
index: 0,
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
},
}
},
}))
handler = new FeatherlessHandler({ featherlessApiKey: "test-key" })
})
afterEach(() => {
vi.restoreAllMocks()
})
it("should use the correct Featherless base URL", () => {
new FeatherlessHandler({ featherlessApiKey: "test-featherless-api-key" })
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://api.featherless.ai/v1" }))
})
it("should use the provided API key", () => {
const featherlessApiKey = "test-featherless-api-key"
new FeatherlessHandler({ featherlessApiKey })
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: featherlessApiKey }))
})
it("should handle reasoning format from models that use <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 }),
}),
}
})
const stream = handler.createMessage("system prompt", [])
const firstChunk = await stream.next()
expect(firstChunk.done).toBe(false)
expect(firstChunk.value).toEqual({ type: "text", text: testContent })
})
it("createMessage should yield usage data from stream", async () => {
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } },
})
.mockResolvedValueOnce({ done: true }),
}),
}
})
const stream = handler.createMessage("system prompt", [])
const firstChunk = await stream.next()
expect(firstChunk.done).toBe(false)
expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 })
})
it("createMessage should pass correct parameters to Featherless client", async () => {
const modelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct"
// Clear previous mocks and set up new implementation
mockCreate.mockClear()
mockCreate.mockImplementationOnce(async () => ({
[Symbol.asyncIterator]: async function* () {
// Empty stream for this test
},
}))
const handlerWithModel = new FeatherlessHandler({
apiModelId: modelId,
featherlessApiKey: "test-featherless-api-key",
})
const systemPrompt = "Test system prompt for Featherless"
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for Featherless" }]
const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages)
await messageGenerator.next()
expect(mockCreate).toHaveBeenCalled()
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs.model).toBe(modelId)
})
it("should use default temperature for non-DeepSeek models", () => {
const testModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct"
const handlerWithModel = new FeatherlessHandler({
apiModelId: testModelId,
featherlessApiKey: "test-featherless-api-key",
})
const model = handlerWithModel.getModel()
expect(model.info.temperature).toBe(0.5)
})
})

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

@ -1,5 +1,7 @@
// npx vitest run src/api/providers/__tests__/gemini.spec.ts
import { NoOutputGeneratedError } from "ai"
const mockCaptureException = vitest.fn()
vitest.mock("@roo-code/telemetry", () => ({
@ -10,6 +12,30 @@ vitest.mock("@roo-code/telemetry", () => ({
},
}))
// Mock the AI SDK functions
const mockStreamText = vitest.fn()
const mockGenerateText = vitest.fn()
vitest.mock("ai", async (importOriginal) => {
const original = await importOriginal<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 +51,16 @@ describe("GeminiHandler", () => {
beforeEach(() => {
// Reset mocks
mockCaptureException.mockClear()
// Create mock functions
const mockGenerateContentStream = vitest.fn()
const mockGenerateContent = vitest.fn()
const mockGetGenerativeModel = vitest.fn()
mockStreamText.mockClear()
mockGenerateText.mockClear()
mockCreateGoogleGenerativeAI.mockClear()
mockCreateGoogleGenerativeAI.mockReturnValue(() => ({}))
handler = new GeminiHandler({
apiKey: "test-key",
apiModelId: GEMINI_MODEL_NAME,
geminiApiKey: "test-key",
})
// Replace the client with our mock
handler["client"] = {
models: {
generateContentStream: mockGenerateContentStream,
generateContent: mockGenerateContent,
getGenerativeModel: mockGetGenerativeModel,
},
} as any
})
describe("constructor", () => {
@ -52,6 +68,37 @@ describe("GeminiHandler", () => {
expect(handler["options"].geminiApiKey).toBe("test-key")
expect(handler["options"].apiModelId).toBe(GEMINI_MODEL_NAME)
})
it("should pass undefined baseURL when googleGeminiBaseUrl is empty string", () => {
mockCreateGoogleGenerativeAI.mockClear()
new GeminiHandler({
apiModelId: GEMINI_MODEL_NAME,
geminiApiKey: "test-key",
googleGeminiBaseUrl: "",
})
expect(mockCreateGoogleGenerativeAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: undefined }))
})
it("should pass undefined baseURL when googleGeminiBaseUrl is not provided", () => {
mockCreateGoogleGenerativeAI.mockClear()
new GeminiHandler({
apiModelId: GEMINI_MODEL_NAME,
geminiApiKey: "test-key",
})
expect(mockCreateGoogleGenerativeAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: undefined }))
})
it("should pass custom baseURL when googleGeminiBaseUrl is a valid URL", () => {
mockCreateGoogleGenerativeAI.mockClear()
new GeminiHandler({
apiModelId: GEMINI_MODEL_NAME,
geminiApiKey: "test-key",
googleGeminiBaseUrl: "https://custom-gemini.example.com/v1beta",
})
expect(mockCreateGoogleGenerativeAI).toHaveBeenCalledWith(
expect.objectContaining({ baseURL: "https://custom-gemini.example.com/v1beta" }),
)
})
})
describe("createMessage", () => {
@ -69,13 +116,17 @@ describe("GeminiHandler", () => {
const systemPrompt = "You are a helpful assistant"
it("should handle text messages correctly", async () => {
// Setup the mock implementation to return an async generator
;(handler["client"].models.generateContentStream as any).mockResolvedValue({
[Symbol.asyncIterator]: async function* () {
yield { text: "Hello" }
yield { text: " world!" }
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } }
},
// Setup the mock implementation to return an async generator for fullStream
// AI SDK text-delta events have a 'text' property (processAiSdkStreamPart casts to this)
const mockFullStream = (async function* () {
yield { type: "text-delta", text: "Hello" }
yield { type: "text-delta", text: " world!" }
})()
mockStreamText.mockReturnValue({
fullStream: mockFullStream,
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
providerMetadata: Promise.resolve({}),
})
const stream = handler.createMessage(systemPrompt, mockMessages)
@ -91,21 +142,105 @@ describe("GeminiHandler", () => {
expect(chunks[1]).toEqual({ type: "text", text: " world!" })
expect(chunks[2]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5 })
// Verify the call to generateContentStream
expect(handler["client"].models.generateContentStream).toHaveBeenCalledWith(
// Verify the call to streamText
expect(mockStreamText).toHaveBeenCalledWith(
expect.objectContaining({
model: GEMINI_MODEL_NAME,
config: expect.objectContaining({
temperature: 1,
systemInstruction: systemPrompt,
}),
system: systemPrompt,
temperature: 1,
}),
)
})
it("should yield informative message when stream produces no text content", async () => {
// Stream with only reasoning (no text-delta) simulates thinking-only response
const mockFullStream = (async function* () {
yield { type: "reasoning-delta", id: "1", text: "thinking..." }
})()
mockStreamText.mockReturnValue({
fullStream: mockFullStream,
usage: Promise.resolve({ inputTokens: 10, outputTokens: 0 }),
providerMetadata: Promise.resolve({}),
})
const stream = handler.createMessage(systemPrompt, mockMessages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Should have: reasoning chunk, empty-stream informative message, usage
const textChunks = chunks.filter((c) => c.type === "text")
expect(textChunks).toHaveLength(1)
expect(textChunks[0]).toEqual({
type: "text",
text: "Model returned an empty response. This may be caused by an unsupported thinking configuration or content filtering.",
})
})
it("should suppress NoOutputGeneratedError when no text content was yielded", async () => {
// Empty stream - nothing yielded at all
const mockFullStream = (async function* () {
// empty stream
})()
mockStreamText.mockReturnValue({
fullStream: mockFullStream,
usage: Promise.reject(new NoOutputGeneratedError({ message: "No output generated." })),
providerMetadata: Promise.resolve({}),
})
const stream = handler.createMessage(systemPrompt, mockMessages)
const chunks = []
// Should NOT throw - the error is suppressed
for await (const chunk of stream) {
chunks.push(chunk)
}
// Should have the informative empty-stream message only (no usage since it errored)
const textChunks = chunks.filter((c) => c.type === "text")
expect(textChunks).toHaveLength(1)
expect(textChunks[0]).toMatchObject({
type: "text",
text: expect.stringContaining("empty response"),
})
})
it("should re-throw NoOutputGeneratedError when text content was yielded", async () => {
// Stream yields text content but usage still throws NoOutputGeneratedError (unexpected)
const mockFullStream = (async function* () {
yield { type: "text-delta", text: "Hello" }
})()
mockStreamText.mockReturnValue({
fullStream: mockFullStream,
usage: Promise.reject(new NoOutputGeneratedError({ message: "No output generated." })),
providerMetadata: Promise.resolve({}),
})
const stream = handler.createMessage(systemPrompt, mockMessages)
await expect(async () => {
for await (const _chunk of stream) {
// consume stream
}
}).rejects.toThrow()
})
it("should handle API errors", async () => {
const mockError = new Error("Gemini API error")
;(handler["client"].models.generateContentStream as any).mockRejectedValue(mockError)
// eslint-disable-next-line require-yield
const mockFullStream = (async function* () {
throw mockError
})()
mockStreamText.mockReturnValue({
fullStream: mockFullStream,
usage: Promise.resolve({}),
providerMetadata: Promise.resolve({}),
})
const stream = handler.createMessage(systemPrompt, mockMessages)
@ -119,28 +254,26 @@ describe("GeminiHandler", () => {
describe("completePrompt", () => {
it("should complete prompt successfully", async () => {
// Mock the response with text property
;(handler["client"].models.generateContent as any).mockResolvedValue({
mockGenerateText.mockResolvedValue({
text: "Test response",
providerMetadata: {},
})
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Test response")
// Verify the call to generateContent
expect(handler["client"].models.generateContent).toHaveBeenCalledWith({
model: GEMINI_MODEL_NAME,
contents: [{ role: "user", parts: [{ text: "Test prompt" }] }],
config: {
httpOptions: undefined,
// Verify the call to generateText
expect(mockGenerateText).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "Test prompt",
temperature: 1,
},
})
}),
)
})
it("should handle API errors", async () => {
const mockError = new Error("Gemini API error")
;(handler["client"].models.generateContent as any).mockRejectedValue(mockError)
mockGenerateText.mockRejectedValue(mockError)
await expect(handler.completePrompt("Test prompt")).rejects.toThrow(
t("common:errors.gemini.generate_complete_prompt", { error: "Gemini API error" }),
@ -148,9 +281,9 @@ describe("GeminiHandler", () => {
})
it("should handle empty response", async () => {
// Mock the response with empty text
;(handler["client"].models.generateContent as any).mockResolvedValue({
mockGenerateText.mockResolvedValue({
text: "",
providerMetadata: {},
})
const result = await handler.completePrompt("Test prompt")
@ -255,7 +388,16 @@ describe("GeminiHandler", () => {
it("should capture telemetry on createMessage error", async () => {
const mockError = new Error("Gemini API error")
;(handler["client"].models.generateContentStream as any).mockRejectedValue(mockError)
// eslint-disable-next-line require-yield
const mockFullStream = (async function* () {
throw mockError
})()
mockStreamText.mockReturnValue({
fullStream: mockFullStream,
usage: Promise.resolve({}),
providerMetadata: Promise.resolve({}),
})
const stream = handler.createMessage(systemPrompt, mockMessages)
@ -283,7 +425,7 @@ describe("GeminiHandler", () => {
it("should capture telemetry on completePrompt error", async () => {
const mockError = new Error("Gemini completion error")
;(handler["client"].models.generateContent as any).mockRejectedValue(mockError)
mockGenerateText.mockRejectedValue(mockError)
await expect(handler.completePrompt("Test prompt")).rejects.toThrow()
@ -305,7 +447,16 @@ describe("GeminiHandler", () => {
it("should still throw the error after capturing telemetry", async () => {
const mockError = new Error("Gemini API error")
;(handler["client"].models.generateContentStream as any).mockRejectedValue(mockError)
// eslint-disable-next-line require-yield
const mockFullStream = (async function* () {
throw mockError
})()
mockStreamText.mockReturnValue({
fullStream: mockFullStream,
usage: Promise.resolve({}),
providerMetadata: Promise.resolve({}),
})
const stream = handler.createMessage(systemPrompt, mockMessages)

View file

@ -1,578 +0,0 @@
// npx vitest run src/api/providers/__tests__/groq.spec.ts
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
mockStreamText: vi.fn(),
mockGenerateText: vi.fn(),
}))
vi.mock("ai", async (importOriginal) => {
const actual = await importOriginal<typeof import("ai")>()
return {
...actual,
streamText: mockStreamText,
generateText: mockGenerateText,
}
})
vi.mock("@ai-sdk/groq", () => ({
createGroq: vi.fn(() => {
// Return a function that returns a mock language model
return vi.fn(() => ({
modelId: "moonshotai/kimi-k2-instruct-0905",
provider: "groq",
}))
}),
}))
import type { Anthropic } from "@anthropic-ai/sdk"
import { groqDefaultModelId, groqModels, type GroqModelId } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../../shared/api"
import { GroqHandler } from "../groq"
describe("GroqHandler", () => {
let handler: GroqHandler
let mockOptions: ApiHandlerOptions
beforeEach(() => {
mockOptions = {
groqApiKey: "test-groq-api-key",
apiModelId: "moonshotai/kimi-k2-instruct-0905",
}
handler = new GroqHandler(mockOptions)
vi.clearAllMocks()
})
describe("constructor", () => {
it("should initialize with provided options", () => {
expect(handler).toBeInstanceOf(GroqHandler)
expect(handler.getModel().id).toBe(mockOptions.apiModelId)
})
it("should use default model ID if not provided", () => {
const handlerWithoutModel = new GroqHandler({
...mockOptions,
apiModelId: undefined,
})
expect(handlerWithoutModel.getModel().id).toBe(groqDefaultModelId)
})
})
describe("getModel", () => {
it("should return default model when no model is specified", () => {
const handlerWithoutModel = new GroqHandler({
groqApiKey: "test-groq-api-key",
})
const model = handlerWithoutModel.getModel()
expect(model.id).toBe(groqDefaultModelId)
expect(model.info).toEqual(groqModels[groqDefaultModelId])
})
it("should return specified model when valid model is provided", () => {
const testModelId: GroqModelId = "llama-3.3-70b-versatile"
const handlerWithModel = new GroqHandler({
apiModelId: testModelId,
groqApiKey: "test-groq-api-key",
})
const model = handlerWithModel.getModel()
expect(model.id).toBe(testModelId)
expect(model.info).toEqual(groqModels[testModelId])
})
it("should return model info for llama-3.1-8b-instant", () => {
const handlerWithLlama = new GroqHandler({
...mockOptions,
apiModelId: "llama-3.1-8b-instant",
})
const model = handlerWithLlama.getModel()
expect(model.id).toBe("llama-3.1-8b-instant")
expect(model.info).toBeDefined()
expect(model.info.maxTokens).toBe(8192)
expect(model.info.contextWindow).toBe(131072)
expect(model.info.supportsImages).toBe(false)
expect(model.info.supportsPromptCache).toBe(false)
})
it("should return model info for kimi-k2 which supports prompt cache", () => {
const handlerWithKimi = new GroqHandler({
...mockOptions,
apiModelId: "moonshotai/kimi-k2-instruct-0905",
})
const model = handlerWithKimi.getModel()
expect(model.id).toBe("moonshotai/kimi-k2-instruct-0905")
expect(model.info).toBeDefined()
expect(model.info.maxTokens).toBe(16384)
expect(model.info.contextWindow).toBe(262144)
expect(model.info.supportsPromptCache).toBe(true)
})
it("should return provided model ID with default model info if model does not exist", () => {
const handlerWithInvalidModel = new GroqHandler({
...mockOptions,
apiModelId: "invalid-model",
})
const model = handlerWithInvalidModel.getModel()
expect(model.id).toBe("invalid-model")
expect(model.info).toBeDefined()
// Should use default model info
expect(model.info).toBe(groqModels[groqDefaultModelId])
})
it("should include model parameters from getModelParams", () => {
const model = handler.getModel()
expect(model).toHaveProperty("temperature")
expect(model).toHaveProperty("maxTokens")
})
})
describe("createMessage", () => {
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text" as const,
text: "Hello!",
},
],
},
]
it("should handle streaming responses", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response from Groq" }
}
const mockUsage = Promise.resolve({
inputTokens: 10,
outputTokens: 5,
})
const mockProviderMetadata = Promise.resolve({})
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: mockUsage,
providerMetadata: mockProviderMetadata,
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks.length).toBeGreaterThan(0)
const textChunks = chunks.filter((chunk) => chunk.type === "text")
expect(textChunks).toHaveLength(1)
expect(textChunks[0].text).toBe("Test response from Groq")
})
it("should include usage information", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
const mockUsage = Promise.resolve({
inputTokens: 10,
outputTokens: 20,
})
const mockProviderMetadata = Promise.resolve({})
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: mockUsage,
providerMetadata: mockProviderMetadata,
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks.length).toBeGreaterThan(0)
expect(usageChunks[0].inputTokens).toBe(10)
expect(usageChunks[0].outputTokens).toBe(20)
})
it("should handle cached tokens in usage data from providerMetadata", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
const mockUsage = Promise.resolve({
inputTokens: 100,
outputTokens: 50,
})
// Groq provides cache metrics via providerMetadata for supported models
const mockProviderMetadata = Promise.resolve({
groq: {
promptCacheHitTokens: 30,
promptCacheMissTokens: 70,
},
})
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: mockUsage,
providerMetadata: mockProviderMetadata,
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks.length).toBeGreaterThan(0)
expect(usageChunks[0].inputTokens).toBe(100)
expect(usageChunks[0].outputTokens).toBe(50)
expect(usageChunks[0].cacheReadTokens).toBe(30)
expect(usageChunks[0].cacheWriteTokens).toBe(70)
})
it("should handle usage with details.cachedInputTokens when providerMetadata is not available", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
const mockUsage = Promise.resolve({
inputTokens: 100,
outputTokens: 50,
details: {
cachedInputTokens: 25,
},
})
const mockProviderMetadata = Promise.resolve({})
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: mockUsage,
providerMetadata: mockProviderMetadata,
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks.length).toBeGreaterThan(0)
expect(usageChunks[0].cacheReadTokens).toBe(25)
expect(usageChunks[0].cacheWriteTokens).toBeUndefined()
})
it("should pass correct temperature (0.5 default) to streamText", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
providerMetadata: Promise.resolve({}),
})
const handlerWithDefaultTemp = new GroqHandler({
groqApiKey: "test-key",
apiModelId: "llama-3.1-8b-instant",
})
const stream = handlerWithDefaultTemp.createMessage(systemPrompt, messages)
for await (const _ of stream) {
// consume stream
}
expect(mockStreamText).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.5,
}),
)
})
})
describe("completePrompt", () => {
it("should complete a prompt using generateText", async () => {
mockGenerateText.mockResolvedValue({
text: "Test completion from Groq",
})
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Test completion from Groq")
expect(mockGenerateText).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "Test prompt",
}),
)
})
it("should use default temperature in completePrompt", async () => {
mockGenerateText.mockResolvedValue({
text: "Test completion",
})
await handler.completePrompt("Test prompt")
expect(mockGenerateText).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.5,
}),
)
})
})
describe("processUsageMetrics", () => {
it("should correctly process usage metrics including cache information from providerMetadata", () => {
class TestGroqHandler extends GroqHandler {
public testProcessUsageMetrics(usage: any, providerMetadata?: any) {
return this.processUsageMetrics(usage, providerMetadata)
}
}
const testHandler = new TestGroqHandler(mockOptions)
const usage = {
inputTokens: 100,
outputTokens: 50,
}
const providerMetadata = {
groq: {
promptCacheHitTokens: 20,
promptCacheMissTokens: 80,
},
}
const result = testHandler.testProcessUsageMetrics(usage, providerMetadata)
expect(result.type).toBe("usage")
expect(result.inputTokens).toBe(100)
expect(result.outputTokens).toBe(50)
expect(result.cacheWriteTokens).toBe(80)
expect(result.cacheReadTokens).toBe(20)
})
it("should handle missing cache metrics gracefully", () => {
class TestGroqHandler extends GroqHandler {
public testProcessUsageMetrics(usage: any, providerMetadata?: any) {
return this.processUsageMetrics(usage, providerMetadata)
}
}
const testHandler = new TestGroqHandler(mockOptions)
const usage = {
inputTokens: 100,
outputTokens: 50,
}
const result = testHandler.testProcessUsageMetrics(usage)
expect(result.type).toBe("usage")
expect(result.inputTokens).toBe(100)
expect(result.outputTokens).toBe(50)
expect(result.cacheWriteTokens).toBeUndefined()
expect(result.cacheReadTokens).toBeUndefined()
})
it("should include reasoning tokens when provided", () => {
class TestGroqHandler extends GroqHandler {
public testProcessUsageMetrics(usage: any, providerMetadata?: any) {
return this.processUsageMetrics(usage, providerMetadata)
}
}
const testHandler = new TestGroqHandler(mockOptions)
const usage = {
inputTokens: 100,
outputTokens: 50,
details: {
reasoningTokens: 30,
},
}
const result = testHandler.testProcessUsageMetrics(usage)
expect(result.reasoningTokens).toBe(30)
})
})
describe("tool handling", () => {
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [{ type: "text" as const, text: "Hello!" }],
},
]
it("should handle tool calls in streaming", async () => {
async function* mockFullStream() {
yield {
type: "tool-input-start",
id: "tool-call-1",
toolName: "read_file",
}
yield {
type: "tool-input-delta",
id: "tool-call-1",
delta: '{"path":"test.ts"}',
}
yield {
type: "tool-input-end",
id: "tool-call-1",
}
}
const mockUsage = Promise.resolve({
inputTokens: 10,
outputTokens: 5,
})
const mockProviderMetadata = Promise.resolve({})
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: mockUsage,
providerMetadata: mockProviderMetadata,
})
const stream = handler.createMessage(systemPrompt, messages, {
taskId: "test-task",
tools: [
{
type: "function",
function: {
name: "read_file",
description: "Read a file",
parameters: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
},
},
},
],
})
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start")
const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta")
const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end")
expect(toolCallStartChunks.length).toBe(1)
expect(toolCallStartChunks[0].id).toBe("tool-call-1")
expect(toolCallStartChunks[0].name).toBe("read_file")
expect(toolCallDeltaChunks.length).toBe(1)
expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}')
expect(toolCallEndChunks.length).toBe(1)
expect(toolCallEndChunks[0].id).toBe("tool-call-1")
})
it("should ignore tool-call events to prevent duplicate tools in UI", async () => {
async function* mockFullStream() {
yield {
type: "tool-call",
toolCallId: "tool-call-1",
toolName: "read_file",
input: { path: "test.ts" },
}
}
const mockUsage = Promise.resolve({
inputTokens: 10,
outputTokens: 5,
})
const mockProviderMetadata = Promise.resolve({})
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: mockUsage,
providerMetadata: mockProviderMetadata,
})
const stream = handler.createMessage(systemPrompt, messages, {
taskId: "test-task",
tools: [
{
type: "function",
function: {
name: "read_file",
description: "Read a file",
parameters: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
},
},
},
],
})
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// tool-call events are ignored, so no tool_call chunks should be emitted
const toolCallChunks = chunks.filter((c) => c.type === "tool_call")
expect(toolCallChunks.length).toBe(0)
})
})
describe("getMaxOutputTokens", () => {
it("should return maxTokens from model info", () => {
class TestGroqHandler extends GroqHandler {
public testGetMaxOutputTokens() {
return this.getMaxOutputTokens()
}
}
const testHandler = new TestGroqHandler({
...mockOptions,
apiModelId: "llama-3.1-8b-instant",
})
const result = testHandler.testGetMaxOutputTokens()
// llama-3.1-8b-instant has maxTokens of 8192
expect(result).toBe(8192)
})
it("should use modelMaxTokens when provided", () => {
class TestGroqHandler extends GroqHandler {
public testGetMaxOutputTokens() {
return this.getMaxOutputTokens()
}
}
const customMaxTokens = 5000
const testHandler = new TestGroqHandler({
...mockOptions,
modelMaxTokens: customMaxTokens,
})
const result = testHandler.testGetMaxOutputTokens()
expect(result).toBe(customMaxTokens)
})
})
})

View file

@ -1,303 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { IOIntelligenceHandler } from "../io-intelligence"
import type { ApiHandlerOptions } from "../../../shared/api"
const mockCreate = vi.fn()
// Mock OpenAI
vi.mock("openai", () => ({
default: class MockOpenAI {
baseURL: string
apiKey: string
chat = {
completions: {
create: vi.fn(),
},
}
constructor(options: any) {
this.baseURL = options.baseURL
this.apiKey = options.apiKey
this.chat.completions.create = mockCreate
}
},
}))
// Mock the fetcher functions
vi.mock("../fetchers/io-intelligence", () => ({
getIOIntelligenceModels: vi.fn(),
getCachedIOIntelligenceModels: vi.fn(() => ({
"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": {
maxTokens: 8192,
contextWindow: 430000,
description: "Llama 4 Maverick 17B model",
supportsImages: true,
supportsPromptCache: false,
},
"deepseek-ai/DeepSeek-R1-0528": {
maxTokens: 8192,
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
description: "DeepSeek R1 reasoning model",
},
"Intel/Qwen3-Coder-480B-A35B-Instruct-int4-mixed-ar": {
maxTokens: 4096,
contextWindow: 106000,
supportsImages: false,
supportsPromptCache: false,
description: "Qwen3 Coder 480B specialized for coding",
},
"openai/gpt-oss-120b": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
description: "OpenAI GPT-OSS 120B model",
},
})),
}))
// Mock constants
vi.mock("../constants", () => ({
DEFAULT_HEADERS: { "User-Agent": "roo-cline" },
}))
// Mock transform functions
vi.mock("../../transform/openai-format", () => ({
convertToOpenAiMessages: vi.fn((messages) => messages),
}))
describe("IOIntelligenceHandler", () => {
let handler: IOIntelligenceHandler
let mockOptions: ApiHandlerOptions
beforeEach(() => {
vi.clearAllMocks()
mockOptions = {
ioIntelligenceApiKey: "test-api-key",
apiModelId: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
modelTemperature: 0.7,
includeMaxTokens: false,
modelMaxTokens: undefined,
} as ApiHandlerOptions
mockCreate.mockImplementation(async () => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: { content: "Test response" },
index: 0,
},
],
usage: null,
}
yield {
choices: [
{
delta: {},
index: 0,
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
},
}
},
}))
handler = new IOIntelligenceHandler(mockOptions)
})
afterEach(() => {
vi.restoreAllMocks()
})
it("should create OpenAI client with correct configuration", () => {
const ioIntelligenceApiKey = "test-io-intelligence-api-key"
const handler = new IOIntelligenceHandler({ ioIntelligenceApiKey })
// Verify that the handler was created successfully
expect(handler).toBeInstanceOf(IOIntelligenceHandler)
expect(handler["client"]).toBeDefined()
// Verify the client has the expected properties
expect(handler["client"].baseURL).toBe("https://api.intelligence.io.solutions/api/v1")
expect(handler["client"].apiKey).toBe(ioIntelligenceApiKey)
})
it("should initialize with correct configuration", () => {
expect(handler).toBeInstanceOf(IOIntelligenceHandler)
expect(handler["client"]).toBeDefined()
expect(handler["options"]).toEqual({
...mockOptions,
apiKey: mockOptions.ioIntelligenceApiKey,
})
})
it("should throw error when API key is missing", () => {
const optionsWithoutKey = { ...mockOptions }
delete optionsWithoutKey.ioIntelligenceApiKey
expect(() => new IOIntelligenceHandler(optionsWithoutKey)).toThrow("IO Intelligence API key is required")
})
it("should handle streaming response correctly", async () => {
const mockStream = [
{
choices: [{ delta: { content: "Hello" } }],
usage: null,
},
{
choices: [{ delta: { content: " world" } }],
usage: null,
},
{
choices: [{ delta: {} }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
},
]
mockCreate.mockResolvedValue({
[Symbol.asyncIterator]: async function* () {
for (const chunk of mockStream) {
yield chunk
}
},
})
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
const stream = handler.createMessage("System prompt", messages)
const results = []
for await (const chunk of stream) {
results.push(chunk)
}
expect(results).toHaveLength(3)
expect(results[0]).toEqual({ type: "text", text: "Hello" })
expect(results[1]).toEqual({ type: "text", text: " world" })
expect(results[2]).toMatchObject({
type: "usage",
inputTokens: 10,
outputTokens: 5,
})
})
it("completePrompt method should return text from IO Intelligence API", async () => {
const expectedResponse = "This is a test response from IO Intelligence"
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] })
const result = await handler.completePrompt("test prompt")
expect(result).toBe(expectedResponse)
})
it("should handle errors in completePrompt", async () => {
const errorMessage = "IO Intelligence API error"
mockCreate.mockRejectedValueOnce(new Error(errorMessage))
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
`IO Intelligence completion error: ${errorMessage}`,
)
})
it("createMessage should yield text content from stream", async () => {
const testContent = "This is test content from IO Intelligence stream"
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: { choices: [{ delta: { content: testContent } }] },
})
.mockResolvedValueOnce({ done: true }),
}),
}
})
const stream = handler.createMessage("system prompt", [])
const firstChunk = await stream.next()
expect(firstChunk.done).toBe(false)
expect(firstChunk.value).toEqual({ type: "text", text: testContent })
})
it("createMessage should yield usage data from stream", async () => {
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } },
})
.mockResolvedValueOnce({ done: true }),
}),
}
})
const stream = handler.createMessage("system prompt", [])
const firstChunk = await stream.next()
expect(firstChunk.done).toBe(false)
expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 })
})
it("should return model info from cache when available", () => {
const model = handler.getModel()
expect(model.id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8")
expect(model.info).toEqual({
maxTokens: 8192,
contextWindow: 430000,
description: "Llama 4 Maverick 17B model",
supportsImages: true,
supportsPromptCache: false,
})
})
it("should return fallback model info when not in cache", () => {
const handlerWithUnknownModel = new IOIntelligenceHandler({
...mockOptions,
apiModelId: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
})
const model = handlerWithUnknownModel.getModel()
expect(model.id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8")
expect(model.info).toEqual({
maxTokens: 8192,
contextWindow: 430000,
description: "Llama 4 Maverick 17B model",
supportsImages: true,
supportsPromptCache: false,
})
})
it("should use default model when no model is specified", () => {
const handlerWithoutModel = new IOIntelligenceHandler({
...mockOptions,
apiModelId: undefined,
})
const model = handlerWithoutModel.getModel()
expect(model.id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8")
})
it("should handle empty response from completePrompt", async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: null } }],
})
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("")
})
it("should handle missing choices in completePrompt response", async () => {
mockCreate.mockResolvedValueOnce({
choices: [],
})
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("")
})
})

View file

@ -1,62 +1,69 @@
// npx vitest run api/providers/__tests__/lm-studio-timeout.spec.ts
const { mockCreateOpenAICompatible } = vi.hoisted(() => ({
mockCreateOpenAICompatible: vi.fn(() => {
return vi.fn(() => ({
modelId: "llama2",
provider: "lmstudio",
}))
}),
}))
vi.mock("@ai-sdk/openai-compatible", () => ({
createOpenAICompatible: mockCreateOpenAICompatible,
}))
vi.mock("ai", async (importOriginal) => {
const actual = await importOriginal<typeof import("ai")>()
return {
...actual,
streamText: vi.fn(),
generateText: vi.fn(),
}
})
import { LmStudioHandler } from "../lm-studio"
import { ApiHandlerOptions } from "../../../shared/api"
// Mock the timeout config utility
vitest.mock("../utils/timeout-config", () => ({
getApiRequestTimeout: vitest.fn(),
}))
import { getApiRequestTimeout } from "../utils/timeout-config"
// Mock OpenAI
const mockOpenAIConstructor = vitest.fn()
vitest.mock("openai", () => {
return {
__esModule: true,
default: vitest.fn().mockImplementation((config) => {
mockOpenAIConstructor(config)
return {
chat: {
completions: {
create: vitest.fn(),
},
},
}
}),
}
})
describe("LmStudioHandler timeout configuration", () => {
describe("LmStudioHandler configuration", () => {
beforeEach(() => {
vitest.clearAllMocks()
vi.clearAllMocks()
})
it("should use default timeout of 600 seconds when no configuration is set", () => {
;(getApiRequestTimeout as any).mockReturnValue(600000)
it("should configure the provider with default base URL", () => {
const options: ApiHandlerOptions = {
apiModelId: "llama2",
lmStudioModelId: "llama2",
lmStudioBaseUrl: "http://localhost:1234",
}
new LmStudioHandler(options)
expect(getApiRequestTimeout).toHaveBeenCalled()
expect(mockOpenAIConstructor).toHaveBeenCalledWith(
expect(mockCreateOpenAICompatible).toHaveBeenCalledWith(
expect.objectContaining({
name: "lmstudio",
baseURL: "http://localhost:1234/v1",
apiKey: "noop",
timeout: 600000, // 600 seconds in milliseconds
}),
)
})
it("should use custom timeout when configuration is set", () => {
;(getApiRequestTimeout as any).mockReturnValue(1200000) // 20 minutes
it("should configure the provider with custom base URL", () => {
const options: ApiHandlerOptions = {
apiModelId: "llama2",
lmStudioModelId: "llama2",
lmStudioBaseUrl: "http://localhost:5678",
}
new LmStudioHandler(options)
expect(mockCreateOpenAICompatible).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "http://localhost:5678/v1",
}),
)
})
it("should use 'noop' as the API key", () => {
const options: ApiHandlerOptions = {
apiModelId: "llama2",
lmStudioModelId: "llama2",
@ -65,26 +72,9 @@ describe("LmStudioHandler timeout configuration", () => {
new LmStudioHandler(options)
expect(mockOpenAIConstructor).toHaveBeenCalledWith(
expect(mockCreateOpenAICompatible).toHaveBeenCalledWith(
expect.objectContaining({
timeout: 1200000, // 1200 seconds in milliseconds
}),
)
})
it("should handle zero timeout (no timeout)", () => {
;(getApiRequestTimeout as any).mockReturnValue(0)
const options: ApiHandlerOptions = {
apiModelId: "llama2",
lmStudioModelId: "llama2",
}
new LmStudioHandler(options)
expect(mockOpenAIConstructor).toHaveBeenCalledWith(
expect.objectContaining({
timeout: 0, // No timeout
apiKey: "noop",
}),
)
})

View file

@ -1,22 +1,28 @@
// npx vitest run api/providers/__tests__/lmstudio-native-tools.spec.ts
// Mock OpenAI client - must come before other imports
const mockCreate = vi.fn()
vi.mock("openai", () => {
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
const { mockStreamText } = vi.hoisted(() => ({
mockStreamText: vi.fn(),
}))
vi.mock("ai", async (importOriginal) => {
const actual = await importOriginal<typeof import("ai")>()
return {
__esModule: true,
default: vi.fn().mockImplementation(() => ({
chat: {
completions: {
create: mockCreate,
},
},
})),
...actual,
streamText: mockStreamText,
}
})
vi.mock("@ai-sdk/openai-compatible", () => ({
createOpenAICompatible: vi.fn(() => {
return vi.fn(() => ({
modelId: "local-model",
provider: "lmstudio",
}))
}),
}))
import { LmStudioHandler } from "../lm-studio"
import { NativeToolCallParser } from "../../../core/assistant-message/NativeToolCallParser"
import type { ApiHandlerOptions } from "../../../shared/api"
describe("LmStudioHandler Native Tools", () => {
@ -49,128 +55,76 @@ describe("LmStudioHandler Native Tools", () => {
lmStudioBaseUrl: "http://localhost:1234",
}
handler = new LmStudioHandler(mockOptions)
// Clear NativeToolCallParser state before each test
NativeToolCallParser.clearRawChunkState()
})
describe("Native Tool Calling Support", () => {
it("should include tools in request when model supports native tools and tools are provided", async () => {
mockCreate.mockImplementationOnce(() => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [{ delta: { content: "Test response" } }],
}
},
}))
it("should include tools in request when tools are provided", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
})
const stream = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
})
await stream.next()
for await (const _chunk of stream) {
// consume stream
}
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.arrayContaining([
expect.objectContaining({
type: "function",
function: expect.objectContaining({
name: "test_tool",
}),
}),
]),
}),
)
// parallel_tool_calls should be true by default when not explicitly set
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs).toHaveProperty("parallel_tool_calls", true)
const callArgs = mockStreamText.mock.calls[0][0]
expect(callArgs.tools).toBeDefined()
})
it("should include tool_choice when provided", async () => {
mockCreate.mockImplementationOnce(() => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [{ delta: { content: "Test response" } }],
}
},
}))
it("should include toolChoice when provided", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
})
const stream = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
tool_choice: "auto",
})
await stream.next()
for await (const _chunk of stream) {
// consume stream
}
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
tool_choice: "auto",
}),
)
const callArgs = mockStreamText.mock.calls[0][0]
expect(callArgs.toolChoice).toBe("auto")
})
it("should always include tools and tool_choice in request (tools are always present after PR #10841)", async () => {
mockCreate.mockImplementationOnce(() => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [{ delta: { content: "Test response" } }],
}
},
}))
it("should yield tool_call_start, tool_call_delta, and tool_call_end chunks from AI SDK stream", async () => {
async function* mockFullStream() {
yield {
type: "tool-input-start",
id: "call_lmstudio_123",
toolName: "test_tool",
}
yield {
type: "tool-input-delta",
id: "call_lmstudio_123",
delta: '{"arg1":"value"}',
}
yield {
type: "tool-input-end",
id: "call_lmstudio_123",
}
}
const stream = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
})
await stream.next()
const callArgs = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0]
// Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
expect(callArgs).toHaveProperty("tools")
expect(callArgs).toHaveProperty("tool_choice")
// parallel_tool_calls should be true by default when not explicitly set
expect(callArgs).toHaveProperty("parallel_tool_calls", true)
})
it("should yield tool_call_partial chunks during streaming", async () => {
mockCreate.mockImplementationOnce(() => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: "call_lmstudio_123",
function: {
name: "test_tool",
arguments: '{"arg1":',
},
},
],
},
},
],
}
yield {
choices: [
{
delta: {
tool_calls: [
{
index: 0,
function: {
arguments: '"value"}',
},
},
],
},
},
],
}
},
}))
const stream = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
@ -182,168 +136,56 @@ describe("LmStudioHandler Native Tools", () => {
chunks.push(chunk)
}
expect(chunks).toContainEqual({
type: "tool_call_partial",
index: 0,
const startChunks = chunks.filter((chunk) => chunk.type === "tool_call_start")
expect(startChunks).toHaveLength(1)
expect(startChunks[0]).toEqual({
type: "tool_call_start",
id: "call_lmstudio_123",
name: "test_tool",
arguments: '{"arg1":',
})
expect(chunks).toContainEqual({
type: "tool_call_partial",
index: 0,
id: undefined,
name: undefined,
arguments: '"value"}',
})
})
it("should set parallel_tool_calls based on metadata", async () => {
mockCreate.mockImplementationOnce(() => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [{ delta: { content: "Test response" } }],
}
},
}))
const stream = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
parallelToolCalls: true,
})
await stream.next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
parallel_tool_calls: true,
}),
)
})
it("should yield tool_call_end events when finish_reason is tool_calls", async () => {
mockCreate.mockImplementationOnce(() => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: "call_lmstudio_test",
function: {
name: "test_tool",
arguments: '{"arg1":"value"}',
},
},
],
},
},
],
}
yield {
choices: [
{
delta: {},
finish_reason: "tool_calls",
},
],
}
},
}))
const stream = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
const deltaChunks = chunks.filter((chunk) => chunk.type === "tool_call_delta")
expect(deltaChunks).toHaveLength(1)
expect(deltaChunks[0]).toEqual({
type: "tool_call_delta",
id: "call_lmstudio_123",
delta: '{"arg1":"value"}',
})
const chunks = []
for await (const chunk of stream) {
// Simulate what Task.ts does: when we receive tool_call_partial,
// process it through NativeToolCallParser to populate rawChunkTracker
if (chunk.type === "tool_call_partial") {
NativeToolCallParser.processRawChunk({
index: chunk.index,
id: chunk.id,
name: chunk.name,
arguments: chunk.arguments,
})
}
chunks.push(chunk)
}
// Should have tool_call_partial and tool_call_end
const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end")
expect(partialChunks).toHaveLength(1)
expect(endChunks).toHaveLength(1)
expect(endChunks[0].id).toBe("call_lmstudio_test")
})
it("should work with parallel tool calls disabled (sends false)", async () => {
mockCreate.mockImplementationOnce(() => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [{ delta: { content: "Response" } }],
}
},
}))
const stream = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
parallelToolCalls: false,
expect(endChunks[0]).toEqual({
type: "tool_call_end",
id: "call_lmstudio_123",
})
await stream.next()
// When parallelToolCalls is false, the parameter should be sent as false
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs).toHaveProperty("parallel_tool_calls", false)
})
it("should handle reasoning content alongside tool calls", async () => {
mockCreate.mockImplementationOnce(() => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: {
content: "<think>Thinking about this...</think>",
},
},
],
}
yield {
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: "call_after_think",
function: {
name: "test_tool",
arguments: '{"arg1":"result"}',
},
},
],
},
},
],
}
yield {
choices: [
{
delta: {},
finish_reason: "tool_calls",
},
],
}
},
}))
async function* mockFullStream() {
yield {
type: "reasoning",
text: "Thinking about this...",
}
yield {
type: "tool-input-start",
id: "call_after_think",
toolName: "test_tool",
}
yield {
type: "tool-input-delta",
id: "call_after_think",
delta: '{"arg1":"result"}',
}
yield {
type: "tool-input-end",
id: "call_after_think",
}
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
})
const stream = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
@ -352,25 +194,60 @@ describe("LmStudioHandler Native Tools", () => {
const chunks = []
for await (const chunk of stream) {
if (chunk.type === "tool_call_partial") {
NativeToolCallParser.processRawChunk({
index: chunk.index,
id: chunk.id,
name: chunk.name,
arguments: chunk.arguments,
})
}
chunks.push(chunk)
}
// Should have reasoning, tool_call_partial, and tool_call_end
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
const startChunks = chunks.filter((chunk) => chunk.type === "tool_call_start")
const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end")
expect(reasoningChunks).toHaveLength(1)
expect(reasoningChunks[0].text).toBe("Thinking about this...")
expect(partialChunks).toHaveLength(1)
expect(startChunks).toHaveLength(1)
expect(endChunks).toHaveLength(1)
})
it("should handle text and tool calls in the same response", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Here's the result: " }
yield {
type: "tool-input-start",
id: "call_mixed",
toolName: "test_tool",
}
yield {
type: "tool-input-delta",
id: "call_mixed",
delta: '{"arg1":"mixed"}',
}
yield {
type: "tool-input-end",
id: "call_mixed",
}
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
})
const stream = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
})
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const textChunks = chunks.filter((chunk) => chunk.type === "text")
const startChunks = chunks.filter((chunk) => chunk.type === "tool_call_start")
const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end")
expect(textChunks).toHaveLength(1)
expect(textChunks[0].text).toBe("Here's the result: ")
expect(startChunks).toHaveLength(1)
expect(endChunks).toHaveLength(1)
})
})

View file

@ -1,63 +1,29 @@
// Mock OpenAI client - must come before other imports
const mockCreate = vi.fn()
vi.mock("openai", () => {
return {
__esModule: true,
default: vi.fn().mockImplementation(() => ({
chat: {
completions: {
create: mockCreate.mockImplementation(async (options) => {
if (!options.stream) {
return {
id: "test-completion",
choices: [
{
message: { role: "assistant", content: "Test response" },
finish_reason: "stop",
index: 0,
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
},
}
}
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
const { mockStreamText, mockGenerateText, mockWrapLanguageModel } = vi.hoisted(() => ({
mockStreamText: vi.fn(),
mockGenerateText: vi.fn(),
mockWrapLanguageModel: vi.fn((opts: any) => opts.model),
}))
return {
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: { content: "Test response" },
index: 0,
},
],
usage: null,
}
yield {
choices: [
{
delta: {},
index: 0,
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
},
}
},
}
}),
},
},
})),
vi.mock("ai", async (importOriginal) => {
const actual = await importOriginal<typeof import("ai")>()
return {
...actual,
streamText: mockStreamText,
generateText: mockGenerateText,
wrapLanguageModel: mockWrapLanguageModel,
}
})
vi.mock("@ai-sdk/openai-compatible", () => ({
createOpenAICompatible: vi.fn(() => {
return vi.fn(() => ({
modelId: "local-model",
provider: "lmstudio",
}))
}),
}))
import type { Anthropic } from "@anthropic-ai/sdk"
import { LmStudioHandler } from "../lm-studio"
@ -74,7 +40,7 @@ describe("LmStudioHandler", () => {
lmStudioBaseUrl: "http://localhost:1234",
}
handler = new LmStudioHandler(mockOptions)
mockCreate.mockClear()
vi.clearAllMocks()
})
describe("constructor", () => {
@ -102,6 +68,20 @@ describe("LmStudioHandler", () => {
]
it("should handle streaming responses", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
const mockUsage = Promise.resolve({
inputTokens: 10,
outputTokens: 5,
})
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: mockUsage,
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
@ -114,8 +94,43 @@ describe("LmStudioHandler", () => {
expect(textChunks[0].text).toBe("Test response")
})
it("should include usage information", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
const mockUsage = Promise.resolve({
inputTokens: 10,
outputTokens: 5,
})
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: mockUsage,
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks.length).toBeGreaterThan(0)
expect(usageChunks[0].inputTokens).toBe(10)
expect(usageChunks[0].outputTokens).toBe(5)
})
it("should handle API errors", async () => {
mockCreate.mockRejectedValueOnce(new Error("API Error"))
async function* mockFullStream(): AsyncGenerator<{ type: string; text: string }> {
yield { type: "text-delta", text: "" }
throw new Error("API Error")
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
})
const stream = handler.createMessage(systemPrompt, messages)
@ -123,36 +138,37 @@ describe("LmStudioHandler", () => {
for await (const _chunk of stream) {
// Should not reach here
}
}).rejects.toThrow("Please check the LM Studio developer logs to debug what went wrong")
}).rejects.toThrow()
})
})
describe("completePrompt", () => {
it("should complete prompt successfully", async () => {
mockGenerateText.mockResolvedValue({
text: "Test response",
})
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Test response")
expect(mockCreate).toHaveBeenCalledWith({
model: mockOptions.lmStudioModelId,
messages: [{ role: "user", content: "Test prompt" }],
temperature: 0,
stream: false,
})
})
it("should handle API errors", async () => {
mockCreate.mockRejectedValueOnce(new Error("API Error"))
await expect(handler.completePrompt("Test prompt")).rejects.toThrow(
"Please check the LM Studio developer logs to debug what went wrong",
expect(mockGenerateText).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "Test prompt",
}),
)
})
it("should handle empty response", async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: "" } }],
mockGenerateText.mockResolvedValue({
text: "",
})
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("")
})
it("should handle API errors with handleAiSdkError", async () => {
mockGenerateText.mockRejectedValueOnce(new Error("Connection refused"))
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("LM Studio")
})
})
describe("getModel", () => {
@ -164,4 +180,131 @@ describe("LmStudioHandler", () => {
expect(modelInfo.info.contextWindow).toBe(128_000)
})
})
describe("speculative decoding", () => {
it("should include draft_model in providerOptions when speculative decoding is enabled", async () => {
const speculativeHandler = new LmStudioHandler({
...mockOptions,
lmStudioSpeculativeDecodingEnabled: true,
lmStudioDraftModelId: "draft-model-id",
})
async function* mockFullStream() {
yield { type: "text-delta", text: "Test" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 5, outputTokens: 3 }),
})
const stream = speculativeHandler.createMessage("test prompt", [])
for await (const _chunk of stream) {
// consume stream
}
expect(mockStreamText).toHaveBeenCalledWith(
expect.objectContaining({
providerOptions: {
lmstudio: { draft_model: "draft-model-id" },
},
}),
)
})
it("should not include draft_model when speculative decoding is disabled", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 5, outputTokens: 3 }),
})
const stream = handler.createMessage("test prompt", [])
for await (const _chunk of stream) {
// consume stream
}
const callArgs = mockStreamText.mock.calls[0][0]
expect(callArgs.providerOptions).toBeUndefined()
})
it("should include draft_model in completePrompt when speculative decoding is enabled", async () => {
const speculativeHandler = new LmStudioHandler({
...mockOptions,
lmStudioSpeculativeDecodingEnabled: true,
lmStudioDraftModelId: "draft-model-id",
})
mockGenerateText.mockResolvedValue({ text: "Test" })
await speculativeHandler.completePrompt("Test prompt")
expect(mockGenerateText).toHaveBeenCalledWith(
expect.objectContaining({
providerOptions: {
lmstudio: { draft_model: "draft-model-id" },
},
}),
)
})
})
describe("reasoning middleware", () => {
it("should wrap the language model with extractReasoningMiddleware for <think> tags", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 5, outputTokens: 3 }),
})
const stream = handler.createMessage("test prompt", [])
for await (const _chunk of stream) {
// consume stream to trigger getLanguageModel()
}
expect(mockWrapLanguageModel).toHaveBeenCalledWith(
expect.objectContaining({
middleware: expect.any(Object),
}),
)
})
it("should handle reasoning-delta chunks from middleware-processed stream", async () => {
async function* mockFullStream() {
yield { type: "reasoning-delta", text: "Let me think about this..." }
yield { type: "text-delta", text: "The answer is 42." }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 8 }),
})
const stream = handler.createMessage("test prompt", [])
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
const textChunks = chunks.filter((c) => c.type === "text")
expect(reasoningChunks).toHaveLength(1)
expect(reasoningChunks[0].text).toBe("Let me think about this...")
expect(textChunks).toHaveLength(1)
expect(textChunks[0].text).toBe("The answer is 42.")
})
})
describe("isAiSdkProvider", () => {
it("should return true", () => {
expect(handler.isAiSdkProvider()).toBe(true)
})
})
})

View file

@ -1,59 +1,36 @@
// Mock TelemetryService - must come before other imports
const mockCaptureException = vi.hoisted(() => vi.fn())
vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
instance: {
captureException: mockCaptureException,
},
},
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
const { mockStreamText, mockGenerateText, mockCreateMistral } = vi.hoisted(() => ({
mockStreamText: vi.fn(),
mockGenerateText: vi.fn(),
mockCreateMistral: vi.fn(() => {
// Return a function that returns a mock language model
return vi.fn(() => ({
modelId: "codestral-latest",
provider: "mistral",
}))
}),
}))
// Mock Mistral client - must come before other imports
const mockCreate = vi.fn()
const mockComplete = vi.fn()
vi.mock("@mistralai/mistralai", () => {
vi.mock("ai", async (importOriginal) => {
const actual = await importOriginal<typeof import("ai")>()
return {
Mistral: vi.fn().mockImplementation(() => ({
chat: {
stream: mockCreate.mockImplementation(async (_options) => {
const stream = {
[Symbol.asyncIterator]: async function* () {
yield {
data: {
choices: [
{
delta: { content: "Test response" },
index: 0,
},
],
},
}
},
}
return stream
}),
complete: mockComplete.mockImplementation(async (_options) => {
return {
choices: [
{
message: {
content: "Test response",
},
},
],
}
}),
},
})),
...actual,
streamText: mockStreamText,
generateText: mockGenerateText,
}
})
vi.mock("@ai-sdk/mistral", () => ({
createMistral: mockCreateMistral,
}))
import type { Anthropic } from "@anthropic-ai/sdk"
import type OpenAI from "openai"
import { MistralHandler } from "../mistral"
import { mistralDefaultModelId, mistralModels, type MistralModelId } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../../shared/api"
import type { ApiHandlerCreateMessageMetadata } from "../../index"
import type { ApiStreamTextChunk, ApiStreamReasoningChunk, ApiStreamToolCallPartialChunk } from "../../transform/stream"
import { MistralHandler } from "../mistral"
describe("MistralHandler", () => {
let handler: MistralHandler
@ -61,15 +38,11 @@ describe("MistralHandler", () => {
beforeEach(() => {
mockOptions = {
apiModelId: "codestral-latest", // Update to match the actual model ID
mistralApiKey: "test-api-key",
includeMaxTokens: true,
modelTemperature: 0,
apiModelId: "codestral-latest" as MistralModelId,
}
handler = new MistralHandler(mockOptions)
mockCreate.mockClear()
mockComplete.mockClear()
mockCaptureException.mockClear()
vi.clearAllMocks()
})
describe("constructor", () => {
@ -78,32 +51,53 @@ describe("MistralHandler", () => {
expect(handler.getModel().id).toBe(mockOptions.apiModelId)
})
it("should throw error if API key is missing", () => {
expect(() => {
new MistralHandler({
...mockOptions,
mistralApiKey: undefined,
})
}).toThrow("Mistral API key is required")
})
it("should use custom base URL if provided", () => {
const customBaseUrl = "https://custom.mistral.ai/v1"
const handlerWithCustomUrl = new MistralHandler({
it("should use default model ID if not provided", () => {
const handlerWithoutModel = new MistralHandler({
...mockOptions,
mistralCodestralUrl: customBaseUrl,
apiModelId: undefined,
})
expect(handlerWithCustomUrl).toBeInstanceOf(MistralHandler)
expect(handlerWithoutModel.getModel().id).toBe(mistralDefaultModelId)
})
})
describe("getModel", () => {
it("should return correct model info", () => {
it("should return model info for valid model ID", () => {
const model = handler.getModel()
expect(model.id).toBe(mockOptions.apiModelId)
expect(model.info).toBeDefined()
expect(model.info.maxTokens).toBe(8192)
expect(model.info.contextWindow).toBe(256_000)
expect(model.info.supportsImages).toBe(false)
expect(model.info.supportsPromptCache).toBe(false)
})
it("should return provided model ID with default model info if model does not exist", () => {
const handlerWithInvalidModel = new MistralHandler({
...mockOptions,
apiModelId: "invalid-model",
})
const model = handlerWithInvalidModel.getModel()
expect(model.id).toBe("invalid-model") // Returns provided ID
expect(model.info).toBeDefined()
// Should have the same base properties as default model
expect(model.info.contextWindow).toBe(mistralModels[mistralDefaultModelId].contextWindow)
})
it("should return default model if no model ID is provided", () => {
const handlerWithoutModel = new MistralHandler({
...mockOptions,
apiModelId: undefined,
})
const model = handlerWithoutModel.getModel()
expect(model.id).toBe(mistralDefaultModelId)
expect(model.info).toBeDefined()
})
it("should include model parameters from getModelParams", () => {
const model = handler.getModel()
expect(model).toHaveProperty("temperature")
expect(model).toHaveProperty("maxTokens")
})
})
describe("createMessage", () => {
@ -111,389 +105,446 @@ describe("MistralHandler", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [{ type: "text", text: "Hello!" }],
content: [
{
type: "text" as const,
text: "Hello!",
},
],
},
]
it("should create message successfully", async () => {
const iterator = handler.createMessage(systemPrompt, messages)
const result = await iterator.next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: mockOptions.apiModelId,
messages: expect.any(Array),
maxTokens: expect.any(Number),
temperature: 0,
// Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
tools: expect.any(Array),
toolChoice: "any",
}),
)
expect(result.value).toBeDefined()
expect(result.done).toBe(false)
})
it("should handle streaming response correctly", async () => {
const iterator = handler.createMessage(systemPrompt, messages)
const results: ApiStreamTextChunk[] = []
for await (const chunk of iterator) {
if ("text" in chunk) {
results.push(chunk as ApiStreamTextChunk)
}
it("should handle streaming responses", async () => {
// Mock the fullStream async generator
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
expect(results.length).toBeGreaterThan(0)
expect(results[0].text).toBe("Test response")
})
it("should handle errors gracefully", async () => {
mockCreate.mockRejectedValueOnce(new Error("API Error"))
await expect(handler.createMessage(systemPrompt, messages).next()).rejects.toThrow("API Error")
})
it("should handle thinking content as reasoning chunks", async () => {
// Mock stream with thinking content matching new SDK structure
mockCreate.mockImplementationOnce(async (_options) => {
const stream = {
[Symbol.asyncIterator]: async function* () {
yield {
data: {
choices: [
{
delta: {
content: [
{
type: "thinking",
thinking: [{ type: "text", text: "Let me think about this..." }],
},
{ type: "text", text: "Here's the answer" },
],
},
index: 0,
},
],
},
}
},
}
return stream
// Mock usage promise
const mockUsage = Promise.resolve({
inputTokens: 10,
outputTokens: 5,
})
const iterator = handler.createMessage(systemPrompt, messages)
const results: (ApiStreamTextChunk | ApiStreamReasoningChunk)[] = []
for await (const chunk of iterator) {
if ("text" in chunk) {
results.push(chunk as ApiStreamTextChunk | ApiStreamReasoningChunk)
}
}
expect(results).toHaveLength(2)
expect(results[0]).toEqual({ type: "reasoning", text: "Let me think about this..." })
expect(results[1]).toEqual({ type: "text", text: "Here's the answer" })
})
it("should handle mixed content arrays correctly", async () => {
// Mock stream with mixed content matching new SDK structure
mockCreate.mockImplementationOnce(async (_options) => {
const stream = {
[Symbol.asyncIterator]: async function* () {
yield {
data: {
choices: [
{
delta: {
content: [
{ type: "text", text: "First text" },
{
type: "thinking",
thinking: [{ type: "text", text: "Some reasoning" }],
},
{ type: "text", text: "Second text" },
],
},
index: 0,
},
],
},
}
},
}
return stream
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: mockUsage,
})
const iterator = handler.createMessage(systemPrompt, messages)
const results: (ApiStreamTextChunk | ApiStreamReasoningChunk)[] = []
for await (const chunk of iterator) {
if ("text" in chunk) {
results.push(chunk as ApiStreamTextChunk | ApiStreamReasoningChunk)
}
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(results).toHaveLength(3)
expect(results[0]).toEqual({ type: "text", text: "First text" })
expect(results[1]).toEqual({ type: "reasoning", text: "Some reasoning" })
expect(results[2]).toEqual({ type: "text", text: "Second text" })
expect(chunks.length).toBeGreaterThan(0)
const textChunks = chunks.filter((chunk) => chunk.type === "text")
expect(textChunks).toHaveLength(1)
expect(textChunks[0].text).toBe("Test response")
})
})
describe("native tool calling", () => {
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [{ type: "text", text: "What's the weather?" }],
},
]
it("should include usage information", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
const mockTools: OpenAI.Chat.ChatCompletionTool[] = [
{
type: "function",
function: {
name: "get_weather",
description: "Get the current weather",
parameters: {
type: "object",
properties: {
location: { type: "string" },
},
required: ["location"],
},
const mockUsage = Promise.resolve({
inputTokens: 10,
outputTokens: 5,
})
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: mockUsage,
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks.length).toBeGreaterThan(0)
expect(usageChunks[0].inputTokens).toBe(10)
expect(usageChunks[0].outputTokens).toBe(5)
})
it("should handle reasoning content in streaming responses", async () => {
// Mock the fullStream async generator with reasoning content
async function* mockFullStream() {
yield { type: "reasoning", text: "Let me think about this..." }
yield { type: "reasoning", text: " I'll analyze step by step." }
yield { type: "text-delta", text: "Test response" }
}
const mockUsage = Promise.resolve({
inputTokens: 10,
outputTokens: 5,
details: {
reasoningTokens: 15,
},
},
]
it("should include tools in request by default (native is default)", async () => {
const metadata: ApiHandlerCreateMessageMetadata = {
taskId: "test-task",
tools: mockTools,
}
const iterator = handler.createMessage(systemPrompt, messages, metadata)
await iterator.next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.arrayContaining([
expect.objectContaining({
type: "function",
function: expect.objectContaining({
name: "get_weather",
description: "Get the current weather",
parameters: expect.any(Object),
}),
}),
]),
toolChoice: "any",
}),
)
})
it("should always include tools in request (tools are always present after PR #10841)", async () => {
const metadata: ApiHandlerCreateMessageMetadata = {
taskId: "test-task",
}
const iterator = handler.createMessage(systemPrompt, messages, metadata)
await iterator.next()
// Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.any(Array),
toolChoice: "any",
}),
)
})
it("should handle tool calls in streaming response", async () => {
// Mock stream with tool calls
mockCreate.mockImplementationOnce(async (_options) => {
const stream = {
[Symbol.asyncIterator]: async function* () {
yield {
data: {
choices: [
{
delta: {
toolCalls: [
{
id: "call_123",
type: "function",
function: {
name: "get_weather",
arguments: '{"location":"New York"}',
},
},
],
},
index: 0,
},
],
},
}
},
}
return stream
})
const metadata: ApiHandlerCreateMessageMetadata = {
taskId: "test-task",
tools: mockTools,
}
const iterator = handler.createMessage(systemPrompt, messages, metadata)
const results: ApiStreamToolCallPartialChunk[] = []
for await (const chunk of iterator) {
if (chunk.type === "tool_call_partial") {
results.push(chunk)
}
}
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: "tool_call_partial",
index: 0,
id: "call_123",
name: "get_weather",
arguments: '{"location":"New York"}',
})
})
it("should handle multiple tool calls in a single response", async () => {
// Mock stream with multiple tool calls
mockCreate.mockImplementationOnce(async (_options) => {
const stream = {
[Symbol.asyncIterator]: async function* () {
yield {
data: {
choices: [
{
delta: {
toolCalls: [
{
id: "call_1",
type: "function",
function: {
name: "get_weather",
arguments: '{"location":"NYC"}',
},
},
{
id: "call_2",
type: "function",
function: {
name: "get_weather",
arguments: '{"location":"LA"}',
},
},
],
},
index: 0,
},
],
},
}
},
}
return stream
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: mockUsage,
})
const metadata: ApiHandlerCreateMessageMetadata = {
taskId: "test-task",
tools: mockTools,
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const iterator = handler.createMessage(systemPrompt, messages, metadata)
const results: ApiStreamToolCallPartialChunk[] = []
// Should have reasoning chunks
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
expect(reasoningChunks.length).toBe(2)
expect(reasoningChunks[0].text).toBe("Let me think about this...")
expect(reasoningChunks[1].text).toBe(" I'll analyze step by step.")
for await (const chunk of iterator) {
if (chunk.type === "tool_call_partial") {
results.push(chunk)
}
}
expect(results).toHaveLength(2)
expect(results[0]).toEqual({
type: "tool_call_partial",
index: 0,
id: "call_1",
name: "get_weather",
arguments: '{"location":"NYC"}',
})
expect(results[1]).toEqual({
type: "tool_call_partial",
index: 1,
id: "call_2",
name: "get_weather",
arguments: '{"location":"LA"}',
})
})
it("should always set toolChoice to 'any' when tools are provided", async () => {
// Even if tool_choice is provided in metadata, we override it to "any"
const metadata: ApiHandlerCreateMessageMetadata = {
taskId: "test-task",
tools: mockTools,
tool_choice: "auto", // This should be ignored
}
const iterator = handler.createMessage(systemPrompt, messages, metadata)
await iterator.next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
toolChoice: "any",
}),
)
// Should also have text chunks
const textChunks = chunks.filter((chunk) => chunk.type === "text")
expect(textChunks.length).toBe(1)
expect(textChunks[0].text).toBe("Test response")
})
})
describe("completePrompt", () => {
it("should complete prompt successfully", async () => {
const prompt = "Test prompt"
const result = await handler.completePrompt(prompt)
expect(mockComplete).toHaveBeenCalledWith({
model: mockOptions.apiModelId,
messages: [{ role: "user", content: prompt }],
temperature: 0,
it("should complete a prompt using generateText", async () => {
mockGenerateText.mockResolvedValue({
text: "Test completion",
})
expect(result).toBe("Test response")
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Test completion")
expect(mockGenerateText).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "Test prompt",
}),
)
})
})
describe("processUsageMetrics", () => {
it("should correctly process usage metrics", () => {
// We need to access the protected method, so we'll create a test subclass
class TestMistralHandler extends MistralHandler {
public testProcessUsageMetrics(usage: any) {
return this.processUsageMetrics(usage)
}
}
const testHandler = new TestMistralHandler(mockOptions)
const usage = {
inputTokens: 100,
outputTokens: 50,
details: {
cachedInputTokens: 20,
reasoningTokens: 30,
},
}
const result = testHandler.testProcessUsageMetrics(usage)
expect(result.type).toBe("usage")
expect(result.inputTokens).toBe(100)
expect(result.outputTokens).toBe(50)
expect(result.cacheReadTokens).toBe(20)
expect(result.reasoningTokens).toBe(30)
})
it("should filter out thinking content in completePrompt", async () => {
mockComplete.mockImplementationOnce(async (_options) => {
return {
choices: [
{
message: {
content: [
{ type: "thinking", text: "Let me think..." },
{ type: "text", text: "Answer part 1" },
{ type: "text", text: "Answer part 2" },
],
it("should handle missing cache metrics gracefully", () => {
class TestMistralHandler extends MistralHandler {
public testProcessUsageMetrics(usage: any) {
return this.processUsageMetrics(usage)
}
}
const testHandler = new TestMistralHandler(mockOptions)
const usage = {
inputTokens: 100,
outputTokens: 50,
}
const result = testHandler.testProcessUsageMetrics(usage)
expect(result.type).toBe("usage")
expect(result.inputTokens).toBe(100)
expect(result.outputTokens).toBe(50)
expect(result.cacheReadTokens).toBeUndefined()
expect(result.reasoningTokens).toBeUndefined()
})
})
describe("getMaxOutputTokens", () => {
it("should return maxTokens from model info", () => {
class TestMistralHandler extends MistralHandler {
public testGetMaxOutputTokens() {
return this.getMaxOutputTokens()
}
}
const testHandler = new TestMistralHandler(mockOptions)
const result = testHandler.testGetMaxOutputTokens()
// codestral-latest maxTokens is 8192
expect(result).toBe(8192)
})
it("should use modelMaxTokens when provided", () => {
class TestMistralHandler extends MistralHandler {
public testGetMaxOutputTokens() {
return this.getMaxOutputTokens()
}
}
const customMaxTokens = 5000
const testHandler = new TestMistralHandler({
...mockOptions,
modelMaxTokens: customMaxTokens,
})
const result = testHandler.testGetMaxOutputTokens()
expect(result).toBe(customMaxTokens)
})
it("should fall back to modelInfo.maxTokens when modelMaxTokens is not provided", () => {
class TestMistralHandler extends MistralHandler {
public testGetMaxOutputTokens() {
return this.getMaxOutputTokens()
}
}
const testHandler = new TestMistralHandler(mockOptions)
const result = testHandler.testGetMaxOutputTokens()
// codestral-latest has maxTokens of 8192
expect(result).toBe(8192)
})
})
describe("tool handling", () => {
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [{ type: "text" as const, text: "Hello!" }],
},
]
it("should handle tool calls in streaming", async () => {
async function* mockFullStream() {
yield {
type: "tool-input-start",
id: "tool-call-1",
toolName: "read_file",
}
yield {
type: "tool-input-delta",
id: "tool-call-1",
delta: '{"path":"test.ts"}',
}
yield {
type: "tool-input-end",
id: "tool-call-1",
}
}
const mockUsage = Promise.resolve({
inputTokens: 10,
outputTokens: 5,
})
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: mockUsage,
})
const stream = handler.createMessage(systemPrompt, messages, {
taskId: "test-task",
tools: [
{
type: "function",
function: {
name: "read_file",
description: "Read a file",
parameters: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
},
},
],
}
},
],
})
const prompt = "Test prompt"
const result = await handler.completePrompt(prompt)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(result).toBe("Answer part 1Answer part 2")
const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start")
const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta")
const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end")
expect(toolCallStartChunks.length).toBe(1)
expect(toolCallStartChunks[0].id).toBe("tool-call-1")
expect(toolCallStartChunks[0].name).toBe("read_file")
expect(toolCallDeltaChunks.length).toBe(1)
expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}')
expect(toolCallEndChunks.length).toBe(1)
expect(toolCallEndChunks[0].id).toBe("tool-call-1")
})
it("should handle errors in completePrompt", async () => {
mockComplete.mockRejectedValueOnce(new Error("API Error"))
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Mistral completion error: API Error")
it("should ignore tool-call events to prevent duplicate tools in UI", async () => {
// tool-call events are intentionally ignored because tool-input-start/delta/end
// already provide complete tool call information. Emitting tool-call would cause
// duplicate tools in the UI for AI SDK providers.
async function* mockFullStream() {
yield {
type: "tool-call",
toolCallId: "tool-call-1",
toolName: "read_file",
input: { path: "test.ts" },
}
}
const mockUsage = Promise.resolve({
inputTokens: 10,
outputTokens: 5,
})
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: mockUsage,
})
const stream = handler.createMessage(systemPrompt, messages, {
taskId: "test-task",
tools: [
{
type: "function",
function: {
name: "read_file",
description: "Read a file",
parameters: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
},
},
},
],
})
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// tool-call events are ignored, so no tool_call chunks should be emitted
const toolCallChunks = chunks.filter((c) => c.type === "tool_call")
expect(toolCallChunks.length).toBe(0)
})
})
describe("mapToolChoice", () => {
it("should handle string tool choices", () => {
class TestMistralHandler extends MistralHandler {
public testMapToolChoice(toolChoice: any) {
return this.mapToolChoice(toolChoice)
}
}
const testHandler = new TestMistralHandler(mockOptions)
expect(testHandler.testMapToolChoice("auto")).toBe("auto")
expect(testHandler.testMapToolChoice("none")).toBe("none")
expect(testHandler.testMapToolChoice("required")).toBe("required")
expect(testHandler.testMapToolChoice("any")).toBe("required")
expect(testHandler.testMapToolChoice("unknown")).toBe("auto")
})
it("should handle object tool choice with function name", () => {
class TestMistralHandler extends MistralHandler {
public testMapToolChoice(toolChoice: any) {
return this.mapToolChoice(toolChoice)
}
}
const testHandler = new TestMistralHandler(mockOptions)
const result = testHandler.testMapToolChoice({
type: "function",
function: { name: "my_tool" },
})
expect(result).toEqual({ type: "tool", toolName: "my_tool" })
})
it("should return undefined for null or undefined", () => {
class TestMistralHandler extends MistralHandler {
public testMapToolChoice(toolChoice: any) {
return this.mapToolChoice(toolChoice)
}
}
const testHandler = new TestMistralHandler(mockOptions)
expect(testHandler.testMapToolChoice(null)).toBeUndefined()
expect(testHandler.testMapToolChoice(undefined)).toBeUndefined()
})
})
describe("Codestral URL handling", () => {
beforeEach(() => {
mockCreateMistral.mockClear()
})
it("should use default Codestral URL for codestral models", () => {
new MistralHandler({
...mockOptions,
apiModelId: "codestral-latest",
})
expect(mockCreateMistral).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://codestral.mistral.ai/v1",
}),
)
})
it("should use custom Codestral URL when provided", () => {
new MistralHandler({
...mockOptions,
apiModelId: "codestral-latest",
mistralCodestralUrl: "https://custom.codestral.url/v1",
})
expect(mockCreateMistral).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://custom.codestral.url/v1",
}),
)
})
it("should use default Mistral URL for non-codestral models", () => {
new MistralHandler({
...mockOptions,
apiModelId: "mistral-large-latest",
})
expect(mockCreateMistral).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://api.mistral.ai/v1",
}),
)
})
})
})

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