mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
Merge remote-tracking branch 'origin/main' into bugfix/settingssave
This commit is contained in:
commit
27d3fc9613
246 changed files with 15674 additions and 13413 deletions
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
|
|
@ -1,2 +1,2 @@
|
|||
# These owners will be the default owners for everything in the repo
|
||||
* @mrubens @cte @jr
|
||||
* @mrubens @cte @jr @hannesrudolph @daniel-lxs
|
||||
|
|
|
|||
390
.github/workflows/cli-release.yml
vendored
Normal file
390
.github/workflows/cli-release.yml
vendored
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
name: CLI Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release (e.g., 0.1.0). Leave empty to use package.json version.'
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: 'Dry run (build and test but do not create release).'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
# Build CLI for each platform.
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
platform: darwin-arm64
|
||||
runs-on: macos-latest
|
||||
- os: ubuntu-latest
|
||||
platform: linux-x64
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js and pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
if [ -n "${{ inputs.version }}" ]; then
|
||||
VERSION="${{ inputs.version }}"
|
||||
else
|
||||
VERSION=$(node -p "require('./apps/cli/package.json').version")
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=cli-v$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Using version: $VERSION"
|
||||
|
||||
- name: Build extension bundle
|
||||
run: pnpm bundle
|
||||
|
||||
- name: Build CLI
|
||||
run: pnpm --filter @roo-code/cli build
|
||||
|
||||
- name: Create release tarball
|
||||
id: tarball
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
RELEASE_DIR="roo-cli-${PLATFORM}"
|
||||
TARBALL="roo-cli-${PLATFORM}.tar.gz"
|
||||
|
||||
# Clean up any previous build.
|
||||
rm -rf "$RELEASE_DIR"
|
||||
rm -f "$TARBALL"
|
||||
|
||||
# Create directory structure.
|
||||
mkdir -p "$RELEASE_DIR/bin"
|
||||
mkdir -p "$RELEASE_DIR/lib"
|
||||
mkdir -p "$RELEASE_DIR/extension"
|
||||
|
||||
# Copy CLI dist files.
|
||||
echo "Copying CLI files..."
|
||||
cp -r apps/cli/dist/* "$RELEASE_DIR/lib/"
|
||||
|
||||
# Create package.json for npm install.
|
||||
echo "Creating package.json..."
|
||||
node -e "
|
||||
const pkg = require('./apps/cli/package.json');
|
||||
const newPkg = {
|
||||
name: '@roo-code/cli',
|
||||
version: '$VERSION',
|
||||
type: 'module',
|
||||
dependencies: {
|
||||
'@inkjs/ui': pkg.dependencies['@inkjs/ui'],
|
||||
'@trpc/client': pkg.dependencies['@trpc/client'],
|
||||
'commander': pkg.dependencies.commander,
|
||||
'fuzzysort': pkg.dependencies.fuzzysort,
|
||||
'ink': pkg.dependencies.ink,
|
||||
'p-wait-for': pkg.dependencies['p-wait-for'],
|
||||
'react': pkg.dependencies.react,
|
||||
'superjson': pkg.dependencies.superjson,
|
||||
'zustand': pkg.dependencies.zustand
|
||||
}
|
||||
};
|
||||
console.log(JSON.stringify(newPkg, null, 2));
|
||||
" > "$RELEASE_DIR/package.json"
|
||||
|
||||
# Copy extension bundle.
|
||||
echo "Copying extension bundle..."
|
||||
cp -r src/dist/* "$RELEASE_DIR/extension/"
|
||||
|
||||
# Add package.json to extension directory for CommonJS.
|
||||
echo '{"type": "commonjs"}' > "$RELEASE_DIR/extension/package.json"
|
||||
|
||||
# Find and copy ripgrep binary.
|
||||
echo "Looking for ripgrep binary..."
|
||||
RIPGREP_PATH=$(find node_modules -path "*/@vscode/ripgrep/bin/rg" -type f 2>/dev/null | head -1)
|
||||
if [ -n "$RIPGREP_PATH" ] && [ -f "$RIPGREP_PATH" ]; then
|
||||
echo "Found ripgrep at: $RIPGREP_PATH"
|
||||
mkdir -p "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin"
|
||||
cp "$RIPGREP_PATH" "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/"
|
||||
chmod +x "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/rg"
|
||||
mkdir -p "$RELEASE_DIR/bin"
|
||||
cp "$RIPGREP_PATH" "$RELEASE_DIR/bin/"
|
||||
chmod +x "$RELEASE_DIR/bin/rg"
|
||||
else
|
||||
echo "Warning: ripgrep binary not found"
|
||||
fi
|
||||
|
||||
# Create the wrapper script
|
||||
echo "Creating wrapper script..."
|
||||
printf '%s\n' '#!/usr/bin/env node' \
|
||||
'' \
|
||||
"import { fileURLToPath } from 'url';" \
|
||||
"import { dirname, join } from 'path';" \
|
||||
'' \
|
||||
'const __filename = fileURLToPath(import.meta.url);' \
|
||||
'const __dirname = dirname(__filename);' \
|
||||
'' \
|
||||
'// Set environment variables for the CLI' \
|
||||
"process.env.ROO_CLI_ROOT = join(__dirname, '..');" \
|
||||
"process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension');" \
|
||||
"process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg');" \
|
||||
'' \
|
||||
'// Import and run the actual CLI' \
|
||||
"await import(join(__dirname, '..', 'lib', 'index.js'));" \
|
||||
> "$RELEASE_DIR/bin/roo"
|
||||
|
||||
chmod +x "$RELEASE_DIR/bin/roo"
|
||||
|
||||
# Create empty .env file.
|
||||
touch "$RELEASE_DIR/.env"
|
||||
|
||||
# Create tarball.
|
||||
echo "Creating tarball..."
|
||||
tar -czvf "$TARBALL" "$RELEASE_DIR"
|
||||
|
||||
# Clean up release directory.
|
||||
rm -rf "$RELEASE_DIR"
|
||||
|
||||
# Create checksum.
|
||||
if command -v sha256sum &> /dev/null; then
|
||||
sha256sum "$TARBALL" > "${TARBALL}.sha256"
|
||||
elif command -v shasum &> /dev/null; then
|
||||
shasum -a 256 "$TARBALL" > "${TARBALL}.sha256"
|
||||
fi
|
||||
|
||||
echo "tarball=$TARBALL" >> $GITHUB_OUTPUT
|
||||
echo "Created: $TARBALL"
|
||||
ls -la "$TARBALL"
|
||||
|
||||
- name: Verify tarball
|
||||
env:
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
TARBALL="roo-cli-${PLATFORM}.tar.gz"
|
||||
|
||||
# Create temp directory for verification.
|
||||
VERIFY_DIR=$(mktemp -d)
|
||||
|
||||
# Extract and verify structure.
|
||||
tar -xzf "$TARBALL" -C "$VERIFY_DIR"
|
||||
|
||||
echo "Verifying tarball contents..."
|
||||
ls -la "$VERIFY_DIR/roo-cli-${PLATFORM}/"
|
||||
|
||||
# Check required files exist.
|
||||
test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/bin/roo" || { echo "Missing bin/roo"; exit 1; }
|
||||
test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/lib/index.js" || { echo "Missing lib/index.js"; exit 1; }
|
||||
test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/package.json" || { echo "Missing package.json"; exit 1; }
|
||||
test -d "$VERIFY_DIR/roo-cli-${PLATFORM}/extension" || { echo "Missing extension directory"; exit 1; }
|
||||
|
||||
echo "Tarball verification passed!"
|
||||
|
||||
# Cleanup.
|
||||
rm -rf "$VERIFY_DIR"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cli-${{ matrix.platform }}
|
||||
path: |
|
||||
roo-cli-${{ matrix.platform }}.tar.gz
|
||||
roo-cli-${{ matrix.platform }}.tar.gz.sha256
|
||||
retention-days: 7
|
||||
|
||||
# Create GitHub release with all platform artifacts.
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !inputs.dry_run }}
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
if [ -n "${{ inputs.version }}" ]; then
|
||||
VERSION="${{ inputs.version }}"
|
||||
else
|
||||
VERSION=$(node -p "require('./apps/cli/package.json').version")
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=cli-v$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Prepare release files
|
||||
run: |
|
||||
mkdir -p release
|
||||
find artifacts -name "*.tar.gz" -exec cp {} release/ \;
|
||||
find artifacts -name "*.sha256" -exec cp {} release/ \;
|
||||
ls -la release/
|
||||
|
||||
- name: Extract changelog
|
||||
id: changelog
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
CHANGELOG_FILE="apps/cli/CHANGELOG.md"
|
||||
|
||||
if [ -f "$CHANGELOG_FILE" ]; then
|
||||
# Extract content between version headers.
|
||||
CONTENT=$(awk -v version="$VERSION" '
|
||||
BEGIN { found = 0; content = ""; target = "[" version "]" }
|
||||
/^## \[/ {
|
||||
if (found) { exit }
|
||||
if (index($0, target) > 0) { found = 1; next }
|
||||
}
|
||||
found { content = content $0 "\n" }
|
||||
END { print content }
|
||||
' "$CHANGELOG_FILE")
|
||||
|
||||
if [ -n "$CONTENT" ]; then
|
||||
echo "Found changelog content"
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "No changelog content found for version $VERSION"
|
||||
echo "content=" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
echo "No changelog file found"
|
||||
echo "content=" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Generate checksums summary
|
||||
id: checksums
|
||||
run: |
|
||||
echo "checksums<<EOF" >> $GITHUB_OUTPUT
|
||||
cat release/*.sha256 >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check for existing release
|
||||
id: check_release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
if gh release view "$TAG" &> /dev/null; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Delete existing release
|
||||
if: steps.check_release.outputs.exists == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
echo "Deleting existing release $TAG..."
|
||||
gh release delete "$TAG" --yes || true
|
||||
git push origin ":refs/tags/$TAG" || true
|
||||
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
CHANGELOG_CONTENT: ${{ steps.changelog.outputs.content }}
|
||||
CHECKSUMS: ${{ steps.checksums.outputs.checksums }}
|
||||
run: |
|
||||
NOTES_FILE=$(mktemp)
|
||||
|
||||
if [ -n "$CHANGELOG_CONTENT" ]; then
|
||||
echo "## What's New" >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo "$CHANGELOG_CONTENT" >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
fi
|
||||
|
||||
echo "## Installation" >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo '```bash' >> "$NOTES_FILE"
|
||||
echo "curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh" >> "$NOTES_FILE"
|
||||
echo '```' >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo "Or install a specific version:" >> "$NOTES_FILE"
|
||||
echo '```bash' >> "$NOTES_FILE"
|
||||
echo "ROO_VERSION=$VERSION curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh" >> "$NOTES_FILE"
|
||||
echo '```' >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo "## Requirements" >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo "- Node.js 20 or higher" >> "$NOTES_FILE"
|
||||
echo "- macOS Apple Silicon (M1/M2/M3/M4) or Linux x64" >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo "## Usage" >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo '```bash' >> "$NOTES_FILE"
|
||||
echo "# Run a task" >> "$NOTES_FILE"
|
||||
echo 'roo "What is this project?"' >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo "# See all options" >> "$NOTES_FILE"
|
||||
echo "roo --help" >> "$NOTES_FILE"
|
||||
echo '```' >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo "## Platform Support" >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo "This release includes binaries for:" >> "$NOTES_FILE"
|
||||
echo '- `roo-cli-darwin-arm64.tar.gz` - macOS Apple Silicon (M1/M2/M3)' >> "$NOTES_FILE"
|
||||
echo '- `roo-cli-linux-x64.tar.gz` - Linux x64' >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo "## Checksums" >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo '```' >> "$NOTES_FILE"
|
||||
echo "$CHECKSUMS" >> "$NOTES_FILE"
|
||||
echo '```' >> "$NOTES_FILE"
|
||||
|
||||
gh release create "$TAG" \
|
||||
--title "Roo Code CLI v$VERSION" \
|
||||
--notes-file "$NOTES_FILE" \
|
||||
--prerelease \
|
||||
release/*
|
||||
|
||||
rm -f "$NOTES_FILE"
|
||||
echo "Release created: https://github.com/${{ github.repository }}/releases/tag/$TAG"
|
||||
|
||||
# Summary job for dry runs
|
||||
summary:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ inputs.dry_run }}
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Show build summary
|
||||
run: |
|
||||
echo "## Dry Run Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The following artifacts were built:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
find artifacts -name "*.tar.gz" | while read f; do
|
||||
SIZE=$(ls -lh "$f" | awk '{print $5}')
|
||||
echo "- $(basename $f) ($SIZE)" >> $GITHUB_STEP_SUMMARY
|
||||
done
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Checksums" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
cat artifacts/*/*.sha256 >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -18,6 +18,7 @@ bin/
|
|||
|
||||
# Local prompts and rules
|
||||
/local-prompts
|
||||
AGENTS.local.md
|
||||
|
||||
# Test environment
|
||||
.test_env
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 trade‑offs
|
||||
- Provide troubleshooting playbooks (symptoms → causes → fixes → prevention)
|
||||
- Recommend targeted visuals for complex states (not step‑by‑step screenshots)
|
||||
|
||||
This mode does not generate final user documentation; it produces verification and source-material reports for docs teams.
|
||||
</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 anti‑patterns (what to avoid and why)</area>
|
||||
<area>Decision rationale and trade‑offs 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/Don’t” 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/Don’t and anti‑patterns
|
||||
- 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>
|
||||
|
|
@ -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>
|
||||
85
.roo/rules-docs-extractor/2_verification_workflow.xml
Normal file
85
.roo/rules-docs-extractor/2_verification_workflow.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
133
.roo/rules-docs-extractor/3_output_format.xml
Normal file
133
.roo/rules-docs-extractor/3_output_format.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
44
.roomodes
44
.roomodes
|
|
@ -88,27 +88,6 @@ customModes:
|
|||
- fileRegex: (apps/vscode-e2e/.*\.(ts|js)$|packages/types/.*\.ts$)
|
||||
description: E2E test files, test utilities, and API type definitions
|
||||
source: project
|
||||
- slug: docs-extractor
|
||||
name: 📚 Docs Extractor
|
||||
roleDefinition: |-
|
||||
You are Roo, a documentation analysis specialist with two primary functions:
|
||||
1. Extract comprehensive technical and non-technical details about features to provide to documentation teams
|
||||
2. Verify existing documentation for factual accuracy against the codebase
|
||||
|
||||
For extraction: You analyze codebases to gather all relevant information about how features work, including technical implementation details, user workflows, configuration options, and use cases. You organize this information clearly for documentation teams to use.
|
||||
|
||||
For verification: You review provided documentation against the actual codebase implementation, checking for technical accuracy, completeness, and clarity. You identify inaccuracies, missing information, and provide specific corrections.
|
||||
|
||||
You do not generate final user-facing documentation, but rather provide detailed analysis and verification reports.
|
||||
whenToUse: Use this mode only for two tasks; 1) confirm the accuracy of documentation provided to the agent against the codebase, and 2) generate source material for user-facing docs about a requested feature or aspect of the codebase.
|
||||
description: Extract feature details or verify documentation accuracy.
|
||||
groups:
|
||||
- read
|
||||
- - edit
|
||||
- fileRegex: (EXTRACTION-.*\.md$|VERIFICATION-.*\.md$|DOCS-TEMP-.*\.md$|\.roo/docs-extractor/.*\.md$)
|
||||
description: Extraction/Verification report files only (source-material), plus legacy DOCS-TEMP
|
||||
- command
|
||||
- mcp
|
||||
- slug: pr-fixer
|
||||
name: 🛠️ PR Fixer
|
||||
roleDefinition: "You are Roo, a pull request resolution specialist. Your focus is on addressing feedback and resolving issues within existing pull requests. Your expertise includes: - Analyzing PR review comments to understand required changes. - Checking CI/CD workflow statuses to identify failing tests. - Fetching and analyzing test logs to diagnose failures. - Identifying and resolving merge conflicts. - Guiding the user through the resolution process."
|
||||
|
|
@ -236,3 +215,26 @@ customModes:
|
|||
- command
|
||||
- mcp
|
||||
source: project
|
||||
- slug: docs-extractor
|
||||
name: 📚 Docs Extractor
|
||||
roleDefinition: |-
|
||||
You are Roo Code, a codebase analyst who extracts raw facts for documentation teams.
|
||||
You do NOT write documentation. You extract and organize information.
|
||||
|
||||
Two functions:
|
||||
1. Extract: Gather facts about a feature/aspect from the codebase
|
||||
2. Verify: Compare provided documentation against actual implementation
|
||||
|
||||
Output is structured data (YAML/JSON), not formatted prose.
|
||||
No templates, no markdown formatting, no document structure decisions.
|
||||
Let documentation-writer mode handle all writing.
|
||||
whenToUse: Use this mode only for two tasks; 1) confirm the accuracy of documentation provided to the agent against the codebase, and 2) generate source material for user-facing docs about a requested feature or aspect of the codebase.
|
||||
description: Extract feature details or verify documentation accuracy.
|
||||
groups:
|
||||
- read
|
||||
- - edit
|
||||
- fileRegex: \.roo/extraction/.*\.(yaml|json|md)$
|
||||
description: Extraction output files only
|
||||
- command
|
||||
- mcp
|
||||
source: project
|
||||
|
|
|
|||
50
CHANGELOG.md
50
CHANGELOG.md
|
|
@ -1,5 +1,55 @@
|
|||
# Roo Code Changelog
|
||||
|
||||
## [3.47.3] - 2026-02-06
|
||||
|
||||
- Remove "Enable URL context" and "Enable Grounding with Google search" checkboxes that are no longer needed (PR #11253 by @roomote)
|
||||
- Revert refactor that appended environment details into existing blocks, restoring original behavior (PR #11256 by @mrubens)
|
||||
- Revert removal of stripAppendedEnvironmentDetails and helpers, restoring necessary utility functions (PR #11255 by @mrubens)
|
||||
|
||||
## [3.47.2] - 2026-02-05
|
||||
|
||||
- Add support for .agents/skills directory (PR #11181 by @roomote)
|
||||
- Fix: Restore Gemini thought signature round-tripping after AI SDK migration (PR #11237 by @hannesrudolph)
|
||||
- Fix: Capture and round-trip thinking signature for Bedrock Claude (PR #11238 by @hannesrudolph)
|
||||
|
||||
## [3.47.1] - 2026-02-05
|
||||
|
||||
- Fix: Correct Bedrock model ID for Claude Opus 4.6, resolving model selection issues for Bedrock users (#11231 by @cogwirrel, PR #11232 by @roomote)
|
||||
- Fix: Guard against empty-string baseURL in provider constructors, preventing connection errors when baseURL is accidentally set to empty string (PR #11233 by @hannesrudolph)
|
||||
- Chore: Remove unused stripAppendedEnvironmentDetails and helpers to clean up codebase (#11228 by @hannesrudolph, PR #11226 by @hannesrudolph)
|
||||
|
||||
## [3.47.0] - 2026-02-05
|
||||
|
||||

|
||||
|
||||
- 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)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,35 @@ All notable changes to the `@roo-code/cli` package will be documented in this fi
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.0.51] - 2026-02-06
|
||||
|
||||
### Changed
|
||||
|
||||
- **Default Model Update**: Changed the default model from Opus 4.5 to Opus 4.6 for improved performance and capabilities
|
||||
|
||||
## [0.0.50] - 2026-02-05
|
||||
|
||||
### Added
|
||||
|
||||
- **Linux Support**: The CLI now supports Linux platforms in addition to macOS
|
||||
- **Roo Provider API Key Support**: Allow `--api-key` flag and `ROO_API_KEY` environment variable for the roo provider instead of requiring cloud auth token
|
||||
- **Exit on Error**: New `--exit-on-error` flag to exit immediately on API request errors instead of retrying, useful for CI/CD pipelines
|
||||
|
||||
### Changed
|
||||
|
||||
- **Improved Dev Experience**: Dev scripts now use `tsx` for running directly from source without building first
|
||||
- **Path Resolution Fixes**: Fixed path resolution in [`version.ts`](src/lib/utils/version.ts), [`extension.ts`](src/lib/utils/extension.ts), and [`extension-host.ts`](src/agent/extension-host.ts) to work from both source and bundled locations
|
||||
- **Debug Logging**: Debug log file (`~/.roo/cli-debug.log`) is now disabled by default unless `--debug` flag is passed
|
||||
- Updated README with complete environment variable table and dev workflow documentation
|
||||
|
||||
### Fixed
|
||||
|
||||
- Corrected example in install script
|
||||
|
||||
### Removed
|
||||
|
||||
- Dropped macOS 13 support
|
||||
|
||||
## [0.0.49] - 2026-01-18
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@roo-code/cli",
|
||||
"version": "0.0.49",
|
||||
"version": "0.0.51",
|
||||
"description": "Roo Code CLI - Run the Roo Code agent from the command line",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
@ -15,11 +15,8 @@
|
|||
"test": "vitest run",
|
||||
"build": "tsup",
|
||||
"build:extension": "pnpm --filter roo-cline bundle",
|
||||
"build:all": "pnpm --filter roo-cline bundle && tsup",
|
||||
"dev": "tsup --watch",
|
||||
"start": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy node dist/index.js",
|
||||
"start:production": "node dist/index.js",
|
||||
"release": "scripts/release.sh",
|
||||
"dev": "ROO_AUTH_BASE_URL=https://app.roocode.com ROO_SDK_BASE_URL=https://cloud-api.roocode.com ROO_CODE_PROVIDER_URL=https://api.roocode.com/proxy tsx src/index.ts -y",
|
||||
"dev:local": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy tsx src/index.ts",
|
||||
"clean": "rimraf dist .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
343
apps/cli/scripts/build.sh
Executable file
343
apps/cli/scripts/build.sh
Executable 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export type FlagOptions = {
|
|||
debug: boolean
|
||||
yes: boolean
|
||||
dangerouslySkipPermissions: boolean
|
||||
exitOnError: boolean
|
||||
apiKey?: string
|
||||
provider?: SupportedProvider
|
||||
model?: string
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@roo-code/types",
|
||||
"version": "1.107.0",
|
||||
"version": "1.110.0",
|
||||
"description": "TypeScript type definitions for Roo Code.",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ export const organizationDefaultSettingsSchema = globalSettingsSchema
|
|||
terminalShellIntegrationDisabled: true,
|
||||
terminalShellIntegrationTimeout: true,
|
||||
terminalZshClearEolMark: true,
|
||||
disabledTools: true,
|
||||
})
|
||||
// Add stronger validations for some fields.
|
||||
.merge(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from "zod"
|
||||
|
||||
import { clineMessageSchema, queuedMessageSchema, tokenUsageSchema } from "./message.js"
|
||||
import { modelInfoSchema } from "./model.js"
|
||||
import { toolNamesSchema, toolUsageSchema } from "./tool.js"
|
||||
|
||||
/**
|
||||
|
|
@ -45,6 +46,11 @@ export enum RooCodeEventName {
|
|||
ModeChanged = "modeChanged",
|
||||
ProviderProfileChanged = "providerProfileChanged",
|
||||
|
||||
// Query Responses
|
||||
CommandsResponse = "commandsResponse",
|
||||
ModesResponse = "modesResponse",
|
||||
ModelsResponse = "modelsResponse",
|
||||
|
||||
// Evals
|
||||
EvalPass = "evalPass",
|
||||
EvalFail = "evalFail",
|
||||
|
|
@ -108,6 +114,20 @@ export const rooCodeEventsSchema = z.object({
|
|||
|
||||
[RooCodeEventName.ModeChanged]: z.tuple([z.string()]),
|
||||
[RooCodeEventName.ProviderProfileChanged]: z.tuple([z.object({ name: z.string(), provider: z.string() })]),
|
||||
|
||||
[RooCodeEventName.CommandsResponse]: z.tuple([
|
||||
z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
source: z.enum(["global", "project", "built-in"]),
|
||||
filePath: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
argumentHint: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
]),
|
||||
[RooCodeEventName.ModesResponse]: z.tuple([z.array(z.object({ slug: z.string(), name: z.string() }))]),
|
||||
[RooCodeEventName.ModelsResponse]: z.tuple([z.record(z.string(), modelInfoSchema)]),
|
||||
})
|
||||
|
||||
export type RooCodeEvents = z.infer<typeof rooCodeEventsSchema>
|
||||
|
|
@ -237,6 +257,23 @@ export const taskEventSchema = z.discriminatedUnion("eventName", [
|
|||
taskId: z.number().optional(),
|
||||
}),
|
||||
|
||||
// Query Responses
|
||||
z.object({
|
||||
eventName: z.literal(RooCodeEventName.CommandsResponse),
|
||||
payload: rooCodeEventsSchema.shape[RooCodeEventName.CommandsResponse],
|
||||
taskId: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
eventName: z.literal(RooCodeEventName.ModesResponse),
|
||||
payload: rooCodeEventsSchema.shape[RooCodeEventName.ModesResponse],
|
||||
taskId: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
eventName: z.literal(RooCodeEventName.ModelsResponse),
|
||||
payload: rooCodeEventsSchema.shape[RooCodeEventName.ModelsResponse],
|
||||
taskId: z.number().optional(),
|
||||
}),
|
||||
|
||||
// Evals
|
||||
z.object({
|
||||
eventName: z.literal(RooCodeEventName.EvalPass),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { experimentsSchema } from "./experiment.js"
|
|||
import { telemetrySettingsSchema } from "./telemetry.js"
|
||||
import { modeConfigSchema } from "./mode.js"
|
||||
import { customModePromptsSchema, customSupportPromptsSchema } from "./mode.js"
|
||||
import { toolNamesSchema } from "./tool.js"
|
||||
import { languagesSchema } from "./vscode.js"
|
||||
|
||||
/**
|
||||
|
|
@ -232,6 +233,12 @@ export const globalSettingsSchema = z.object({
|
|||
* @default true
|
||||
*/
|
||||
showWorktreesInHomeScreen: z.boolean().optional(),
|
||||
|
||||
/**
|
||||
* List of native tool names to globally disable.
|
||||
* Tools in this list will be excluded from prompt generation and rejected at execution time.
|
||||
*/
|
||||
disabledTools: z.array(toolNamesSchema).optional(),
|
||||
})
|
||||
|
||||
export type GlobalSettings = z.infer<typeof globalSettingsSchema>
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ export enum TaskCommandName {
|
|||
CloseTask = "CloseTask",
|
||||
ResumeTask = "ResumeTask",
|
||||
SendMessage = "SendMessage",
|
||||
GetCommands = "GetCommands",
|
||||
GetModes = "GetModes",
|
||||
GetModels = "GetModels",
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -79,6 +82,15 @@ export const taskCommandSchema = z.discriminatedUnion("commandName", [
|
|||
images: z.array(z.string()).optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
commandName: z.literal(TaskCommandName.GetCommands),
|
||||
}),
|
||||
z.object({
|
||||
commandName: z.literal(TaskCommandName.GetModes),
|
||||
}),
|
||||
z.object({
|
||||
commandName: z.literal(TaskCommandName.GetModels),
|
||||
}),
|
||||
])
|
||||
|
||||
export type TaskCommand = z.infer<typeof taskCommandSchema>
|
||||
|
|
|
|||
|
|
@ -227,8 +227,6 @@ const vertexSchema = apiModelIdProviderModelSchema.extend({
|
|||
vertexJsonCredentials: z.string().optional(),
|
||||
vertexProjectId: z.string().optional(),
|
||||
vertexRegion: z.string().optional(),
|
||||
enableUrlContext: z.boolean().optional(),
|
||||
enableGrounding: z.boolean().optional(),
|
||||
vertex1MContext: z.boolean().optional(), // Enable 'context-1m-2025-08-07' beta for 1M context window.
|
||||
})
|
||||
|
||||
|
|
@ -273,8 +271,6 @@ const lmStudioSchema = baseProviderSettingsSchema.extend({
|
|||
const geminiSchema = apiModelIdProviderModelSchema.extend({
|
||||
geminiApiKey: z.string().optional(),
|
||||
googleGeminiBaseUrl: z.string().optional(),
|
||||
enableUrlContext: z.boolean().optional(),
|
||||
enableGrounding: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const geminiCliSchema = apiModelIdProviderModelSchema.extend({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -334,7 +334,9 @@ export type ExtensionState = Pick<
|
|||
| "maxGitStatusFiles"
|
||||
| "requestDelaySeconds"
|
||||
| "showWorktreesInHomeScreen"
|
||||
| "disabledTools"
|
||||
> & {
|
||||
lockApiConfigAcrossModes?: boolean
|
||||
version: string
|
||||
clineMessages: ClineMessage[]
|
||||
currentTaskItem?: HistoryItem
|
||||
|
|
@ -523,6 +525,7 @@ export interface WebviewMessage {
|
|||
| "searchFiles"
|
||||
| "toggleApiConfigPin"
|
||||
| "hasOpenedModeSelector"
|
||||
| "lockApiConfigAcrossModes"
|
||||
| "clearCloudAuthSkipModel"
|
||||
| "cloudButtonClicked"
|
||||
| "rooCloudSignIn"
|
||||
|
|
@ -605,6 +608,7 @@ export interface WebviewMessage {
|
|||
| "createSkill"
|
||||
| "deleteSkill"
|
||||
| "moveSkill"
|
||||
| "updateSkillModes"
|
||||
| "openSkillFile"
|
||||
text?: string
|
||||
editedMessageContent?: string
|
||||
|
|
@ -641,9 +645,15 @@ export interface WebviewMessage {
|
|||
payload?: WebViewMessagePayload
|
||||
source?: "global" | "project" | "built-in"
|
||||
skillName?: string // For skill operations (createSkill, deleteSkill, moveSkill, openSkillFile)
|
||||
/** @deprecated Use skillModeSlugs instead */
|
||||
skillMode?: string // For skill operations (current mode restriction)
|
||||
/** @deprecated Use newSkillModeSlugs instead */
|
||||
newSkillMode?: string // For moveSkill (target mode)
|
||||
skillDescription?: string // For createSkill (skill description)
|
||||
/** Mode slugs for skill operations. undefined/empty = any mode */
|
||||
skillModeSlugs?: string[] // For skill operations (mode restrictions)
|
||||
/** Target mode slugs for updateSkillModes */
|
||||
newSkillModeSlugs?: string[] // For updateSkillModes (new mode restrictions)
|
||||
requestId?: string
|
||||
ids?: string[]
|
||||
hasSystemPromptOverride?: boolean
|
||||
|
|
|
|||
875
pnpm-lock.yaml
generated
875
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
BIN
releases/3.47.0-release.png
Normal file
BIN
releases/3.47.0-release.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
|
|
@ -117,6 +117,15 @@ export interface ApiHandler {
|
|||
* @returns A promise resolving to the token count
|
||||
*/
|
||||
countTokens(content: Array<Anthropic.Messages.ContentBlockParam>): Promise<number>
|
||||
|
||||
/**
|
||||
* Indicates whether this provider uses the Vercel AI SDK for streaming.
|
||||
* AI SDK providers handle reasoning blocks differently and need to preserve
|
||||
* them in conversation history for proper round-tripping.
|
||||
*
|
||||
* @returns true if the provider uses AI SDK, false otherwise
|
||||
*/
|
||||
isAiSdkProvider(): boolean
|
||||
}
|
||||
|
||||
export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
|
||||
|
|
|
|||
446
src/api/providers/__tests__/baseten.spec.ts
Normal file
446
src/api/providers/__tests__/baseten.spec.ts
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
// npx vitest run src/api/providers/__tests__/baseten.spec.ts
|
||||
|
||||
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
|
||||
const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
|
||||
mockStreamText: vi.fn(),
|
||||
mockGenerateText: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
...actual,
|
||||
streamText: mockStreamText,
|
||||
generateText: mockGenerateText,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@ai-sdk/baseten", () => ({
|
||||
createBaseten: vi.fn(() => {
|
||||
return vi.fn(() => ({
|
||||
modelId: "zai-org/GLM-4.6",
|
||||
provider: "baseten",
|
||||
}))
|
||||
}),
|
||||
}))
|
||||
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { basetenDefaultModelId, basetenModels, type BasetenModelId } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
import { BasetenHandler } from "../baseten"
|
||||
|
||||
describe("BasetenHandler", () => {
|
||||
let handler: BasetenHandler
|
||||
let mockOptions: ApiHandlerOptions
|
||||
|
||||
beforeEach(() => {
|
||||
mockOptions = {
|
||||
basetenApiKey: "test-baseten-api-key",
|
||||
apiModelId: "zai-org/GLM-4.6",
|
||||
}
|
||||
handler = new BasetenHandler(mockOptions)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should initialize with provided options", () => {
|
||||
expect(handler).toBeInstanceOf(BasetenHandler)
|
||||
expect(handler.getModel().id).toBe(mockOptions.apiModelId)
|
||||
})
|
||||
|
||||
it("should use default model ID if not provided", () => {
|
||||
const handlerWithoutModel = new BasetenHandler({
|
||||
...mockOptions,
|
||||
apiModelId: undefined,
|
||||
})
|
||||
expect(handlerWithoutModel.getModel().id).toBe(basetenDefaultModelId)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return default model when no model is specified", () => {
|
||||
const handlerWithoutModel = new BasetenHandler({
|
||||
basetenApiKey: "test-baseten-api-key",
|
||||
})
|
||||
const model = handlerWithoutModel.getModel()
|
||||
expect(model.id).toBe(basetenDefaultModelId)
|
||||
expect(model.info).toEqual(basetenModels[basetenDefaultModelId])
|
||||
})
|
||||
|
||||
it("should return specified model when valid model is provided", () => {
|
||||
const testModelId: BasetenModelId = "deepseek-ai/DeepSeek-R1"
|
||||
const handlerWithModel = new BasetenHandler({
|
||||
apiModelId: testModelId,
|
||||
basetenApiKey: "test-baseten-api-key",
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.id).toBe(testModelId)
|
||||
expect(model.info).toEqual(basetenModels[testModelId])
|
||||
})
|
||||
|
||||
it("should return provided model ID with default model info if model does not exist", () => {
|
||||
const handlerWithInvalidModel = new BasetenHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "invalid-model",
|
||||
})
|
||||
const model = handlerWithInvalidModel.getModel()
|
||||
expect(model.id).toBe("invalid-model")
|
||||
expect(model.info).toBeDefined()
|
||||
expect(model.info).toBe(basetenModels[basetenDefaultModelId])
|
||||
})
|
||||
|
||||
it("should include model parameters from getModelParams", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model).toHaveProperty("temperature")
|
||||
expect(model).toHaveProperty("maxTokens")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "Hello!",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
it("should handle streaming responses", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response from Baseten" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks).toHaveLength(1)
|
||||
expect(textChunks[0].text).toBe("Test response from Baseten")
|
||||
})
|
||||
|
||||
it("should include usage information", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0].inputTokens).toBe(10)
|
||||
expect(usageChunks[0].outputTokens).toBe(20)
|
||||
})
|
||||
|
||||
it("should pass correct temperature (0.5 default) to streamText", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
})
|
||||
|
||||
const handlerWithDefaultTemp = new BasetenHandler({
|
||||
basetenApiKey: "test-key",
|
||||
apiModelId: "zai-org/GLM-4.6",
|
||||
})
|
||||
|
||||
const stream = handlerWithDefaultTemp.createMessage(systemPrompt, messages)
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 0.5,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use user-specified temperature over default", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
})
|
||||
|
||||
const handlerWithCustomTemp = new BasetenHandler({
|
||||
basetenApiKey: "test-key",
|
||||
apiModelId: "zai-org/GLM-4.6",
|
||||
modelTemperature: 0.9,
|
||||
})
|
||||
|
||||
const stream = handlerWithCustomTemp.createMessage(systemPrompt, messages)
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 0.9,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle stream with multiple chunks", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Hello" }
|
||||
yield { type: "text-delta", text: " world" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 5, outputTokens: 10 }),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const textChunks = chunks.filter((c) => c.type === "text")
|
||||
expect(textChunks[0]).toEqual({ type: "text", text: "Hello" })
|
||||
expect(textChunks[1]).toEqual({ type: "text", text: " world" })
|
||||
|
||||
const usageChunks = chunks.filter((c) => c.type === "usage")
|
||||
expect(usageChunks[0]).toMatchObject({ type: "usage", inputTokens: 5, outputTokens: 10 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("completePrompt", () => {
|
||||
it("should complete a prompt using generateText", async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test completion from Baseten",
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
|
||||
expect(result).toBe("Test completion from Baseten")
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "Test prompt",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use default temperature in completePrompt", async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test completion",
|
||||
})
|
||||
|
||||
await handler.completePrompt("Test prompt")
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 0.5,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool handling", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text" as const, text: "Hello!" }],
|
||||
},
|
||||
]
|
||||
|
||||
it("should handle tool calls in streaming", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield {
|
||||
type: "tool-input-start",
|
||||
id: "tool-call-1",
|
||||
toolName: "read_file",
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-delta",
|
||||
id: "tool-call-1",
|
||||
delta: '{"path":"test.ts"}',
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-end",
|
||||
id: "tool-call-1",
|
||||
}
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages, {
|
||||
taskId: "test-task",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
description: "Read a file",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string" } },
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start")
|
||||
const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta")
|
||||
const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end")
|
||||
|
||||
expect(toolCallStartChunks.length).toBe(1)
|
||||
expect(toolCallStartChunks[0].id).toBe("tool-call-1")
|
||||
expect(toolCallStartChunks[0].name).toBe("read_file")
|
||||
|
||||
expect(toolCallDeltaChunks.length).toBe(1)
|
||||
expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}')
|
||||
|
||||
expect(toolCallEndChunks.length).toBe(1)
|
||||
expect(toolCallEndChunks[0].id).toBe("tool-call-1")
|
||||
})
|
||||
|
||||
it("should ignore tool-call events to prevent duplicate tools in UI", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield {
|
||||
type: "tool-call",
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "read_file",
|
||||
input: { path: "test.ts" },
|
||||
}
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const toolCallChunks = chunks.filter(
|
||||
(c) => c.type === "tool_call_start" || c.type === "tool_call_delta" || c.type === "tool_call_end",
|
||||
)
|
||||
expect(toolCallChunks.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text" as const, text: "Hello!" }],
|
||||
},
|
||||
]
|
||||
|
||||
it("should handle AI SDK errors with handleAiSdkError", async () => {
|
||||
// eslint-disable-next-line require-yield
|
||||
async function* mockFullStream(): AsyncGenerator<any> {
|
||||
throw new Error("API Error")
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
}).rejects.toThrow("Baseten: API Error")
|
||||
})
|
||||
|
||||
it("should preserve status codes in error handling", async () => {
|
||||
const apiError = new Error("Rate limit exceeded")
|
||||
;(apiError as any).status = 429
|
||||
|
||||
// eslint-disable-next-line require-yield
|
||||
async function* mockFullStream(): AsyncGenerator<any> {
|
||||
throw apiError
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
|
||||
try {
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
expect.fail("Should have thrown an error")
|
||||
} catch (error: any) {
|
||||
expect(error.message).toContain("Baseten")
|
||||
expect(error.status).toBe(429)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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> = {}) => {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,336 +1,490 @@
|
|||
// npx vitest run api/providers/__tests__/chutes.spec.ts
|
||||
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
const { mockStreamText, mockGenerateText, mockGetModels, mockGetModelsFromCache } = vi.hoisted(() => ({
|
||||
mockStreamText: vi.fn(),
|
||||
mockGenerateText: vi.fn(),
|
||||
mockGetModels: vi.fn(),
|
||||
mockGetModelsFromCache: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
...actual,
|
||||
streamText: mockStreamText,
|
||||
generateText: mockGenerateText,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@ai-sdk/openai-compatible", () => ({
|
||||
createOpenAICompatible: vi.fn(() => {
|
||||
return vi.fn((modelId: string) => ({
|
||||
modelId,
|
||||
provider: "chutes",
|
||||
}))
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("../fetchers/modelCache", () => ({
|
||||
getModels: mockGetModels,
|
||||
getModelsFromCache: mockGetModelsFromCache,
|
||||
}))
|
||||
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { chutesDefaultModelId, chutesDefaultModelInfo, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types"
|
||||
|
||||
import { ChutesHandler } from "../chutes"
|
||||
|
||||
// Create mock functions
|
||||
const mockCreate = vi.fn()
|
||||
const mockFetchModel = vi.fn()
|
||||
|
||||
// Mock OpenAI module
|
||||
vi.mock("openai", () => ({
|
||||
default: vi.fn(() => ({
|
||||
chat: {
|
||||
completions: {
|
||||
create: mockCreate,
|
||||
},
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
describe("ChutesHandler", () => {
|
||||
let handler: ChutesHandler
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Set up default mock implementation
|
||||
mockCreate.mockImplementation(async () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "Test response" },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
handler = new ChutesHandler({ chutesApiKey: "test-key" })
|
||||
// Mock fetchModel to return default model
|
||||
mockFetchModel.mockResolvedValue({
|
||||
id: chutesDefaultModelId,
|
||||
info: chutesDefaultModelInfo,
|
||||
mockGetModels.mockResolvedValue({
|
||||
[chutesDefaultModelId]: chutesDefaultModelInfo,
|
||||
})
|
||||
handler.fetchModel = mockFetchModel
|
||||
mockGetModelsFromCache.mockReturnValue(undefined)
|
||||
handler = new ChutesHandler({ chutesApiKey: "test-key" })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should use the correct Chutes base URL", () => {
|
||||
new ChutesHandler({ chutesApiKey: "test-chutes-api-key" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://llm.chutes.ai/v1" }))
|
||||
describe("constructor", () => {
|
||||
it("should initialize with provided options", () => {
|
||||
expect(handler).toBeInstanceOf(ChutesHandler)
|
||||
})
|
||||
|
||||
it("should use default model when no model ID is provided", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe(chutesDefaultModelId)
|
||||
})
|
||||
})
|
||||
|
||||
it("should use the provided API key", () => {
|
||||
const chutesApiKey = "test-chutes-api-key"
|
||||
new ChutesHandler({ chutesApiKey })
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: chutesApiKey }))
|
||||
describe("getModel", () => {
|
||||
it("should return default model when no model is specified and no cache", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe(chutesDefaultModelId)
|
||||
expect(model.info).toEqual(
|
||||
expect.objectContaining({
|
||||
...chutesDefaultModelInfo,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should return model info from fetched models", async () => {
|
||||
const testModelInfo = {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 128000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
}
|
||||
mockGetModels.mockResolvedValue({
|
||||
"some-model": testModelInfo,
|
||||
})
|
||||
|
||||
const handlerWithModel = new ChutesHandler({
|
||||
apiModelId: "some-model",
|
||||
chutesApiKey: "test-key",
|
||||
})
|
||||
const model = await handlerWithModel.fetchModel()
|
||||
expect(model.id).toBe("some-model")
|
||||
expect(model.info).toEqual(expect.objectContaining(testModelInfo))
|
||||
})
|
||||
|
||||
it("should fall back to global cache when instance models are empty", () => {
|
||||
const cachedInfo = {
|
||||
maxTokens: 2048,
|
||||
contextWindow: 64000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
}
|
||||
mockGetModelsFromCache.mockReturnValue({
|
||||
"cached-model": cachedInfo,
|
||||
})
|
||||
|
||||
const handlerWithModel = new ChutesHandler({
|
||||
apiModelId: "cached-model",
|
||||
chutesApiKey: "test-key",
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.id).toBe("cached-model")
|
||||
expect(model.info).toEqual(expect.objectContaining(cachedInfo))
|
||||
})
|
||||
|
||||
it("should apply DeepSeek default temperature for R1 models", () => {
|
||||
const r1Info = {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 163840,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
}
|
||||
mockGetModelsFromCache.mockReturnValue({
|
||||
"deepseek-ai/DeepSeek-R1-0528": r1Info,
|
||||
})
|
||||
|
||||
const handlerWithModel = new ChutesHandler({
|
||||
apiModelId: "deepseek-ai/DeepSeek-R1-0528",
|
||||
chutesApiKey: "test-key",
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.info.defaultTemperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE)
|
||||
expect(model.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE)
|
||||
})
|
||||
|
||||
it("should use default temperature for non-DeepSeek models", () => {
|
||||
const modelInfo = {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 128000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
}
|
||||
mockGetModelsFromCache.mockReturnValue({
|
||||
"unsloth/Llama-3.3-70B-Instruct": modelInfo,
|
||||
})
|
||||
|
||||
const handlerWithModel = new ChutesHandler({
|
||||
apiModelId: "unsloth/Llama-3.3-70B-Instruct",
|
||||
chutesApiKey: "test-key",
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.info.defaultTemperature).toBe(0.5)
|
||||
expect(model.temperature).toBe(0.5)
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle DeepSeek R1 reasoning format", async () => {
|
||||
// Override the mock for this specific test
|
||||
mockCreate.mockImplementationOnce(async () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "<think>Thinking..." },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "</think>Hello" },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5 },
|
||||
}
|
||||
},
|
||||
}))
|
||||
describe("fetchModel", () => {
|
||||
it("should fetch models and return the resolved model", async () => {
|
||||
const model = await handler.fetchModel()
|
||||
expect(mockGetModels).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "chutes",
|
||||
}),
|
||||
)
|
||||
expect(model.id).toBe(chutesDefaultModelId)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }]
|
||||
mockFetchModel.mockResolvedValueOnce({
|
||||
id: "deepseek-ai/DeepSeek-R1-0528",
|
||||
info: { maxTokens: 1024, temperature: 0.7 },
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
it("should handle non-DeepSeek models with standard streaming", async () => {
|
||||
mockGetModels.mockResolvedValue({
|
||||
"some-other-model": { maxTokens: 1024, contextWindow: 8192, supportsPromptCache: false },
|
||||
})
|
||||
|
||||
expect(chunks).toEqual([
|
||||
{ type: "reasoning", text: "Thinking..." },
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "usage", inputTokens: 10, outputTokens: 5 },
|
||||
])
|
||||
})
|
||||
|
||||
it("should handle non-DeepSeek models", async () => {
|
||||
// Use default mock implementation which returns text content
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }]
|
||||
mockFetchModel.mockResolvedValueOnce({
|
||||
id: "some-other-model",
|
||||
info: { maxTokens: 1024, temperature: 0.7 },
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks).toEqual([
|
||||
{ type: "text", text: "Test response" },
|
||||
{ type: "usage", inputTokens: 10, outputTokens: 5 },
|
||||
])
|
||||
})
|
||||
|
||||
it("should return default model when no model is specified", async () => {
|
||||
const model = await handler.fetchModel()
|
||||
expect(model.id).toBe(chutesDefaultModelId)
|
||||
expect(model.info).toEqual(expect.objectContaining(chutesDefaultModelInfo))
|
||||
})
|
||||
|
||||
it("should return specified model when valid model is provided", async () => {
|
||||
const testModelId = "deepseek-ai/DeepSeek-R1"
|
||||
const handlerWithModel = new ChutesHandler({
|
||||
apiModelId: testModelId,
|
||||
chutesApiKey: "test-chutes-api-key",
|
||||
})
|
||||
// Mock fetchModel for this handler to return the test model from dynamic fetch
|
||||
handlerWithModel.fetchModel = vi.fn().mockResolvedValue({
|
||||
id: testModelId,
|
||||
info: { maxTokens: 32768, contextWindow: 163840, supportsImages: false, supportsPromptCache: false },
|
||||
})
|
||||
const model = await handlerWithModel.fetchModel()
|
||||
expect(model.id).toBe(testModelId)
|
||||
})
|
||||
|
||||
it("completePrompt method should return text from Chutes API", async () => {
|
||||
const expectedResponse = "This is a test response from Chutes"
|
||||
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] })
|
||||
const result = await handler.completePrompt("test prompt")
|
||||
expect(result).toBe(expectedResponse)
|
||||
})
|
||||
|
||||
it("should handle errors in completePrompt", async () => {
|
||||
const errorMessage = "Chutes API error"
|
||||
mockCreate.mockRejectedValueOnce(new Error(errorMessage))
|
||||
await expect(handler.completePrompt("test prompt")).rejects.toThrow(`Chutes completion error: ${errorMessage}`)
|
||||
})
|
||||
|
||||
it("createMessage should yield text content from stream", async () => {
|
||||
const testContent = "This is test content from Chutes stream"
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: { content: testContent } }] },
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({ type: "text", text: testContent })
|
||||
})
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
it("createMessage should yield usage data from stream", async () => {
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } },
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
const handlerWithModel = new ChutesHandler({
|
||||
apiModelId: "some-other-model",
|
||||
chutesApiKey: "test-key",
|
||||
})
|
||||
|
||||
const stream = handlerWithModel.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({ type: "usage", inputTokens: 10, outputTokens: 20 })
|
||||
})
|
||||
|
||||
it("createMessage should yield tool_call_partial from stream", async () => {
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_123",
|
||||
function: { name: "test_tool", arguments: '{"arg":"value"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({
|
||||
type: "tool_call_partial",
|
||||
index: 0,
|
||||
id: "call_123",
|
||||
name: "test_tool",
|
||||
arguments: '{"arg":"value"}',
|
||||
})
|
||||
})
|
||||
|
||||
it("createMessage should pass tools and tool_choice to API", async () => {
|
||||
const tools = [
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "test_tool",
|
||||
description: "A test tool",
|
||||
parameters: { type: "object", properties: {} },
|
||||
expect(chunks).toEqual([
|
||||
{ type: "text", text: "Test response" },
|
||||
{
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
},
|
||||
},
|
||||
]
|
||||
const tool_choice = "auto" as const
|
||||
])
|
||||
})
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi.fn().mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
it("should handle DeepSeek R1 reasoning format with TagMatcher", async () => {
|
||||
mockGetModels.mockResolvedValue({
|
||||
"deepseek-ai/DeepSeek-R1-0528": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 163840,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
})
|
||||
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "<think>Thinking..." }
|
||||
yield { type: "text-delta", text: "</think>Hello" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
const handlerWithModel = new ChutesHandler({
|
||||
apiModelId: "deepseek-ai/DeepSeek-R1-0528",
|
||||
chutesApiKey: "test-key",
|
||||
})
|
||||
|
||||
const stream = handlerWithModel.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks).toEqual([
|
||||
{ type: "reasoning", text: "Thinking..." },
|
||||
{ type: "text", text: "Hello" },
|
||||
{
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [], { tools, tool_choice, taskId: "test-task-id" })
|
||||
// Consume stream
|
||||
for await (const _ of stream) {
|
||||
// noop
|
||||
}
|
||||
it("should handle tool calls in R1 path", async () => {
|
||||
mockGetModels.mockResolvedValue({
|
||||
"deepseek-ai/DeepSeek-R1-0528": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 163840,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools,
|
||||
tool_choice,
|
||||
}),
|
||||
)
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Let me help" }
|
||||
yield {
|
||||
type: "tool-input-start",
|
||||
id: "call_123",
|
||||
toolName: "test_tool",
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-delta",
|
||||
id: "call_123",
|
||||
delta: '{"arg":"value"}',
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-end",
|
||||
id: "call_123",
|
||||
}
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 15,
|
||||
outputTokens: 10,
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
const handlerWithModel = new ChutesHandler({
|
||||
apiModelId: "deepseek-ai/DeepSeek-R1-0528",
|
||||
chutesApiKey: "test-key",
|
||||
})
|
||||
|
||||
const stream = handlerWithModel.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks).toContainEqual({ type: "text", text: "Let me help" })
|
||||
expect(chunks).toContainEqual({
|
||||
type: "tool_call_start",
|
||||
id: "call_123",
|
||||
name: "test_tool",
|
||||
})
|
||||
expect(chunks).toContainEqual({
|
||||
type: "tool_call_delta",
|
||||
id: "call_123",
|
||||
delta: '{"arg":"value"}',
|
||||
})
|
||||
expect(chunks).toContainEqual({
|
||||
type: "tool_call_end",
|
||||
id: "call_123",
|
||||
})
|
||||
})
|
||||
|
||||
it("should merge system prompt into first user message for R1 path", async () => {
|
||||
mockGetModels.mockResolvedValue({
|
||||
"deepseek-ai/DeepSeek-R1-0528": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 163840,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
})
|
||||
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Response" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 5, outputTokens: 3 }),
|
||||
})
|
||||
|
||||
const handlerWithModel = new ChutesHandler({
|
||||
apiModelId: "deepseek-ai/DeepSeek-R1-0528",
|
||||
chutesApiKey: "test-key",
|
||||
})
|
||||
|
||||
const stream = handlerWithModel.createMessage(systemPrompt, messages)
|
||||
for await (const _ of stream) {
|
||||
// consume
|
||||
}
|
||||
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: expect.any(Array),
|
||||
}),
|
||||
)
|
||||
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
expect(callArgs.system).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should pass system prompt separately for non-R1 path", async () => {
|
||||
mockGetModels.mockResolvedValue({
|
||||
"some-model": { maxTokens: 1024, contextWindow: 8192, supportsPromptCache: false },
|
||||
})
|
||||
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Response" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 5, outputTokens: 3 }),
|
||||
})
|
||||
|
||||
const handlerWithModel = new ChutesHandler({
|
||||
apiModelId: "some-model",
|
||||
chutesApiKey: "test-key",
|
||||
})
|
||||
|
||||
const stream = handlerWithModel.createMessage(systemPrompt, messages)
|
||||
for await (const _ of stream) {
|
||||
// consume
|
||||
}
|
||||
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
system: systemPrompt,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should include usage information from stream", async () => {
|
||||
mockGetModels.mockResolvedValue({
|
||||
"some-model": { maxTokens: 1024, contextWindow: 8192, supportsPromptCache: false },
|
||||
})
|
||||
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Hello" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({
|
||||
inputTokens: 20,
|
||||
outputTokens: 10,
|
||||
}),
|
||||
})
|
||||
|
||||
const handlerWithModel = new ChutesHandler({
|
||||
apiModelId: "some-model",
|
||||
chutesApiKey: "test-key",
|
||||
})
|
||||
|
||||
const stream = handlerWithModel.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunks = chunks.filter((c) => c.type === "usage")
|
||||
expect(usageChunks).toHaveLength(1)
|
||||
expect(usageChunks[0].inputTokens).toBe(20)
|
||||
expect(usageChunks[0].outputTokens).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
it("should apply DeepSeek default temperature for R1 models", () => {
|
||||
const testModelId = "deepseek-ai/DeepSeek-R1"
|
||||
const handlerWithModel = new ChutesHandler({
|
||||
apiModelId: testModelId,
|
||||
chutesApiKey: "test-chutes-api-key",
|
||||
describe("completePrompt", () => {
|
||||
it("should return text from generateText", async () => {
|
||||
const expectedResponse = "This is a test response from Chutes"
|
||||
mockGenerateText.mockResolvedValue({ text: expectedResponse })
|
||||
|
||||
const result = await handler.completePrompt("test prompt")
|
||||
expect(result).toBe(expectedResponse)
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "test prompt",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle errors in completePrompt", async () => {
|
||||
const errorMessage = "Chutes API error"
|
||||
mockGenerateText.mockRejectedValue(new Error(errorMessage))
|
||||
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
|
||||
`Chutes completion error: ${errorMessage}`,
|
||||
)
|
||||
})
|
||||
|
||||
it("should pass temperature for R1 models in completePrompt", async () => {
|
||||
mockGetModels.mockResolvedValue({
|
||||
"deepseek-ai/DeepSeek-R1-0528": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 163840,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
})
|
||||
|
||||
mockGenerateText.mockResolvedValue({ text: "response" })
|
||||
|
||||
const handlerWithModel = new ChutesHandler({
|
||||
apiModelId: "deepseek-ai/DeepSeek-R1-0528",
|
||||
chutesApiKey: "test-key",
|
||||
})
|
||||
|
||||
await handlerWithModel.completePrompt("test prompt")
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: DEEP_SEEK_DEFAULT_TEMPERATURE,
|
||||
}),
|
||||
)
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.info.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE)
|
||||
})
|
||||
|
||||
it("should use default temperature for non-DeepSeek models", () => {
|
||||
const testModelId = "unsloth/Llama-3.3-70B-Instruct"
|
||||
const handlerWithModel = new ChutesHandler({
|
||||
apiModelId: testModelId,
|
||||
chutesApiKey: "test-chutes-api-key",
|
||||
describe("isAiSdkProvider", () => {
|
||||
it("should return true", () => {
|
||||
expect(handler.isAiSdkProvider()).toBe(true)
|
||||
})
|
||||
// Note: getModel() returns fallback default without calling fetchModel
|
||||
// Since we haven't called fetchModel, it returns the default chutesDefaultModelId
|
||||
// which is DeepSeek-R1-0528, therefore temperature will be DEEP_SEEK_DEFAULT_TEMPERATURE
|
||||
const model = handlerWithModel.getModel()
|
||||
// The default model is DeepSeek-R1, so it returns DEEP_SEEK_DEFAULT_TEMPERATURE
|
||||
expect(model.info.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -1,259 +1,356 @@
|
|||
// npx vitest run api/providers/__tests__/featherless.spec.ts
|
||||
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
|
||||
mockStreamText: vi.fn(),
|
||||
mockGenerateText: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
...actual,
|
||||
streamText: mockStreamText,
|
||||
generateText: mockGenerateText,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@ai-sdk/openai-compatible", () => ({
|
||||
createOpenAICompatible: vi.fn(() => {
|
||||
return vi.fn(() => ({
|
||||
modelId: "featherless-model",
|
||||
provider: "Featherless",
|
||||
}))
|
||||
}),
|
||||
}))
|
||||
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { type FeatherlessModelId, featherlessDefaultModelId, featherlessModels } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
import { FeatherlessHandler } from "../featherless"
|
||||
|
||||
// Create mock functions
|
||||
const mockCreate = vi.fn()
|
||||
|
||||
// Mock OpenAI module
|
||||
vi.mock("openai", () => ({
|
||||
default: vi.fn(() => ({
|
||||
chat: {
|
||||
completions: {
|
||||
create: mockCreate,
|
||||
},
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
describe("FeatherlessHandler", () => {
|
||||
let handler: FeatherlessHandler
|
||||
let mockOptions: ApiHandlerOptions
|
||||
|
||||
beforeEach(() => {
|
||||
mockOptions = {
|
||||
featherlessApiKey: "test-api-key",
|
||||
}
|
||||
handler = new FeatherlessHandler(mockOptions)
|
||||
vi.clearAllMocks()
|
||||
// Set up default mock implementation
|
||||
mockCreate.mockImplementation(async () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "Test response" },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should initialize with provided options", () => {
|
||||
expect(handler).toBeInstanceOf(FeatherlessHandler)
|
||||
expect(handler.getModel().id).toBe(featherlessDefaultModelId)
|
||||
})
|
||||
|
||||
it("should use specified model ID when provided", () => {
|
||||
const testModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct"
|
||||
const handlerWithModel = new FeatherlessHandler({
|
||||
apiModelId: testModelId,
|
||||
featherlessApiKey: "test-api-key",
|
||||
})
|
||||
expect(handlerWithModel.getModel().id).toBe(testModelId)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return default model when no model is specified", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe(featherlessDefaultModelId)
|
||||
expect(model.info).toEqual(expect.objectContaining(featherlessModels[featherlessDefaultModelId]))
|
||||
})
|
||||
|
||||
it("should return specified model when valid model is provided", () => {
|
||||
const testModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct"
|
||||
const handlerWithModel = new FeatherlessHandler({
|
||||
apiModelId: testModelId,
|
||||
featherlessApiKey: "test-api-key",
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.id).toBe(testModelId)
|
||||
expect(model.info).toEqual(expect.objectContaining(featherlessModels[testModelId]))
|
||||
})
|
||||
|
||||
it("should use default temperature for non-DeepSeek models", () => {
|
||||
const testModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct"
|
||||
const handlerWithModel = new FeatherlessHandler({
|
||||
apiModelId: testModelId,
|
||||
featherlessApiKey: "test-api-key",
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.temperature).toBe(0.5)
|
||||
})
|
||||
|
||||
it("should include model parameters from getModelParams", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model).toHaveProperty("temperature")
|
||||
expect(model).toHaveProperty("maxTokens")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "Hello!",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}))
|
||||
handler = new FeatherlessHandler({ featherlessApiKey: "test-key" })
|
||||
})
|
||||
]
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should use the correct Featherless base URL", () => {
|
||||
new FeatherlessHandler({ featherlessApiKey: "test-featherless-api-key" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://api.featherless.ai/v1" }))
|
||||
})
|
||||
|
||||
it("should use the provided API key", () => {
|
||||
const featherlessApiKey = "test-featherless-api-key"
|
||||
new FeatherlessHandler({ featherlessApiKey })
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: featherlessApiKey }))
|
||||
})
|
||||
|
||||
it("should handle reasoning format from models that use <think> tags", async () => {
|
||||
// Override the mock for this specific test
|
||||
mockCreate.mockImplementationOnce(async () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "<think>Thinking..." },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "</think>Hello" },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5 },
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }]
|
||||
vi.spyOn(handler, "getModel").mockReturnValue({
|
||||
id: "some-reasoning-model",
|
||||
info: { maxTokens: 1024, temperature: 0.7 },
|
||||
} as any)
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks[0]).toEqual({ type: "reasoning", text: "Thinking..." })
|
||||
expect(chunks[1]).toEqual({ type: "text", text: "Hello" })
|
||||
expect(chunks[2]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5 })
|
||||
})
|
||||
|
||||
it("should fall back to base provider for non-DeepSeek models", async () => {
|
||||
// Use default mock implementation which returns text content
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }]
|
||||
vi.spyOn(handler, "getModel").mockReturnValue({
|
||||
id: "some-other-model",
|
||||
info: { maxTokens: 1024, temperature: 0.7 },
|
||||
} as any)
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks[0]).toEqual({ type: "text", text: "Test response" })
|
||||
expect(chunks[1]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5 })
|
||||
})
|
||||
|
||||
it("should return default model when no model is specified", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe(featherlessDefaultModelId)
|
||||
expect(model.info).toEqual(expect.objectContaining(featherlessModels[featherlessDefaultModelId]))
|
||||
})
|
||||
|
||||
it("should return specified model when valid model is provided", () => {
|
||||
const testModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct"
|
||||
const handlerWithModel = new FeatherlessHandler({
|
||||
apiModelId: testModelId,
|
||||
featherlessApiKey: "test-featherless-api-key",
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.id).toBe(testModelId)
|
||||
expect(model.info).toEqual(expect.objectContaining(featherlessModels[testModelId]))
|
||||
})
|
||||
|
||||
it("completePrompt method should return text from Featherless API", async () => {
|
||||
const expectedResponse = "This is a test response from Featherless"
|
||||
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] })
|
||||
const result = await handler.completePrompt("test prompt")
|
||||
expect(result).toBe(expectedResponse)
|
||||
})
|
||||
|
||||
it("should handle errors in completePrompt", async () => {
|
||||
const errorMessage = "Featherless API error"
|
||||
mockCreate.mockRejectedValueOnce(new Error(errorMessage))
|
||||
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
|
||||
`Featherless completion error: ${errorMessage}`,
|
||||
)
|
||||
})
|
||||
|
||||
it("createMessage should yield text content from stream", async () => {
|
||||
const testContent = "This is test content from Featherless stream"
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: { content: testContent } }] },
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
it("should handle streaming responses for non-R1 models", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({ type: "text", text: testContent })
|
||||
})
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
it("createMessage should yield usage data from stream", async () => {
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } },
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks).toHaveLength(1)
|
||||
expect(textChunks[0].text).toBe("Test response")
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
it("should include usage information", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 })
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0].inputTokens).toBe(10)
|
||||
expect(usageChunks[0].outputTokens).toBe(5)
|
||||
})
|
||||
|
||||
it("should handle reasoning format from DeepSeek-R1 models using TagMatcher", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "<think>Thinking..." }
|
||||
yield { type: "text-delta", text: "</think>Hello" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
vi.spyOn(handler, "getModel").mockReturnValue({
|
||||
id: "some-DeepSeek-R1-model",
|
||||
info: { maxTokens: 1024, temperature: 0.6 },
|
||||
maxTokens: 1024,
|
||||
temperature: 0.6,
|
||||
} as any)
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks[0]).toEqual({ type: "reasoning", text: "Thinking..." })
|
||||
expect(chunks[1]).toEqual({ type: "text", text: "Hello" })
|
||||
expect(chunks[2]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5 })
|
||||
})
|
||||
|
||||
it("should delegate to super.createMessage for non-DeepSeek-R1 models", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Standard response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 15,
|
||||
outputTokens: 8,
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
vi.spyOn(handler, "getModel").mockReturnValue({
|
||||
id: "some-other-model",
|
||||
info: { maxTokens: 1024, temperature: 0.5 },
|
||||
maxTokens: 1024,
|
||||
temperature: 0.5,
|
||||
} as any)
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks[0]).toEqual({ type: "text", text: "Standard response" })
|
||||
expect(chunks[1]).toMatchObject({ type: "usage", inputTokens: 15, outputTokens: 8 })
|
||||
})
|
||||
|
||||
it("should pass correct model to streamText for R1 path", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "response" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
})
|
||||
|
||||
vi.spyOn(handler, "getModel").mockReturnValue({
|
||||
id: "some-DeepSeek-R1-model",
|
||||
info: { maxTokens: 2048, temperature: 0.6 },
|
||||
maxTokens: 2048,
|
||||
temperature: 0.6,
|
||||
} as any)
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
// Consume stream
|
||||
for await (const _ of stream) {
|
||||
// drain
|
||||
}
|
||||
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 0.6,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should not pass system prompt to streamText for R1 path", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "response" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
})
|
||||
|
||||
vi.spyOn(handler, "getModel").mockReturnValue({
|
||||
id: "some-DeepSeek-R1-model",
|
||||
info: { maxTokens: 2048, temperature: 0.6 },
|
||||
maxTokens: 2048,
|
||||
temperature: 0.6,
|
||||
} as any)
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
for await (const _ of stream) {
|
||||
// drain
|
||||
}
|
||||
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
expect(callArgs.system).toBeUndefined()
|
||||
expect(callArgs.messages).toBeDefined()
|
||||
})
|
||||
|
||||
it("should merge consecutive user messages in R1 path to avoid DeepSeek rejection", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "response" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
})
|
||||
|
||||
vi.spyOn(handler, "getModel").mockReturnValue({
|
||||
id: "some-DeepSeek-R1-model",
|
||||
info: { maxTokens: 2048, temperature: 0.6 },
|
||||
maxTokens: 2048,
|
||||
temperature: 0.6,
|
||||
} as any)
|
||||
|
||||
// messages starts with a user message, so after prepending the system
|
||||
// prompt as a user message we'd have two consecutive user messages.
|
||||
const userFirstMessages: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "Hello!" },
|
||||
{ role: "assistant", content: "Hi there" },
|
||||
{ role: "user", content: "Follow-up" },
|
||||
]
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, userFirstMessages)
|
||||
for await (const _ of stream) {
|
||||
// drain
|
||||
}
|
||||
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
const passedMessages = callArgs.messages
|
||||
|
||||
// Verify no two consecutive messages share the same role
|
||||
for (let i = 1; i < passedMessages.length; i++) {
|
||||
expect(passedMessages[i].role).not.toBe(passedMessages[i - 1].role)
|
||||
}
|
||||
|
||||
// The system prompt and first user message should be merged into a single user message
|
||||
expect(passedMessages[0].role).toBe("user")
|
||||
expect(passedMessages[1].role).toBe("assistant")
|
||||
expect(passedMessages[2].role).toBe("user")
|
||||
expect(passedMessages).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
it("createMessage should pass correct parameters to Featherless client", async () => {
|
||||
const modelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct"
|
||||
describe("completePrompt", () => {
|
||||
it("should complete a prompt using generateText", async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test completion from Featherless",
|
||||
})
|
||||
|
||||
// Clear previous mocks and set up new implementation
|
||||
mockCreate.mockClear()
|
||||
mockCreate.mockImplementationOnce(async () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
// Empty stream for this test
|
||||
},
|
||||
}))
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
|
||||
const handlerWithModel = new FeatherlessHandler({
|
||||
apiModelId: modelId,
|
||||
featherlessApiKey: "test-featherless-api-key",
|
||||
expect(result).toBe("Test completion from Featherless")
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "Test prompt",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const systemPrompt = "Test system prompt for Featherless"
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for Featherless" }]
|
||||
|
||||
const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages)
|
||||
await messageGenerator.next()
|
||||
|
||||
expect(mockCreate).toHaveBeenCalled()
|
||||
const callArgs = mockCreate.mock.calls[0][0]
|
||||
expect(callArgs.model).toBe(modelId)
|
||||
})
|
||||
|
||||
it("should use default temperature for non-DeepSeek models", () => {
|
||||
const testModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct"
|
||||
const handlerWithModel = new FeatherlessHandler({
|
||||
apiModelId: testModelId,
|
||||
featherlessApiKey: "test-featherless-api-key",
|
||||
describe("isAiSdkProvider", () => {
|
||||
it("should return true", () => {
|
||||
expect(handler.isAiSdkProvider()).toBe(true)
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.info.temperature).toBe(0.5)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,6 +10,30 @@ vitest.mock("@roo-code/telemetry", () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
// Mock the AI SDK functions
|
||||
const mockStreamText = vitest.fn()
|
||||
const mockGenerateText = vitest.fn()
|
||||
|
||||
vitest.mock("ai", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
...original,
|
||||
streamText: (...args: unknown[]) => mockStreamText(...args),
|
||||
generateText: (...args: unknown[]) => mockGenerateText(...args),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock createGoogleGenerativeAI to capture constructor options
|
||||
const mockCreateGoogleGenerativeAI = vitest.fn().mockReturnValue(() => ({}))
|
||||
|
||||
vitest.mock("@ai-sdk/google", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@ai-sdk/google")>()
|
||||
return {
|
||||
...original,
|
||||
createGoogleGenerativeAI: (...args: unknown[]) => mockCreateGoogleGenerativeAI(...args),
|
||||
}
|
||||
})
|
||||
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { type ModelInfo, geminiDefaultModelId, ApiProviderError } from "@roo-code/types"
|
||||
|
|
@ -25,26 +49,16 @@ describe("GeminiHandler", () => {
|
|||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
mockCaptureException.mockClear()
|
||||
|
||||
// Create mock functions
|
||||
const mockGenerateContentStream = vitest.fn()
|
||||
const mockGenerateContent = vitest.fn()
|
||||
const mockGetGenerativeModel = vitest.fn()
|
||||
mockStreamText.mockClear()
|
||||
mockGenerateText.mockClear()
|
||||
mockCreateGoogleGenerativeAI.mockClear()
|
||||
mockCreateGoogleGenerativeAI.mockReturnValue(() => ({}))
|
||||
|
||||
handler = new GeminiHandler({
|
||||
apiKey: "test-key",
|
||||
apiModelId: GEMINI_MODEL_NAME,
|
||||
geminiApiKey: "test-key",
|
||||
})
|
||||
|
||||
// Replace the client with our mock
|
||||
handler["client"] = {
|
||||
models: {
|
||||
generateContentStream: mockGenerateContentStream,
|
||||
generateContent: mockGenerateContent,
|
||||
getGenerativeModel: mockGetGenerativeModel,
|
||||
},
|
||||
} as any
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
|
|
@ -52,6 +66,37 @@ describe("GeminiHandler", () => {
|
|||
expect(handler["options"].geminiApiKey).toBe("test-key")
|
||||
expect(handler["options"].apiModelId).toBe(GEMINI_MODEL_NAME)
|
||||
})
|
||||
|
||||
it("should pass undefined baseURL when googleGeminiBaseUrl is empty string", () => {
|
||||
mockCreateGoogleGenerativeAI.mockClear()
|
||||
new GeminiHandler({
|
||||
apiModelId: GEMINI_MODEL_NAME,
|
||||
geminiApiKey: "test-key",
|
||||
googleGeminiBaseUrl: "",
|
||||
})
|
||||
expect(mockCreateGoogleGenerativeAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: undefined }))
|
||||
})
|
||||
|
||||
it("should pass undefined baseURL when googleGeminiBaseUrl is not provided", () => {
|
||||
mockCreateGoogleGenerativeAI.mockClear()
|
||||
new GeminiHandler({
|
||||
apiModelId: GEMINI_MODEL_NAME,
|
||||
geminiApiKey: "test-key",
|
||||
})
|
||||
expect(mockCreateGoogleGenerativeAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: undefined }))
|
||||
})
|
||||
|
||||
it("should pass custom baseURL when googleGeminiBaseUrl is a valid URL", () => {
|
||||
mockCreateGoogleGenerativeAI.mockClear()
|
||||
new GeminiHandler({
|
||||
apiModelId: GEMINI_MODEL_NAME,
|
||||
geminiApiKey: "test-key",
|
||||
googleGeminiBaseUrl: "https://custom-gemini.example.com/v1beta",
|
||||
})
|
||||
expect(mockCreateGoogleGenerativeAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseURL: "https://custom-gemini.example.com/v1beta" }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
|
|
@ -69,13 +114,17 @@ describe("GeminiHandler", () => {
|
|||
const systemPrompt = "You are a helpful assistant"
|
||||
|
||||
it("should handle text messages correctly", async () => {
|
||||
// Setup the mock implementation to return an async generator
|
||||
;(handler["client"].models.generateContentStream as any).mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield { text: "Hello" }
|
||||
yield { text: " world!" }
|
||||
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } }
|
||||
},
|
||||
// Setup the mock implementation to return an async generator for fullStream
|
||||
// AI SDK text-delta events have a 'text' property (processAiSdkStreamPart casts to this)
|
||||
const mockFullStream = (async function* () {
|
||||
yield { type: "text-delta", text: "Hello" }
|
||||
yield { type: "text-delta", text: " world!" }
|
||||
})()
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream,
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, mockMessages)
|
||||
|
|
@ -91,21 +140,27 @@ describe("GeminiHandler", () => {
|
|||
expect(chunks[1]).toEqual({ type: "text", text: " world!" })
|
||||
expect(chunks[2]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5 })
|
||||
|
||||
// Verify the call to generateContentStream
|
||||
expect(handler["client"].models.generateContentStream).toHaveBeenCalledWith(
|
||||
// Verify the call to streamText
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: GEMINI_MODEL_NAME,
|
||||
config: expect.objectContaining({
|
||||
temperature: 1,
|
||||
systemInstruction: systemPrompt,
|
||||
}),
|
||||
system: systemPrompt,
|
||||
temperature: 1,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle API errors", async () => {
|
||||
const mockError = new Error("Gemini API error")
|
||||
;(handler["client"].models.generateContentStream as any).mockRejectedValue(mockError)
|
||||
// eslint-disable-next-line require-yield
|
||||
const mockFullStream = (async function* () {
|
||||
throw mockError
|
||||
})()
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream,
|
||||
usage: Promise.resolve({}),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, mockMessages)
|
||||
|
||||
|
|
@ -119,28 +174,26 @@ describe("GeminiHandler", () => {
|
|||
|
||||
describe("completePrompt", () => {
|
||||
it("should complete prompt successfully", async () => {
|
||||
// Mock the response with text property
|
||||
;(handler["client"].models.generateContent as any).mockResolvedValue({
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test response",
|
||||
providerMetadata: {},
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
expect(result).toBe("Test response")
|
||||
|
||||
// Verify the call to generateContent
|
||||
expect(handler["client"].models.generateContent).toHaveBeenCalledWith({
|
||||
model: GEMINI_MODEL_NAME,
|
||||
contents: [{ role: "user", parts: [{ text: "Test prompt" }] }],
|
||||
config: {
|
||||
httpOptions: undefined,
|
||||
// Verify the call to generateText
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "Test prompt",
|
||||
temperature: 1,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle API errors", async () => {
|
||||
const mockError = new Error("Gemini API error")
|
||||
;(handler["client"].models.generateContent as any).mockRejectedValue(mockError)
|
||||
mockGenerateText.mockRejectedValue(mockError)
|
||||
|
||||
await expect(handler.completePrompt("Test prompt")).rejects.toThrow(
|
||||
t("common:errors.gemini.generate_complete_prompt", { error: "Gemini API error" }),
|
||||
|
|
@ -148,9 +201,9 @@ describe("GeminiHandler", () => {
|
|||
})
|
||||
|
||||
it("should handle empty response", async () => {
|
||||
// Mock the response with empty text
|
||||
;(handler["client"].models.generateContent as any).mockResolvedValue({
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "",
|
||||
providerMetadata: {},
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
|
|
@ -255,7 +308,16 @@ describe("GeminiHandler", () => {
|
|||
|
||||
it("should capture telemetry on createMessage error", async () => {
|
||||
const mockError = new Error("Gemini API error")
|
||||
;(handler["client"].models.generateContentStream as any).mockRejectedValue(mockError)
|
||||
// eslint-disable-next-line require-yield
|
||||
const mockFullStream = (async function* () {
|
||||
throw mockError
|
||||
})()
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream,
|
||||
usage: Promise.resolve({}),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, mockMessages)
|
||||
|
||||
|
|
@ -283,7 +345,7 @@ describe("GeminiHandler", () => {
|
|||
|
||||
it("should capture telemetry on completePrompt error", async () => {
|
||||
const mockError = new Error("Gemini completion error")
|
||||
;(handler["client"].models.generateContent as any).mockRejectedValue(mockError)
|
||||
mockGenerateText.mockRejectedValue(mockError)
|
||||
|
||||
await expect(handler.completePrompt("Test prompt")).rejects.toThrow()
|
||||
|
||||
|
|
@ -305,7 +367,16 @@ describe("GeminiHandler", () => {
|
|||
|
||||
it("should still throw the error after capturing telemetry", async () => {
|
||||
const mockError = new Error("Gemini API error")
|
||||
;(handler["client"].models.generateContentStream as any).mockRejectedValue(mockError)
|
||||
// eslint-disable-next-line require-yield
|
||||
const mockFullStream = (async function* () {
|
||||
throw mockError
|
||||
})()
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream,
|
||||
usage: Promise.resolve({}),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, mockMessages)
|
||||
|
||||
|
|
|
|||
553
src/api/providers/__tests__/huggingface.spec.ts
Normal file
553
src/api/providers/__tests__/huggingface.spec.ts
Normal file
|
|
@ -0,0 +1,553 @@
|
|||
// npx vitest run src/api/providers/__tests__/huggingface.spec.ts
|
||||
|
||||
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
|
||||
const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
|
||||
mockStreamText: vi.fn(),
|
||||
mockGenerateText: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
...actual,
|
||||
streamText: mockStreamText,
|
||||
generateText: mockGenerateText,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@ai-sdk/openai-compatible", () => ({
|
||||
createOpenAICompatible: vi.fn(() => {
|
||||
// Return a function that returns a mock language model
|
||||
return vi.fn(() => ({
|
||||
modelId: "meta-llama/Llama-3.3-70B-Instruct",
|
||||
provider: "huggingface",
|
||||
}))
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the fetchers
|
||||
vi.mock("../fetchers/huggingface", () => ({
|
||||
getHuggingFaceModels: vi.fn(() => Promise.resolve({})),
|
||||
getCachedHuggingFaceModels: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
import { HuggingFaceHandler } from "../huggingface"
|
||||
|
||||
describe("HuggingFaceHandler", () => {
|
||||
let handler: HuggingFaceHandler
|
||||
let mockOptions: ApiHandlerOptions
|
||||
|
||||
beforeEach(() => {
|
||||
mockOptions = {
|
||||
huggingFaceApiKey: "test-huggingface-api-key",
|
||||
huggingFaceModelId: "meta-llama/Llama-3.3-70B-Instruct",
|
||||
}
|
||||
handler = new HuggingFaceHandler(mockOptions)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should initialize with provided options", () => {
|
||||
expect(handler).toBeInstanceOf(HuggingFaceHandler)
|
||||
expect(handler.getModel().id).toBe(mockOptions.huggingFaceModelId)
|
||||
})
|
||||
|
||||
it("should use default model ID if not provided", () => {
|
||||
const handlerWithoutModel = new HuggingFaceHandler({
|
||||
...mockOptions,
|
||||
huggingFaceModelId: undefined,
|
||||
})
|
||||
expect(handlerWithoutModel.getModel().id).toBe("meta-llama/Llama-3.3-70B-Instruct")
|
||||
})
|
||||
|
||||
it("should throw error if API key is not provided", () => {
|
||||
expect(() => {
|
||||
new HuggingFaceHandler({
|
||||
...mockOptions,
|
||||
huggingFaceApiKey: undefined,
|
||||
})
|
||||
}).toThrow("Hugging Face API key is required")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return default model when no model is specified", () => {
|
||||
const handlerWithoutModel = new HuggingFaceHandler({
|
||||
huggingFaceApiKey: "test-huggingface-api-key",
|
||||
})
|
||||
const model = handlerWithoutModel.getModel()
|
||||
expect(model.id).toBe("meta-llama/Llama-3.3-70B-Instruct")
|
||||
expect(model.info).toBeDefined()
|
||||
})
|
||||
|
||||
it("should return specified model when valid model is provided", () => {
|
||||
const testModelId = "mistralai/Mistral-7B-Instruct-v0.3"
|
||||
const handlerWithModel = new HuggingFaceHandler({
|
||||
huggingFaceModelId: testModelId,
|
||||
huggingFaceApiKey: "test-huggingface-api-key",
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.id).toBe(testModelId)
|
||||
})
|
||||
|
||||
it("should include model parameters from getModelParams", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model).toHaveProperty("temperature")
|
||||
expect(model).toHaveProperty("maxTokens")
|
||||
})
|
||||
|
||||
it("should return fallback info when model not in cache", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.info).toEqual(
|
||||
expect.objectContaining({
|
||||
maxTokens: 8192,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "Hello!",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
it("should handle streaming responses", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response from HuggingFace" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks).toHaveLength(1)
|
||||
expect(textChunks[0].text).toBe("Test response from HuggingFace")
|
||||
})
|
||||
|
||||
it("should include usage information", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0].inputTokens).toBe(10)
|
||||
expect(usageChunks[0].outputTokens).toBe(20)
|
||||
})
|
||||
|
||||
it("should handle cached tokens in usage data from providerMetadata", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
})
|
||||
|
||||
// HuggingFace provides cache metrics via providerMetadata for supported models
|
||||
const mockProviderMetadata = Promise.resolve({
|
||||
huggingface: {
|
||||
promptCacheHitTokens: 30,
|
||||
promptCacheMissTokens: 70,
|
||||
},
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0].inputTokens).toBe(100)
|
||||
expect(usageChunks[0].outputTokens).toBe(50)
|
||||
expect(usageChunks[0].cacheReadTokens).toBe(30)
|
||||
expect(usageChunks[0].cacheWriteTokens).toBe(70)
|
||||
})
|
||||
|
||||
it("should handle usage with details.cachedInputTokens when providerMetadata is not available", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
details: {
|
||||
cachedInputTokens: 25,
|
||||
},
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0].cacheReadTokens).toBe(25)
|
||||
expect(usageChunks[0].cacheWriteTokens).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should pass correct temperature (0.7 default) to streamText", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const handlerWithDefaultTemp = new HuggingFaceHandler({
|
||||
huggingFaceApiKey: "test-key",
|
||||
huggingFaceModelId: "meta-llama/Llama-3.3-70B-Instruct",
|
||||
})
|
||||
|
||||
const stream = handlerWithDefaultTemp.createMessage(systemPrompt, messages)
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 0.7,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use user-specified temperature over provider defaults", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const handlerWithCustomTemp = new HuggingFaceHandler({
|
||||
huggingFaceApiKey: "test-key",
|
||||
huggingFaceModelId: "meta-llama/Llama-3.3-70B-Instruct",
|
||||
modelTemperature: 0.7,
|
||||
})
|
||||
|
||||
const stream = handlerWithCustomTemp.createMessage(systemPrompt, messages)
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
// User-specified temperature should take precedence over everything
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 0.7,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle stream with multiple chunks", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Hello" }
|
||||
yield { type: "text-delta", text: " world" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 5, outputTokens: 10 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const textChunks = chunks.filter((c) => c.type === "text")
|
||||
expect(textChunks[0]).toEqual({ type: "text", text: "Hello" })
|
||||
expect(textChunks[1]).toEqual({ type: "text", text: " world" })
|
||||
|
||||
const usageChunks = chunks.filter((c) => c.type === "usage")
|
||||
expect(usageChunks[0]).toMatchObject({ type: "usage", inputTokens: 5, outputTokens: 10 })
|
||||
})
|
||||
|
||||
it("should handle errors with handleAiSdkError", async () => {
|
||||
async function* mockFullStream(): AsyncGenerator<any> {
|
||||
yield { type: "text-delta", text: "" } // Yield something before error to satisfy lint
|
||||
throw new Error("API Error")
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
}).rejects.toThrow("HuggingFace: API Error")
|
||||
})
|
||||
})
|
||||
|
||||
describe("completePrompt", () => {
|
||||
it("should complete a prompt using generateText", async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test completion from HuggingFace",
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
|
||||
expect(result).toBe("Test completion from HuggingFace")
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "Test prompt",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use default temperature in completePrompt", async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test completion",
|
||||
})
|
||||
|
||||
await handler.completePrompt("Test prompt")
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 0.7,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("processUsageMetrics", () => {
|
||||
it("should correctly process usage metrics including cache information from providerMetadata", () => {
|
||||
class TestHuggingFaceHandler extends HuggingFaceHandler {
|
||||
public testProcessUsageMetrics(usage: any, providerMetadata?: any) {
|
||||
return this.processUsageMetrics(usage, providerMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestHuggingFaceHandler(mockOptions)
|
||||
|
||||
const usage = {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
}
|
||||
|
||||
const providerMetadata = {
|
||||
huggingface: {
|
||||
promptCacheHitTokens: 20,
|
||||
promptCacheMissTokens: 80,
|
||||
},
|
||||
}
|
||||
|
||||
const result = testHandler.testProcessUsageMetrics(usage, providerMetadata)
|
||||
|
||||
expect(result.type).toBe("usage")
|
||||
expect(result.inputTokens).toBe(100)
|
||||
expect(result.outputTokens).toBe(50)
|
||||
expect(result.cacheWriteTokens).toBe(80)
|
||||
expect(result.cacheReadTokens).toBe(20)
|
||||
})
|
||||
|
||||
it("should handle missing cache metrics gracefully", () => {
|
||||
class TestHuggingFaceHandler extends HuggingFaceHandler {
|
||||
public testProcessUsageMetrics(usage: any, providerMetadata?: any) {
|
||||
return this.processUsageMetrics(usage, providerMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestHuggingFaceHandler(mockOptions)
|
||||
|
||||
const usage = {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
}
|
||||
|
||||
const result = testHandler.testProcessUsageMetrics(usage)
|
||||
|
||||
expect(result.type).toBe("usage")
|
||||
expect(result.inputTokens).toBe(100)
|
||||
expect(result.outputTokens).toBe(50)
|
||||
expect(result.cacheWriteTokens).toBeUndefined()
|
||||
expect(result.cacheReadTokens).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should include reasoning tokens when provided", () => {
|
||||
class TestHuggingFaceHandler extends HuggingFaceHandler {
|
||||
public testProcessUsageMetrics(usage: any, providerMetadata?: any) {
|
||||
return this.processUsageMetrics(usage, providerMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestHuggingFaceHandler(mockOptions)
|
||||
|
||||
const usage = {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
details: {
|
||||
reasoningTokens: 30,
|
||||
},
|
||||
}
|
||||
|
||||
const result = testHandler.testProcessUsageMetrics(usage)
|
||||
|
||||
expect(result.reasoningTokens).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool handling", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text" as const, text: "Hello!" }],
|
||||
},
|
||||
]
|
||||
|
||||
it("should handle tool calls in streaming", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield {
|
||||
type: "tool-input-start",
|
||||
id: "tool-call-1",
|
||||
toolName: "read_file",
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-delta",
|
||||
id: "tool-call-1",
|
||||
delta: '{"path":"test.ts"}',
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-end",
|
||||
id: "tool-call-1",
|
||||
}
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages, {
|
||||
taskId: "test-task",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
description: "Read a file",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string" } },
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start")
|
||||
const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta")
|
||||
const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end")
|
||||
|
||||
expect(toolCallStartChunks.length).toBe(1)
|
||||
expect(toolCallStartChunks[0].id).toBe("tool-call-1")
|
||||
expect(toolCallStartChunks[0].name).toBe("read_file")
|
||||
|
||||
expect(toolCallDeltaChunks.length).toBe(1)
|
||||
expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}')
|
||||
|
||||
expect(toolCallEndChunks.length).toBe(1)
|
||||
expect(toolCallEndChunks[0].id).toBe("tool-call-1")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,303 +1,197 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
|
||||
mockStreamText: vi.fn(),
|
||||
mockGenerateText: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
...actual,
|
||||
streamText: mockStreamText,
|
||||
generateText: mockGenerateText,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@ai-sdk/openai-compatible", () => ({
|
||||
createOpenAICompatible: vi.fn(() => {
|
||||
return vi.fn(() => ({
|
||||
modelId: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
|
||||
provider: "IO Intelligence",
|
||||
}))
|
||||
}),
|
||||
}))
|
||||
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { ioIntelligenceDefaultModelId } from "@roo-code/types"
|
||||
|
||||
import { IOIntelligenceHandler } from "../io-intelligence"
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
const mockCreate = vi.fn()
|
||||
|
||||
// Mock OpenAI
|
||||
vi.mock("openai", () => ({
|
||||
default: class MockOpenAI {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
chat = {
|
||||
completions: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
}
|
||||
constructor(options: any) {
|
||||
this.baseURL = options.baseURL
|
||||
this.apiKey = options.apiKey
|
||||
this.chat.completions.create = mockCreate
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the fetcher functions
|
||||
vi.mock("../fetchers/io-intelligence", () => ({
|
||||
getIOIntelligenceModels: vi.fn(),
|
||||
getCachedIOIntelligenceModels: vi.fn(() => ({
|
||||
"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 430000,
|
||||
description: "Llama 4 Maverick 17B model",
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"deepseek-ai/DeepSeek-R1-0528": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 128000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
description: "DeepSeek R1 reasoning model",
|
||||
},
|
||||
"Intel/Qwen3-Coder-480B-A35B-Instruct-int4-mixed-ar": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 106000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
description: "Qwen3 Coder 480B specialized for coding",
|
||||
},
|
||||
"openai/gpt-oss-120b": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
description: "OpenAI GPT-OSS 120B model",
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock constants
|
||||
vi.mock("../constants", () => ({
|
||||
DEFAULT_HEADERS: { "User-Agent": "roo-cline" },
|
||||
}))
|
||||
|
||||
// Mock transform functions
|
||||
vi.mock("../../transform/openai-format", () => ({
|
||||
convertToOpenAiMessages: vi.fn((messages) => messages),
|
||||
}))
|
||||
import { IOIntelligenceHandler } from "../io-intelligence"
|
||||
|
||||
describe("IOIntelligenceHandler", () => {
|
||||
let handler: IOIntelligenceHandler
|
||||
let mockOptions: ApiHandlerOptions
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockOptions = {
|
||||
ioIntelligenceApiKey: "test-api-key",
|
||||
apiModelId: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
|
||||
ioIntelligenceModelId: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
|
||||
modelTemperature: 0.7,
|
||||
includeMaxTokens: false,
|
||||
modelMaxTokens: undefined,
|
||||
} as ApiHandlerOptions
|
||||
|
||||
mockCreate.mockImplementation(async () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "Test response" },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
handler = new IOIntelligenceHandler(mockOptions)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
describe("constructor", () => {
|
||||
it("should initialize with provided options", () => {
|
||||
expect(handler).toBeInstanceOf(IOIntelligenceHandler)
|
||||
expect(handler.getModel().id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8")
|
||||
})
|
||||
|
||||
it("should create OpenAI client with correct configuration", () => {
|
||||
const ioIntelligenceApiKey = "test-io-intelligence-api-key"
|
||||
const handler = new IOIntelligenceHandler({ ioIntelligenceApiKey })
|
||||
// Verify that the handler was created successfully
|
||||
expect(handler).toBeInstanceOf(IOIntelligenceHandler)
|
||||
expect(handler["client"]).toBeDefined()
|
||||
// Verify the client has the expected properties
|
||||
expect(handler["client"].baseURL).toBe("https://api.intelligence.io.solutions/api/v1")
|
||||
expect(handler["client"].apiKey).toBe(ioIntelligenceApiKey)
|
||||
})
|
||||
it("should use default model ID if not provided", () => {
|
||||
const handlerWithoutModel = new IOIntelligenceHandler({
|
||||
...mockOptions,
|
||||
ioIntelligenceModelId: undefined,
|
||||
} as ApiHandlerOptions)
|
||||
expect(handlerWithoutModel.getModel().id).toBe(ioIntelligenceDefaultModelId)
|
||||
})
|
||||
|
||||
it("should initialize with correct configuration", () => {
|
||||
expect(handler).toBeInstanceOf(IOIntelligenceHandler)
|
||||
expect(handler["client"]).toBeDefined()
|
||||
expect(handler["options"]).toEqual({
|
||||
...mockOptions,
|
||||
apiKey: mockOptions.ioIntelligenceApiKey,
|
||||
it("should throw error when API key is missing", () => {
|
||||
const optionsWithoutKey = { ...mockOptions }
|
||||
delete optionsWithoutKey.ioIntelligenceApiKey
|
||||
|
||||
expect(() => new IOIntelligenceHandler(optionsWithoutKey)).toThrow("IO Intelligence API key is required")
|
||||
})
|
||||
})
|
||||
|
||||
it("should throw error when API key is missing", () => {
|
||||
const optionsWithoutKey = { ...mockOptions }
|
||||
delete optionsWithoutKey.ioIntelligenceApiKey
|
||||
describe("getModel", () => {
|
||||
it("should return model info for valid model ID", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8")
|
||||
expect(model.info).toBeDefined()
|
||||
expect(model.info.maxTokens).toBe(8192)
|
||||
expect(model.info.contextWindow).toBe(430000)
|
||||
expect(model.info.supportsImages).toBe(true)
|
||||
expect(model.info.supportsPromptCache).toBe(false)
|
||||
})
|
||||
|
||||
expect(() => new IOIntelligenceHandler(optionsWithoutKey)).toThrow("IO Intelligence API key is required")
|
||||
it("should return default model info for unknown model ID", () => {
|
||||
const handlerWithUnknown = new IOIntelligenceHandler({
|
||||
...mockOptions,
|
||||
ioIntelligenceModelId: "unknown-model",
|
||||
} as ApiHandlerOptions)
|
||||
const model = handlerWithUnknown.getModel()
|
||||
expect(model.id).toBe("unknown-model")
|
||||
expect(model.info).toBeDefined()
|
||||
expect(model.info.contextWindow).toBe(handler.getModel().info.contextWindow)
|
||||
})
|
||||
|
||||
it("should return default model if no model ID is provided", () => {
|
||||
const handlerWithoutModel = new IOIntelligenceHandler({
|
||||
...mockOptions,
|
||||
ioIntelligenceModelId: undefined,
|
||||
} as ApiHandlerOptions)
|
||||
const model = handlerWithoutModel.getModel()
|
||||
expect(model.id).toBe(ioIntelligenceDefaultModelId)
|
||||
expect(model.info).toBeDefined()
|
||||
})
|
||||
|
||||
it("should include model parameters from getModelParams", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model).toHaveProperty("temperature")
|
||||
expect(model).toHaveProperty("maxTokens")
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle streaming response correctly", async () => {
|
||||
const mockStream = [
|
||||
describe("createMessage", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
choices: [{ delta: { content: "Hello" } }],
|
||||
usage: null,
|
||||
},
|
||||
{
|
||||
choices: [{ delta: { content: " world" } }],
|
||||
usage: null,
|
||||
},
|
||||
{
|
||||
choices: [{ delta: {} }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5 },
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "Hello!",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
mockCreate.mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
for (const chunk of mockStream) {
|
||||
yield chunk
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const stream = handler.createMessage("System prompt", messages)
|
||||
const results = []
|
||||
|
||||
for await (const chunk of stream) {
|
||||
results.push(chunk)
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(3)
|
||||
expect(results[0]).toEqual({ type: "text", text: "Hello" })
|
||||
expect(results[1]).toEqual({ type: "text", text: " world" })
|
||||
expect(results[2]).toMatchObject({
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it("completePrompt method should return text from IO Intelligence API", async () => {
|
||||
const expectedResponse = "This is a test response from IO Intelligence"
|
||||
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] })
|
||||
const result = await handler.completePrompt("test prompt")
|
||||
expect(result).toBe(expectedResponse)
|
||||
})
|
||||
|
||||
it("should handle errors in completePrompt", async () => {
|
||||
const errorMessage = "IO Intelligence API error"
|
||||
mockCreate.mockRejectedValueOnce(new Error(errorMessage))
|
||||
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
|
||||
`IO Intelligence completion error: ${errorMessage}`,
|
||||
)
|
||||
})
|
||||
|
||||
it("createMessage should yield text content from stream", async () => {
|
||||
const testContent = "This is test content from IO Intelligence stream"
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: { content: testContent } }] },
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
it("should handle streaming responses", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
details: {},
|
||||
raw: {},
|
||||
})
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({ type: "text", text: testContent })
|
||||
})
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
it("createMessage should yield usage data from stream", async () => {
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } },
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks).toHaveLength(1)
|
||||
expect(textChunks[0].text).toBe("Test response")
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
it("should include usage information", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 })
|
||||
})
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
details: {},
|
||||
raw: {},
|
||||
})
|
||||
|
||||
it("should return model info from cache when available", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8")
|
||||
expect(model.info).toEqual({
|
||||
maxTokens: 8192,
|
||||
contextWindow: 430000,
|
||||
description: "Llama 4 Maverick 17B model",
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0].inputTokens).toBe(10)
|
||||
expect(usageChunks[0].outputTokens).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
it("should return fallback model info when not in cache", () => {
|
||||
const handlerWithUnknownModel = new IOIntelligenceHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
|
||||
})
|
||||
const model = handlerWithUnknownModel.getModel()
|
||||
expect(model.id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8")
|
||||
expect(model.info).toEqual({
|
||||
maxTokens: 8192,
|
||||
contextWindow: 430000,
|
||||
description: "Llama 4 Maverick 17B model",
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
})
|
||||
})
|
||||
describe("completePrompt", () => {
|
||||
it("should complete a prompt using generateText", async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test completion",
|
||||
})
|
||||
|
||||
it("should use default model when no model is specified", () => {
|
||||
const handlerWithoutModel = new IOIntelligenceHandler({
|
||||
...mockOptions,
|
||||
apiModelId: undefined,
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
|
||||
expect(result).toBe("Test completion")
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "Test prompt",
|
||||
}),
|
||||
)
|
||||
})
|
||||
const model = handlerWithoutModel.getModel()
|
||||
expect(model.id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8")
|
||||
})
|
||||
|
||||
it("should handle empty response from completePrompt", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: null } }],
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
it("should handle missing choices in completePrompt response", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [],
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ describe("OpenAiCodexHandler.getModel", () => {
|
|||
const handler = new OpenAiCodexHandler({ apiModelId: "not-a-real-model" })
|
||||
const model = handler.getModel()
|
||||
|
||||
expect(model.id).toBe("gpt-5.2-codex")
|
||||
expect(model.id).toBe("gpt-5.3-codex")
|
||||
expect(model.info).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ vitest.mock("@roo-code/telemetry", () => ({
|
|||
}))
|
||||
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
|
||||
import { ApiProviderError } from "@roo-code/types"
|
||||
|
||||
|
|
@ -76,6 +77,28 @@ describe("OpenAiNativeHandler", () => {
|
|||
})
|
||||
expect(handlerWithoutKey).toBeInstanceOf(OpenAiNativeHandler)
|
||||
})
|
||||
|
||||
it("should pass undefined baseURL when openAiNativeBaseUrl is empty string", () => {
|
||||
;(OpenAI as unknown as ReturnType<typeof vitest.fn>).mockClear()
|
||||
new OpenAiNativeHandler({
|
||||
apiModelId: "gpt-4.1",
|
||||
openAiNativeApiKey: "test-key",
|
||||
openAiNativeBaseUrl: "",
|
||||
})
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: undefined }))
|
||||
})
|
||||
|
||||
it("should pass custom baseURL when openAiNativeBaseUrl is a valid URL", () => {
|
||||
;(OpenAI as unknown as ReturnType<typeof vitest.fn>).mockClear()
|
||||
new OpenAiNativeHandler({
|
||||
apiModelId: "gpt-4.1",
|
||||
openAiNativeApiKey: "test-key",
|
||||
openAiNativeBaseUrl: "https://custom-openai.example.com/v1",
|
||||
})
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseURL: "https://custom-openai.example.com/v1" }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
|
|
|
|||
|
|
@ -1,152 +1,628 @@
|
|||
// npx vitest run src/api/providers/__tests__/sambanova.spec.ts
|
||||
|
||||
import OpenAI from "openai"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
|
||||
const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
|
||||
mockStreamText: vi.fn(),
|
||||
mockGenerateText: vi.fn(),
|
||||
}))
|
||||
|
||||
import { type SambaNovaModelId, sambaNovaDefaultModelId, sambaNovaModels } from "@roo-code/types"
|
||||
|
||||
import { SambaNovaHandler } from "../sambanova"
|
||||
|
||||
vitest.mock("openai", () => {
|
||||
const createMock = vitest.fn()
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
default: vitest.fn(() => ({ chat: { completions: { create: createMock } } })),
|
||||
...actual,
|
||||
streamText: mockStreamText,
|
||||
generateText: mockGenerateText,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("sambanova-ai-provider", () => ({
|
||||
createSambaNova: vi.fn(() => {
|
||||
// Return a function that returns a mock language model
|
||||
return vi.fn(() => ({
|
||||
modelId: "Meta-Llama-3.3-70B-Instruct",
|
||||
provider: "sambanova",
|
||||
}))
|
||||
}),
|
||||
}))
|
||||
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { sambaNovaDefaultModelId, sambaNovaModels, type SambaNovaModelId } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
import { SambaNovaHandler } from "../sambanova"
|
||||
|
||||
describe("SambaNovaHandler", () => {
|
||||
let handler: SambaNovaHandler
|
||||
let mockCreate: any
|
||||
let mockOptions: ApiHandlerOptions
|
||||
|
||||
beforeEach(() => {
|
||||
vitest.clearAllMocks()
|
||||
mockCreate = (OpenAI as unknown as any)().chat.completions.create
|
||||
handler = new SambaNovaHandler({ sambaNovaApiKey: "test-sambanova-api-key" })
|
||||
})
|
||||
|
||||
it("should use the correct SambaNova base URL", () => {
|
||||
new SambaNovaHandler({ sambaNovaApiKey: "test-sambanova-api-key" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://api.sambanova.ai/v1" }))
|
||||
})
|
||||
|
||||
it("should use the provided API key", () => {
|
||||
const sambaNovaApiKey = "test-sambanova-api-key"
|
||||
new SambaNovaHandler({ sambaNovaApiKey })
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: sambaNovaApiKey }))
|
||||
})
|
||||
|
||||
it("should return default model when no model is specified", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe(sambaNovaDefaultModelId)
|
||||
expect(model.info).toEqual(sambaNovaModels[sambaNovaDefaultModelId])
|
||||
})
|
||||
|
||||
it("should return specified model when valid model is provided", () => {
|
||||
const testModelId: SambaNovaModelId = "Meta-Llama-3.3-70B-Instruct"
|
||||
const handlerWithModel = new SambaNovaHandler({
|
||||
apiModelId: testModelId,
|
||||
mockOptions = {
|
||||
sambaNovaApiKey: "test-sambanova-api-key",
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.id).toBe(testModelId)
|
||||
expect(model.info).toEqual(sambaNovaModels[testModelId])
|
||||
apiModelId: "Meta-Llama-3.3-70B-Instruct",
|
||||
}
|
||||
handler = new SambaNovaHandler(mockOptions)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("completePrompt method should return text from SambaNova API", async () => {
|
||||
const expectedResponse = "This is a test response from SambaNova"
|
||||
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] })
|
||||
const result = await handler.completePrompt("test prompt")
|
||||
expect(result).toBe(expectedResponse)
|
||||
})
|
||||
|
||||
it("should handle errors in completePrompt", async () => {
|
||||
const errorMessage = "SambaNova API error"
|
||||
mockCreate.mockRejectedValueOnce(new Error(errorMessage))
|
||||
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
|
||||
`SambaNova completion error: ${errorMessage}`,
|
||||
)
|
||||
})
|
||||
|
||||
it("createMessage should yield text content from stream", async () => {
|
||||
const testContent = "This is test content from SambaNova stream"
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vitest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: { content: testContent } }] },
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
describe("constructor", () => {
|
||||
it("should initialize with provided options", () => {
|
||||
expect(handler).toBeInstanceOf(SambaNovaHandler)
|
||||
expect(handler.getModel().id).toBe(mockOptions.apiModelId)
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({ type: "text", text: testContent })
|
||||
it("should use default model ID if not provided", () => {
|
||||
const handlerWithoutModel = new SambaNovaHandler({
|
||||
...mockOptions,
|
||||
apiModelId: undefined,
|
||||
})
|
||||
expect(handlerWithoutModel.getModel().id).toBe(sambaNovaDefaultModelId)
|
||||
})
|
||||
})
|
||||
|
||||
it("createMessage should yield usage data from stream", async () => {
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vitest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } },
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
describe("getModel", () => {
|
||||
it("should return default model when no model is specified", () => {
|
||||
const handlerWithoutModel = new SambaNovaHandler({
|
||||
sambaNovaApiKey: "test-sambanova-api-key",
|
||||
})
|
||||
const model = handlerWithoutModel.getModel()
|
||||
expect(model.id).toBe(sambaNovaDefaultModelId)
|
||||
expect(model.info).toEqual(sambaNovaModels[sambaNovaDefaultModelId])
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
it("should return specified model when valid model is provided", () => {
|
||||
const testModelId: SambaNovaModelId = "Meta-Llama-3.3-70B-Instruct"
|
||||
const handlerWithModel = new SambaNovaHandler({
|
||||
apiModelId: testModelId,
|
||||
sambaNovaApiKey: "test-sambanova-api-key",
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.id).toBe(testModelId)
|
||||
expect(model.info).toEqual(sambaNovaModels[testModelId])
|
||||
})
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 })
|
||||
it("should return Meta-Llama-3.1-8B-Instruct model with correct configuration", () => {
|
||||
const testModelId: SambaNovaModelId = "Meta-Llama-3.1-8B-Instruct"
|
||||
const handlerWithModel = new SambaNovaHandler({
|
||||
apiModelId: testModelId,
|
||||
sambaNovaApiKey: "test-sambanova-api-key",
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.id).toBe(testModelId)
|
||||
expect(model.info).toBeDefined()
|
||||
expect(model.info.maxTokens).toBeDefined()
|
||||
expect(model.info.contextWindow).toBeDefined()
|
||||
})
|
||||
|
||||
it("should return provided model ID with default model info if model does not exist", () => {
|
||||
const handlerWithInvalidModel = new SambaNovaHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "invalid-model",
|
||||
})
|
||||
const model = handlerWithInvalidModel.getModel()
|
||||
expect(model.id).toBe("invalid-model")
|
||||
expect(model.info).toBeDefined()
|
||||
// Should use default model info
|
||||
expect(model.info).toBe(sambaNovaModels[sambaNovaDefaultModelId])
|
||||
})
|
||||
|
||||
it("should include model parameters from getModelParams", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model).toHaveProperty("temperature")
|
||||
expect(model).toHaveProperty("maxTokens")
|
||||
})
|
||||
})
|
||||
|
||||
it("createMessage should pass correct parameters to SambaNova client", async () => {
|
||||
const modelId: SambaNovaModelId = "Meta-Llama-3.3-70B-Instruct"
|
||||
const modelInfo = sambaNovaModels[modelId]
|
||||
const handlerWithModel = new SambaNovaHandler({
|
||||
apiModelId: modelId,
|
||||
sambaNovaApiKey: "test-sambanova-api-key",
|
||||
})
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
describe("createMessage", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "Hello!",
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
it("should handle streaming responses", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response from SambaNova" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks).toHaveLength(1)
|
||||
expect(textChunks[0].text).toBe("Test response from SambaNova")
|
||||
})
|
||||
|
||||
const systemPrompt = "Test system prompt for SambaNova"
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for SambaNova" }]
|
||||
it("should include usage information", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages)
|
||||
await messageGenerator.next()
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
})
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: modelId,
|
||||
max_tokens: modelInfo.maxTokens,
|
||||
temperature: 0.7,
|
||||
messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]),
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
}),
|
||||
undefined,
|
||||
)
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0].inputTokens).toBe(10)
|
||||
expect(usageChunks[0].outputTokens).toBe(20)
|
||||
})
|
||||
|
||||
it("should handle cached tokens in usage data from providerMetadata", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
})
|
||||
|
||||
// SambaNova provides cache metrics via providerMetadata for supported models
|
||||
const mockProviderMetadata = Promise.resolve({
|
||||
sambanova: {
|
||||
promptCacheHitTokens: 30,
|
||||
promptCacheMissTokens: 70,
|
||||
},
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0].inputTokens).toBe(100)
|
||||
expect(usageChunks[0].outputTokens).toBe(50)
|
||||
expect(usageChunks[0].cacheReadTokens).toBe(30)
|
||||
expect(usageChunks[0].cacheWriteTokens).toBe(70)
|
||||
})
|
||||
|
||||
it("should handle usage with details.cachedInputTokens when providerMetadata is not available", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
details: {
|
||||
cachedInputTokens: 25,
|
||||
},
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0].cacheReadTokens).toBe(25)
|
||||
expect(usageChunks[0].cacheWriteTokens).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should pass correct temperature (0.7 default) to streamText", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const handlerWithDefaultTemp = new SambaNovaHandler({
|
||||
sambaNovaApiKey: "test-key",
|
||||
apiModelId: "Meta-Llama-3.3-70B-Instruct",
|
||||
})
|
||||
|
||||
const stream = handlerWithDefaultTemp.createMessage(systemPrompt, messages)
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 0.7,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use user-specified temperature over model and provider defaults", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const handlerWithCustomTemp = new SambaNovaHandler({
|
||||
sambaNovaApiKey: "test-key",
|
||||
apiModelId: "Meta-Llama-3.3-70B-Instruct",
|
||||
modelTemperature: 0.7,
|
||||
})
|
||||
|
||||
const stream = handlerWithCustomTemp.createMessage(systemPrompt, messages)
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
// User-specified temperature should take precedence over everything
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 0.7,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle stream with multiple chunks", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Hello" }
|
||||
yield { type: "text-delta", text: " world" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 5, outputTokens: 10 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const textChunks = chunks.filter((c) => c.type === "text")
|
||||
expect(textChunks[0]).toEqual({ type: "text", text: "Hello" })
|
||||
expect(textChunks[1]).toEqual({ type: "text", text: " world" })
|
||||
|
||||
const usageChunks = chunks.filter((c) => c.type === "usage")
|
||||
expect(usageChunks[0]).toMatchObject({ type: "usage", inputTokens: 5, outputTokens: 10 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("completePrompt", () => {
|
||||
it("should complete a prompt using generateText", async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test completion from SambaNova",
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
|
||||
expect(result).toBe("Test completion from SambaNova")
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "Test prompt",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use default temperature in completePrompt", async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test completion",
|
||||
})
|
||||
|
||||
await handler.completePrompt("Test prompt")
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 0.7,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("processUsageMetrics", () => {
|
||||
it("should correctly process usage metrics including cache information from providerMetadata", () => {
|
||||
class TestSambaNovaHandler extends SambaNovaHandler {
|
||||
public testProcessUsageMetrics(usage: any, providerMetadata?: any) {
|
||||
return this.processUsageMetrics(usage, providerMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestSambaNovaHandler(mockOptions)
|
||||
|
||||
const usage = {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
}
|
||||
|
||||
const providerMetadata = {
|
||||
sambanova: {
|
||||
promptCacheHitTokens: 20,
|
||||
promptCacheMissTokens: 80,
|
||||
},
|
||||
}
|
||||
|
||||
const result = testHandler.testProcessUsageMetrics(usage, providerMetadata)
|
||||
|
||||
expect(result.type).toBe("usage")
|
||||
expect(result.inputTokens).toBe(100)
|
||||
expect(result.outputTokens).toBe(50)
|
||||
expect(result.cacheWriteTokens).toBe(80)
|
||||
expect(result.cacheReadTokens).toBe(20)
|
||||
})
|
||||
|
||||
it("should handle missing cache metrics gracefully", () => {
|
||||
class TestSambaNovaHandler extends SambaNovaHandler {
|
||||
public testProcessUsageMetrics(usage: any, providerMetadata?: any) {
|
||||
return this.processUsageMetrics(usage, providerMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestSambaNovaHandler(mockOptions)
|
||||
|
||||
const usage = {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
}
|
||||
|
||||
const result = testHandler.testProcessUsageMetrics(usage)
|
||||
|
||||
expect(result.type).toBe("usage")
|
||||
expect(result.inputTokens).toBe(100)
|
||||
expect(result.outputTokens).toBe(50)
|
||||
expect(result.cacheWriteTokens).toBeUndefined()
|
||||
expect(result.cacheReadTokens).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should include reasoning tokens when provided", () => {
|
||||
class TestSambaNovaHandler extends SambaNovaHandler {
|
||||
public testProcessUsageMetrics(usage: any, providerMetadata?: any) {
|
||||
return this.processUsageMetrics(usage, providerMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestSambaNovaHandler(mockOptions)
|
||||
|
||||
const usage = {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
details: {
|
||||
reasoningTokens: 30,
|
||||
},
|
||||
}
|
||||
|
||||
const result = testHandler.testProcessUsageMetrics(usage)
|
||||
|
||||
expect(result.reasoningTokens).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool handling", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text" as const, text: "Hello!" }],
|
||||
},
|
||||
]
|
||||
|
||||
it("should handle tool calls in streaming", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield {
|
||||
type: "tool-input-start",
|
||||
id: "tool-call-1",
|
||||
toolName: "read_file",
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-delta",
|
||||
id: "tool-call-1",
|
||||
delta: '{"path":"test.ts"}',
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-end",
|
||||
id: "tool-call-1",
|
||||
}
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages, {
|
||||
taskId: "test-task",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
description: "Read a file",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string" } },
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start")
|
||||
const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta")
|
||||
const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end")
|
||||
|
||||
expect(toolCallStartChunks.length).toBe(1)
|
||||
expect(toolCallStartChunks[0].id).toBe("tool-call-1")
|
||||
expect(toolCallStartChunks[0].name).toBe("read_file")
|
||||
|
||||
expect(toolCallDeltaChunks.length).toBe(1)
|
||||
expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}')
|
||||
|
||||
expect(toolCallEndChunks.length).toBe(1)
|
||||
expect(toolCallEndChunks[0].id).toBe("tool-call-1")
|
||||
})
|
||||
|
||||
it("should ignore tool-call events to prevent duplicate tools in UI", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield {
|
||||
type: "tool-call",
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "read_file",
|
||||
input: { path: "test.ts" },
|
||||
}
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// tool-call events should be ignored (only tool-input-start/delta/end are processed)
|
||||
const toolCallChunks = chunks.filter(
|
||||
(c) => c.type === "tool_call_start" || c.type === "tool_call_delta" || c.type === "tool_call_end",
|
||||
)
|
||||
expect(toolCallChunks.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text" as const, text: "Hello!" }],
|
||||
},
|
||||
]
|
||||
|
||||
it("should handle AI SDK errors with handleAiSdkError", async () => {
|
||||
// eslint-disable-next-line require-yield
|
||||
async function* mockFullStream(): AsyncGenerator<any> {
|
||||
throw new Error("API Error")
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
}).rejects.toThrow("SambaNova: API Error")
|
||||
})
|
||||
|
||||
it("should preserve status codes in error handling", async () => {
|
||||
const apiError = new Error("Rate limit exceeded")
|
||||
;(apiError as any).status = 429
|
||||
|
||||
// eslint-disable-next-line require-yield
|
||||
async function* mockFullStream(): AsyncGenerator<any> {
|
||||
throw apiError
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
|
||||
try {
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
expect.fail("Should have thrown an error")
|
||||
} catch (error: any) {
|
||||
expect(error.message).toContain("SambaNova")
|
||||
expect(error.status).toBe(429)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,6 +3,32 @@
|
|||
// Mock vscode first to avoid import errors
|
||||
vitest.mock("vscode", () => ({}))
|
||||
|
||||
// Mock the createVertex function from @ai-sdk/google-vertex
|
||||
const mockCreateVertex = vitest.fn()
|
||||
|
||||
vitest.mock("@ai-sdk/google-vertex", () => ({
|
||||
createVertex: (...args: unknown[]) => {
|
||||
mockCreateVertex(...args)
|
||||
const provider = Object.assign((modelId: string) => ({ modelId }), {
|
||||
tools: {},
|
||||
})
|
||||
return provider
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the AI SDK functions
|
||||
const mockStreamText = vitest.fn()
|
||||
const mockGenerateText = vitest.fn()
|
||||
|
||||
vitest.mock("ai", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
...original,
|
||||
streamText: (...args: unknown[]) => mockStreamText(...args),
|
||||
generateText: (...args: unknown[]) => mockGenerateText(...args),
|
||||
}
|
||||
})
|
||||
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { ApiStreamChunk } from "../../transform/stream"
|
||||
|
|
@ -14,25 +40,103 @@ describe("VertexHandler", () => {
|
|||
let handler: VertexHandler
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock functions
|
||||
const mockGenerateContentStream = vitest.fn()
|
||||
const mockGenerateContent = vitest.fn()
|
||||
const mockGetGenerativeModel = vitest.fn()
|
||||
mockStreamText.mockClear()
|
||||
mockGenerateText.mockClear()
|
||||
mockCreateVertex.mockClear()
|
||||
|
||||
handler = new VertexHandler({
|
||||
apiModelId: "gemini-1.5-pro-001",
|
||||
vertexProjectId: "test-project",
|
||||
vertexRegion: "us-central1",
|
||||
})
|
||||
})
|
||||
|
||||
// Replace the client with our mock
|
||||
handler["client"] = {
|
||||
models: {
|
||||
generateContentStream: mockGenerateContentStream,
|
||||
generateContent: mockGenerateContent,
|
||||
getGenerativeModel: mockGetGenerativeModel,
|
||||
},
|
||||
} as any
|
||||
describe("constructor", () => {
|
||||
it("should create provider with project and location", () => {
|
||||
new VertexHandler({
|
||||
apiModelId: "gemini-1.5-pro-001",
|
||||
vertexProjectId: "my-project",
|
||||
vertexRegion: "europe-west1",
|
||||
})
|
||||
|
||||
expect(mockCreateVertex).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
project: "my-project",
|
||||
location: "europe-west1",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should create provider with JSON credentials", () => {
|
||||
const credentials = { type: "service_account", project_id: "test" }
|
||||
|
||||
new VertexHandler({
|
||||
apiModelId: "gemini-1.5-pro-001",
|
||||
vertexProjectId: "my-project",
|
||||
vertexRegion: "us-central1",
|
||||
vertexJsonCredentials: JSON.stringify(credentials),
|
||||
})
|
||||
|
||||
expect(mockCreateVertex).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
project: "my-project",
|
||||
location: "us-central1",
|
||||
googleAuthOptions: { credentials },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should create provider with key file", () => {
|
||||
new VertexHandler({
|
||||
apiModelId: "gemini-1.5-pro-001",
|
||||
vertexProjectId: "my-project",
|
||||
vertexRegion: "us-central1",
|
||||
vertexKeyFile: "/path/to/keyfile.json",
|
||||
})
|
||||
|
||||
expect(mockCreateVertex).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
project: "my-project",
|
||||
location: "us-central1",
|
||||
googleAuthOptions: { keyFile: "/path/to/keyfile.json" },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should prefer JSON credentials over key file", () => {
|
||||
const credentials = { type: "service_account", project_id: "test" }
|
||||
|
||||
new VertexHandler({
|
||||
apiModelId: "gemini-1.5-pro-001",
|
||||
vertexProjectId: "my-project",
|
||||
vertexRegion: "us-central1",
|
||||
vertexJsonCredentials: JSON.stringify(credentials),
|
||||
vertexKeyFile: "/path/to/keyfile.json",
|
||||
})
|
||||
|
||||
expect(mockCreateVertex).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
googleAuthOptions: { credentials },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle invalid JSON credentials gracefully", () => {
|
||||
new VertexHandler({
|
||||
apiModelId: "gemini-1.5-pro-001",
|
||||
vertexProjectId: "my-project",
|
||||
vertexRegion: "us-central1",
|
||||
vertexJsonCredentials: "invalid-json",
|
||||
})
|
||||
|
||||
// Should not throw and should create provider without credentials
|
||||
expect(mockCreateVertex).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
project: "my-project",
|
||||
googleAuthOptions: undefined,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
|
|
@ -43,19 +147,11 @@ describe("VertexHandler", () => {
|
|||
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
|
||||
it("should handle streaming responses correctly for Gemini", async () => {
|
||||
// Let's examine the test expectations and adjust our mock accordingly
|
||||
// The test expects 4 chunks:
|
||||
// 1. Usage chunk with input tokens
|
||||
// 2. Text chunk with "Gemini response part 1"
|
||||
// 3. Text chunk with " part 2"
|
||||
// 4. Usage chunk with output tokens
|
||||
|
||||
// Let's modify our approach and directly mock the createMessage method
|
||||
// instead of mocking the client
|
||||
it("should handle streaming responses correctly", async () => {
|
||||
// Mock the createMessage method to test the streaming behavior
|
||||
vitest.spyOn(handler, "createMessage").mockImplementation(async function* () {
|
||||
yield { type: "usage", inputTokens: 10, outputTokens: 0 }
|
||||
yield { type: "text", text: "Gemini response part 1" }
|
||||
yield { type: "text", text: "Vertex response part 1" }
|
||||
yield { type: "text", text: " part 2" }
|
||||
yield { type: "usage", inputTokens: 0, outputTokens: 5 }
|
||||
})
|
||||
|
|
@ -70,50 +166,69 @@ describe("VertexHandler", () => {
|
|||
|
||||
expect(chunks.length).toBe(4)
|
||||
expect(chunks[0]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 0 })
|
||||
expect(chunks[1]).toEqual({ type: "text", text: "Gemini response part 1" })
|
||||
expect(chunks[1]).toEqual({ type: "text", text: "Vertex response part 1" })
|
||||
expect(chunks[2]).toEqual({ type: "text", text: " part 2" })
|
||||
expect(chunks[3]).toEqual({ type: "usage", inputTokens: 0, outputTokens: 5 })
|
||||
})
|
||||
|
||||
// Since we're directly mocking createMessage, we don't need to verify
|
||||
// that generateContentStream was called
|
||||
it("should call streamText with correct options", async () => {
|
||||
const mockFullStream = (async function* () {
|
||||
yield { type: "text-delta", textDelta: "Hello" }
|
||||
})()
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream,
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, mockMessages)
|
||||
const chunks: ApiStreamChunk[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
system: systemPrompt,
|
||||
temperature: 1,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("completePrompt", () => {
|
||||
it("should complete prompt successfully for Gemini", async () => {
|
||||
// Mock the response with text property
|
||||
;(handler["client"].models.generateContent as any).mockResolvedValue({
|
||||
text: "Test Gemini response",
|
||||
it("should complete prompt successfully", async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test Vertex response",
|
||||
providerMetadata: {},
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
expect(result).toBe("Test Gemini response")
|
||||
expect(result).toBe("Test Vertex response")
|
||||
|
||||
// Verify the call to generateContent
|
||||
expect(handler["client"].models.generateContent).toHaveBeenCalledWith(
|
||||
// Verify generateText was called with the prompt
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: expect.any(String),
|
||||
contents: [{ role: "user", parts: [{ text: "Test prompt" }] }],
|
||||
config: expect.objectContaining({
|
||||
temperature: 1,
|
||||
}),
|
||||
prompt: "Test prompt",
|
||||
temperature: 1,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle API errors for Gemini", async () => {
|
||||
it("should handle API errors", async () => {
|
||||
const mockError = new Error("Vertex API error")
|
||||
;(handler["client"].models.generateContent as any).mockRejectedValue(mockError)
|
||||
mockGenerateText.mockRejectedValue(mockError)
|
||||
|
||||
await expect(handler.completePrompt("Test prompt")).rejects.toThrow(
|
||||
t("common:errors.gemini.generate_complete_prompt", { error: "Vertex API error" }),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle empty response for Gemini", async () => {
|
||||
// Mock the response with empty text
|
||||
;(handler["client"].models.generateContent as any).mockResolvedValue({
|
||||
it("should handle empty response", async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "",
|
||||
providerMetadata: {},
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
|
|
@ -122,7 +237,7 @@ describe("VertexHandler", () => {
|
|||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return correct model info for Gemini", () => {
|
||||
it("should return correct model info", () => {
|
||||
// Create a new instance with specific model ID
|
||||
const testHandler = new VertexHandler({
|
||||
apiModelId: "gemini-2.0-flash-001",
|
||||
|
|
@ -130,12 +245,135 @@ describe("VertexHandler", () => {
|
|||
vertexRegion: "us-central1",
|
||||
})
|
||||
|
||||
// Don't mock getModel here as we want to test the actual implementation
|
||||
const modelInfo = testHandler.getModel()
|
||||
expect(modelInfo.id).toBe("gemini-2.0-flash-001")
|
||||
expect(modelInfo.info).toBeDefined()
|
||||
expect(modelInfo.info.maxTokens).toBe(8192)
|
||||
expect(modelInfo.info.contextWindow).toBe(1048576)
|
||||
})
|
||||
|
||||
it("should return default model when invalid ID provided", () => {
|
||||
const testHandler = new VertexHandler({
|
||||
apiModelId: "invalid-model-id",
|
||||
vertexProjectId: "test-project",
|
||||
vertexRegion: "us-central1",
|
||||
})
|
||||
|
||||
const modelInfo = testHandler.getModel()
|
||||
// Should fall back to default model
|
||||
expect(modelInfo.info).toBeDefined()
|
||||
})
|
||||
|
||||
it("should strip :thinking suffix from model ID", () => {
|
||||
const testHandler = new VertexHandler({
|
||||
apiModelId: "gemini-2.5-flash-preview-05-20:thinking",
|
||||
vertexProjectId: "test-project",
|
||||
vertexRegion: "us-central1",
|
||||
})
|
||||
|
||||
const modelInfo = testHandler.getModel()
|
||||
expect(modelInfo.id).toBe("gemini-2.5-flash-preview-05-20")
|
||||
})
|
||||
})
|
||||
|
||||
describe("calculateCost", () => {
|
||||
it("should calculate cost correctly", () => {
|
||||
const result = handler.calculateCost({
|
||||
info: {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1048576,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 1.25,
|
||||
outputPrice: 5.0,
|
||||
},
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
})
|
||||
|
||||
// Input: 1.25 * (1000 / 1_000_000) = 0.00125
|
||||
// Output: 5.0 * (500 / 1_000_000) = 0.0025
|
||||
// Total: 0.00375
|
||||
expect(result).toBeCloseTo(0.00375, 5)
|
||||
})
|
||||
|
||||
it("should handle cache read tokens", () => {
|
||||
const result = handler.calculateCost({
|
||||
info: {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1048576,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 1.25,
|
||||
outputPrice: 5.0,
|
||||
cacheReadsPrice: 0.3125,
|
||||
},
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
cacheReadTokens: 400,
|
||||
})
|
||||
|
||||
// Uncached input: 600 tokens at 1.25/M = 0.00075
|
||||
// Cache read: 400 tokens at 0.3125/M = 0.000125
|
||||
// Output: 500 tokens at 5.0/M = 0.0025
|
||||
// Total: 0.003375
|
||||
expect(result).toBeCloseTo(0.003375, 5)
|
||||
})
|
||||
|
||||
it("should handle reasoning tokens", () => {
|
||||
const result = handler.calculateCost({
|
||||
info: {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1048576,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 1.25,
|
||||
outputPrice: 5.0,
|
||||
},
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
reasoningTokens: 200,
|
||||
})
|
||||
|
||||
// Input: 1.25 * (1000 / 1_000_000) = 0.00125
|
||||
// Output + Reasoning: 5.0 * (700 / 1_000_000) = 0.0035
|
||||
// Total: 0.00475
|
||||
expect(result).toBeCloseTo(0.00475, 5)
|
||||
})
|
||||
|
||||
it("should return undefined when prices are missing", () => {
|
||||
const result = handler.calculateCost({
|
||||
info: {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1048576,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
})
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should use tiered pricing when available", () => {
|
||||
const result = handler.calculateCost({
|
||||
info: {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1048576,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 1.25,
|
||||
outputPrice: 5.0,
|
||||
tiers: [
|
||||
{ contextWindow: 128000, inputPrice: 0.5, outputPrice: 2.0 },
|
||||
{ contextWindow: 1048576, inputPrice: 1.0, outputPrice: 4.0 },
|
||||
],
|
||||
},
|
||||
inputTokens: 50000, // Falls into first tier
|
||||
outputTokens: 500,
|
||||
})
|
||||
|
||||
// Uses tier 1 pricing: inputPrice=0.5, outputPrice=2.0
|
||||
// Input: 0.5 * (50000 / 1_000_000) = 0.025
|
||||
// Output: 2.0 * (500 / 1_000_000) = 0.001
|
||||
// Total: 0.026
|
||||
expect(result).toBeCloseTo(0.026, 5)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,30 @@
|
|||
// npx vitest run src/api/providers/__tests__/zai.spec.ts
|
||||
|
||||
import OpenAI from "openai"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
|
||||
const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
|
||||
mockStreamText: vi.fn(),
|
||||
mockGenerateText: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
...actual,
|
||||
streamText: mockStreamText,
|
||||
generateText: mockGenerateText,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("zhipu-ai-provider", () => ({
|
||||
createZhipu: vi.fn(() => {
|
||||
return vi.fn(() => ({
|
||||
modelId: "glm-4.6",
|
||||
provider: "zhipu",
|
||||
}))
|
||||
}),
|
||||
}))
|
||||
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import {
|
||||
type InternationalZAiModelId,
|
||||
|
|
@ -13,22 +36,36 @@ import {
|
|||
ZAI_DEFAULT_TEMPERATURE,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import { ZAiHandler } from "../zai"
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
vitest.mock("openai", () => {
|
||||
const createMock = vitest.fn()
|
||||
return {
|
||||
default: vitest.fn(() => ({ chat: { completions: { create: createMock } } })),
|
||||
}
|
||||
})
|
||||
import { ZAiHandler } from "../zai"
|
||||
|
||||
describe("ZAiHandler", () => {
|
||||
let handler: ZAiHandler
|
||||
let mockCreate: any
|
||||
let mockOptions: ApiHandlerOptions
|
||||
|
||||
beforeEach(() => {
|
||||
vitest.clearAllMocks()
|
||||
mockCreate = (OpenAI as unknown as any)().chat.completions.create
|
||||
mockOptions = {
|
||||
zaiApiKey: "test-zai-api-key",
|
||||
zaiApiLine: "international_coding",
|
||||
apiModelId: "glm-4.6",
|
||||
}
|
||||
handler = new ZAiHandler(mockOptions)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should initialize with provided options", () => {
|
||||
expect(handler).toBeInstanceOf(ZAiHandler)
|
||||
expect(handler.getModel().id).toBe(mockOptions.apiModelId)
|
||||
})
|
||||
|
||||
it("should default to international when no zaiApiLine is specified", () => {
|
||||
const handlerDefault = new ZAiHandler({ zaiApiKey: "test-zai-api-key" })
|
||||
const model = handlerDefault.getModel()
|
||||
expect(model.id).toBe(internationalZAiDefaultModelId)
|
||||
expect(model.info).toEqual(internationalZAiModels[internationalZAiDefaultModelId])
|
||||
})
|
||||
})
|
||||
|
||||
describe("International Z AI", () => {
|
||||
|
|
@ -36,21 +73,6 @@ describe("ZAiHandler", () => {
|
|||
handler = new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "international_coding" })
|
||||
})
|
||||
|
||||
it("should use the correct international Z AI base URL", () => {
|
||||
new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "international_coding" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://api.z.ai/api/coding/paas/v4",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use the provided API key for international", () => {
|
||||
const zaiApiKey = "test-zai-api-key"
|
||||
new ZAiHandler({ zaiApiKey, zaiApiLine: "international_coding" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: zaiApiKey }))
|
||||
})
|
||||
|
||||
it("should return international default model when no model is specified", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe(internationalZAiDefaultModelId)
|
||||
|
|
@ -119,19 +141,6 @@ describe("ZAiHandler", () => {
|
|||
handler = new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "china_coding" })
|
||||
})
|
||||
|
||||
it("should use the correct China Z AI base URL", () => {
|
||||
new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "china_coding" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseURL: "https://open.bigmodel.cn/api/coding/paas/v4" }),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use the provided API key for China", () => {
|
||||
const zaiApiKey = "test-zai-api-key"
|
||||
new ZAiHandler({ zaiApiKey, zaiApiLine: "china_coding" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: zaiApiKey }))
|
||||
})
|
||||
|
||||
it("should return China default model when no model is specified", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe(mainlandZAiDefaultModelId)
|
||||
|
|
@ -200,21 +209,6 @@ describe("ZAiHandler", () => {
|
|||
handler = new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "international_api" })
|
||||
})
|
||||
|
||||
it("should use the correct international API base URL", () => {
|
||||
new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "international_api" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://api.z.ai/api/paas/v4",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use the provided API key for international API", () => {
|
||||
const zaiApiKey = "test-zai-api-key"
|
||||
new ZAiHandler({ zaiApiKey, zaiApiLine: "international_api" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: zaiApiKey }))
|
||||
})
|
||||
|
||||
it("should return international default model when no model is specified", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe(internationalZAiDefaultModelId)
|
||||
|
|
@ -239,21 +233,6 @@ describe("ZAiHandler", () => {
|
|||
handler = new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "china_api" })
|
||||
})
|
||||
|
||||
it("should use the correct China API base URL", () => {
|
||||
new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "china_api" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://open.bigmodel.cn/api/paas/v4",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use the provided API key for China API", () => {
|
||||
const zaiApiKey = "test-zai-api-key"
|
||||
new ZAiHandler({ zaiApiKey, zaiApiLine: "china_api" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: zaiApiKey }))
|
||||
})
|
||||
|
||||
it("should return China default model when no model is specified", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe(mainlandZAiDefaultModelId)
|
||||
|
|
@ -273,133 +252,98 @@ describe("ZAiHandler", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("Default behavior", () => {
|
||||
it("should default to international when no zaiApiLine is specified", () => {
|
||||
const handlerDefault = new ZAiHandler({ zaiApiKey: "test-zai-api-key" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://api.z.ai/api/coding/paas/v4",
|
||||
}),
|
||||
)
|
||||
|
||||
const model = handlerDefault.getModel()
|
||||
expect(model.id).toBe(internationalZAiDefaultModelId)
|
||||
expect(model.info).toEqual(internationalZAiModels[internationalZAiDefaultModelId])
|
||||
})
|
||||
|
||||
it("should use 'not-provided' as default API key when none is specified", () => {
|
||||
new ZAiHandler({ zaiApiLine: "international_coding" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "not-provided" }))
|
||||
describe("getModel", () => {
|
||||
it("should include model parameters from getModelParams", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model).toHaveProperty("temperature")
|
||||
expect(model).toHaveProperty("maxTokens")
|
||||
})
|
||||
})
|
||||
|
||||
describe("API Methods", () => {
|
||||
beforeEach(() => {
|
||||
handler = new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "international_coding" })
|
||||
})
|
||||
describe("createMessage", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text" as const, text: "Hello!" }],
|
||||
},
|
||||
]
|
||||
|
||||
it("completePrompt method should return text from Z AI API", async () => {
|
||||
const expectedResponse = "This is a test response from Z AI"
|
||||
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] })
|
||||
const result = await handler.completePrompt("test prompt")
|
||||
expect(result).toBe(expectedResponse)
|
||||
})
|
||||
it("should handle streaming responses", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response from Z.ai" }
|
||||
}
|
||||
|
||||
it("should handle errors in completePrompt", async () => {
|
||||
const errorMessage = "Z AI API error"
|
||||
mockCreate.mockRejectedValueOnce(new Error(errorMessage))
|
||||
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
|
||||
`Z.ai completion error: ${errorMessage}`,
|
||||
)
|
||||
})
|
||||
|
||||
it("createMessage should yield text content from stream", async () => {
|
||||
const testContent = "This is test content from Z AI stream"
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vitest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: { content: testContent } }] },
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({ type: "text", text: testContent })
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks).toHaveLength(1)
|
||||
expect(textChunks[0].text).toBe("Test response from Z.ai")
|
||||
})
|
||||
|
||||
it("createMessage should yield usage data from stream", async () => {
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vitest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
choices: [{ delta: {} }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20 },
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
it("should include usage information", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 })
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0].inputTokens).toBe(10)
|
||||
expect(usageChunks[0].outputTokens).toBe(20)
|
||||
})
|
||||
|
||||
it("createMessage should pass correct parameters to Z AI client", async () => {
|
||||
const modelId: InternationalZAiModelId = "glm-4.5"
|
||||
const modelInfo = internationalZAiModels[modelId]
|
||||
const handlerWithModel = new ZAiHandler({
|
||||
apiModelId: modelId,
|
||||
zaiApiKey: "test-zai-api-key",
|
||||
zaiApiLine: "international_coding",
|
||||
it("should pass correct parameters to streamText", async () => {
|
||||
async function* mockFullStream() {
|
||||
// empty stream
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
})
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
// Consume the stream
|
||||
for await (const _chunk of stream) {
|
||||
// drain
|
||||
}
|
||||
|
||||
const systemPrompt = "Test system prompt for Z AI"
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for Z AI" }]
|
||||
|
||||
const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages)
|
||||
await messageGenerator.next()
|
||||
|
||||
// Centralized 20% cap should apply to OpenAI-compatible providers like Z AI
|
||||
const expectedMaxTokens = Math.min(modelInfo.maxTokens, Math.ceil(modelInfo.contextWindow * 0.2))
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: modelId,
|
||||
max_tokens: expectedMaxTokens,
|
||||
temperature: ZAI_DEFAULT_TEMPERATURE,
|
||||
messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]),
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
system: systemPrompt,
|
||||
temperature: expect.any(Number),
|
||||
}),
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -410,27 +354,29 @@ describe("ZAiHandler", () => {
|
|||
apiModelId: "glm-4.7",
|
||||
zaiApiKey: "test-zai-api-key",
|
||||
zaiApiLine: "international_coding",
|
||||
// No reasoningEffort setting - should use model default (medium)
|
||||
})
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
}
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "response" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
})
|
||||
|
||||
const messageGenerator = handlerWithModel.createMessage("system prompt", [])
|
||||
await messageGenerator.next()
|
||||
const stream = handlerWithModel.createMessage("system prompt", [])
|
||||
for await (const _chunk of stream) {
|
||||
// drain
|
||||
}
|
||||
|
||||
// For GLM-4.7 with default reasoning (medium), thinking should be enabled
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: "glm-4.7",
|
||||
thinking: { type: "enabled" },
|
||||
providerOptions: {
|
||||
zhipu: {
|
||||
thinking: { type: "enabled" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -444,24 +390,27 @@ describe("ZAiHandler", () => {
|
|||
reasoningEffort: "disable",
|
||||
})
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
}
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "response" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
})
|
||||
|
||||
const messageGenerator = handlerWithModel.createMessage("system prompt", [])
|
||||
await messageGenerator.next()
|
||||
const stream = handlerWithModel.createMessage("system prompt", [])
|
||||
for await (const _chunk of stream) {
|
||||
// drain
|
||||
}
|
||||
|
||||
// For GLM-4.7 with reasoning disabled, thinking should be disabled
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: "glm-4.7",
|
||||
thinking: { type: "disabled" },
|
||||
providerOptions: {
|
||||
zhipu: {
|
||||
thinking: { type: "disabled" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -475,51 +424,109 @@ describe("ZAiHandler", () => {
|
|||
reasoningEffort: "medium",
|
||||
})
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
}
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "response" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
})
|
||||
|
||||
const messageGenerator = handlerWithModel.createMessage("system prompt", [])
|
||||
await messageGenerator.next()
|
||||
const stream = handlerWithModel.createMessage("system prompt", [])
|
||||
for await (const _chunk of stream) {
|
||||
// drain
|
||||
}
|
||||
|
||||
// For GLM-4.7 with reasoning set to medium, thinking should be enabled
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: "glm-4.7",
|
||||
thinking: { type: "enabled" },
|
||||
providerOptions: {
|
||||
zhipu: {
|
||||
thinking: { type: "enabled" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should NOT add thinking parameter for non-thinking models like GLM-4.6", async () => {
|
||||
it("should NOT add providerOptions for non-thinking models like GLM-4.6", async () => {
|
||||
const handlerWithModel = new ZAiHandler({
|
||||
apiModelId: "glm-4.6",
|
||||
zaiApiKey: "test-zai-api-key",
|
||||
zaiApiLine: "international_coding",
|
||||
})
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
}
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "response" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
})
|
||||
|
||||
const messageGenerator = handlerWithModel.createMessage("system prompt", [])
|
||||
await messageGenerator.next()
|
||||
const stream = handlerWithModel.createMessage("system prompt", [])
|
||||
for await (const _chunk of stream) {
|
||||
// drain
|
||||
}
|
||||
|
||||
// For GLM-4.6 (no thinking support), thinking parameter should not be present
|
||||
const callArgs = mockCreate.mock.calls[0][0]
|
||||
expect(callArgs.thinking).toBeUndefined()
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
expect(callArgs.providerOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should handle reasoning content in streaming responses", async () => {
|
||||
const handlerWithModel = new ZAiHandler({
|
||||
apiModelId: "glm-4.7",
|
||||
zaiApiKey: "test-zai-api-key",
|
||||
zaiApiLine: "international_coding",
|
||||
})
|
||||
|
||||
async function* mockFullStream() {
|
||||
yield { type: "reasoning", text: "Let me think about this..." }
|
||||
yield { type: "text-delta", text: "Here is my answer" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }),
|
||||
})
|
||||
|
||||
const stream = handlerWithModel.createMessage("system prompt", [])
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
|
||||
expect(reasoningChunks).toHaveLength(1)
|
||||
expect(reasoningChunks[0].text).toBe("Let me think about this...")
|
||||
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks).toHaveLength(1)
|
||||
expect(textChunks[0].text).toBe("Here is my answer")
|
||||
})
|
||||
})
|
||||
|
||||
describe("completePrompt", () => {
|
||||
it("should complete a prompt using generateText", async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test completion from Z.ai",
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
|
||||
expect(result).toBe("Test completion from Z.ai")
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "Test prompt",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isAiSdkProvider", () => {
|
||||
it("should return true", () => {
|
||||
expect(handler.isAiSdkProvider()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -231,7 +231,13 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple
|
|||
}
|
||||
}
|
||||
|
||||
const params = getModelParams({ format: "anthropic", modelId: id, model: info, settings: this.options })
|
||||
const params = getModelParams({
|
||||
format: "anthropic",
|
||||
modelId: id,
|
||||
model: info,
|
||||
settings: this.options,
|
||||
defaultTemperature: 0,
|
||||
})
|
||||
|
||||
// Build betas array for request headers
|
||||
const betas: string[] = []
|
||||
|
|
|
|||
|
|
@ -64,9 +64,11 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
|
|||
// Filter out non-Anthropic blocks (reasoning, thoughtSignature, etc.) before sending to the API
|
||||
const sanitizedMessages = filterNonAnthropicBlocks(messages)
|
||||
|
||||
// Add 1M context beta flag if enabled for Claude Sonnet 4 and 4.5
|
||||
// Add 1M context beta flag if enabled for supported models (Claude Sonnet 4/4.5, Opus 4.6)
|
||||
if (
|
||||
(modelId === "claude-sonnet-4-20250514" || modelId === "claude-sonnet-4-5") &&
|
||||
(modelId === "claude-sonnet-4-20250514" ||
|
||||
modelId === "claude-sonnet-4-5" ||
|
||||
modelId === "claude-opus-4-6") &&
|
||||
this.options.anthropicBeta1MContext
|
||||
) {
|
||||
betas.push("context-1m-2025-08-07")
|
||||
|
|
@ -80,6 +82,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
|
|||
switch (modelId) {
|
||||
case "claude-sonnet-4-5":
|
||||
case "claude-sonnet-4-20250514":
|
||||
case "claude-opus-4-6":
|
||||
case "claude-opus-4-5-20251101":
|
||||
case "claude-opus-4-1-20250805":
|
||||
case "claude-opus-4-20250514":
|
||||
|
|
@ -144,6 +147,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
|
|||
switch (modelId) {
|
||||
case "claude-sonnet-4-5":
|
||||
case "claude-sonnet-4-20250514":
|
||||
case "claude-opus-4-6":
|
||||
case "claude-opus-4-5-20251101":
|
||||
case "claude-opus-4-1-20250805":
|
||||
case "claude-opus-4-20250514":
|
||||
|
|
@ -330,8 +334,11 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
|
|||
let id = modelId && modelId in anthropicModels ? (modelId as AnthropicModelId) : anthropicDefaultModelId
|
||||
let info: ModelInfo = anthropicModels[id]
|
||||
|
||||
// If 1M context beta is enabled for Claude Sonnet 4 or 4.5, update the model info
|
||||
if ((id === "claude-sonnet-4-20250514" || id === "claude-sonnet-4-5") && this.options.anthropicBeta1MContext) {
|
||||
// If 1M context beta is enabled for supported models, update the model info
|
||||
if (
|
||||
(id === "claude-sonnet-4-20250514" || id === "claude-sonnet-4-5" || id === "claude-opus-4-6") &&
|
||||
this.options.anthropicBeta1MContext
|
||||
) {
|
||||
// Use the tier pricing for 1M context
|
||||
const tier = info.tiers?.[0]
|
||||
if (tier) {
|
||||
|
|
@ -351,6 +358,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
|
|||
modelId: id,
|
||||
model: info,
|
||||
settings: this.options,
|
||||
defaultTemperature: 0,
|
||||
})
|
||||
|
||||
// The `:thinking` suffix indicates that the model is a "Hybrid"
|
||||
|
|
|
|||
|
|
@ -119,4 +119,12 @@ export abstract class BaseProvider implements ApiHandler {
|
|||
|
||||
return countTokens(content, { useWorker: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation returns false.
|
||||
* AI SDK providers should override this to return true.
|
||||
*/
|
||||
isAiSdkProvider(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,156 @@
|
|||
import { type BasetenModelId, basetenDefaultModelId, basetenModels } from "@roo-code/types"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { createBaseten } from "@ai-sdk/baseten"
|
||||
import { streamText, generateText, ToolSet } from "ai"
|
||||
|
||||
import { basetenModels, basetenDefaultModelId, type ModelInfo } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider"
|
||||
|
||||
export class BasetenHandler extends BaseOpenAiCompatibleProvider<BasetenModelId> {
|
||||
import {
|
||||
convertToAiSdkMessages,
|
||||
convertToolsForAiSdk,
|
||||
processAiSdkStreamPart,
|
||||
mapToolChoice,
|
||||
handleAiSdkError,
|
||||
} from "../transform/ai-sdk"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import { getModelParams } from "../transform/model-params"
|
||||
|
||||
import { DEFAULT_HEADERS } from "./constants"
|
||||
import { BaseProvider } from "./base-provider"
|
||||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
|
||||
const BASETEN_DEFAULT_TEMPERATURE = 0.5
|
||||
|
||||
/**
|
||||
* Baseten provider using the dedicated @ai-sdk/baseten package.
|
||||
* Provides native support for Baseten's inference API.
|
||||
*/
|
||||
export class BasetenHandler extends BaseProvider implements SingleCompletionHandler {
|
||||
protected options: ApiHandlerOptions
|
||||
protected provider: ReturnType<typeof createBaseten>
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
super({
|
||||
...options,
|
||||
providerName: "Baseten",
|
||||
super()
|
||||
this.options = options
|
||||
|
||||
this.provider = createBaseten({
|
||||
baseURL: "https://inference.baseten.co/v1",
|
||||
apiKey: options.basetenApiKey,
|
||||
defaultProviderModelId: basetenDefaultModelId,
|
||||
providerModels: basetenModels,
|
||||
defaultTemperature: 0.5,
|
||||
apiKey: options.basetenApiKey ?? "not-provided",
|
||||
headers: DEFAULT_HEADERS,
|
||||
})
|
||||
}
|
||||
|
||||
override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } {
|
||||
const id = this.options.apiModelId ?? basetenDefaultModelId
|
||||
const info = basetenModels[id as keyof typeof basetenModels] || basetenModels[basetenDefaultModelId]
|
||||
const params = getModelParams({
|
||||
format: "openai",
|
||||
modelId: id,
|
||||
model: info,
|
||||
settings: this.options,
|
||||
defaultTemperature: BASETEN_DEFAULT_TEMPERATURE,
|
||||
})
|
||||
return { id, info, ...params }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the language model for the configured model ID.
|
||||
*/
|
||||
protected getLanguageModel() {
|
||||
const { id } = this.getModel()
|
||||
return this.provider(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process usage metrics from the AI SDK response.
|
||||
*/
|
||||
protected processUsageMetrics(usage: {
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
details?: {
|
||||
cachedInputTokens?: number
|
||||
reasoningTokens?: number
|
||||
}
|
||||
}): ApiStreamUsageChunk {
|
||||
return {
|
||||
type: "usage",
|
||||
inputTokens: usage.inputTokens || 0,
|
||||
outputTokens: usage.outputTokens || 0,
|
||||
reasoningTokens: usage.details?.reasoningTokens,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the max tokens parameter to include in the request.
|
||||
*/
|
||||
protected getMaxOutputTokens(): number | undefined {
|
||||
const { info } = this.getModel()
|
||||
return this.options.modelMaxTokens || info.maxTokens || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a message stream using the AI SDK.
|
||||
*/
|
||||
override async *createMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const { temperature } = this.getModel()
|
||||
const languageModel = this.getLanguageModel()
|
||||
|
||||
const aiSdkMessages = convertToAiSdkMessages(messages)
|
||||
|
||||
const openAiTools = this.convertToolsForOpenAI(metadata?.tools)
|
||||
const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined
|
||||
|
||||
const requestOptions: Parameters<typeof streamText>[0] = {
|
||||
model: languageModel,
|
||||
system: systemPrompt,
|
||||
messages: aiSdkMessages,
|
||||
temperature: this.options.modelTemperature ?? temperature ?? BASETEN_DEFAULT_TEMPERATURE,
|
||||
maxOutputTokens: this.getMaxOutputTokens(),
|
||||
tools: aiSdkTools,
|
||||
toolChoice: mapToolChoice(metadata?.tool_choice),
|
||||
}
|
||||
|
||||
const result = streamText(requestOptions)
|
||||
|
||||
try {
|
||||
for await (const part of result.fullStream) {
|
||||
for (const chunk of processAiSdkStreamPart(part)) {
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
|
||||
const usage = await result.usage
|
||||
if (usage) {
|
||||
yield this.processUsageMetrics(usage)
|
||||
}
|
||||
} catch (error) {
|
||||
throw handleAiSdkError(error, "Baseten")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a prompt using the AI SDK generateText.
|
||||
*/
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
const { temperature } = this.getModel()
|
||||
const languageModel = this.getLanguageModel()
|
||||
|
||||
const { text } = await generateText({
|
||||
model: languageModel,
|
||||
prompt,
|
||||
maxOutputTokens: this.getMaxOutputTokens(),
|
||||
temperature: this.options.modelTemperature ?? temperature ?? BASETEN_DEFAULT_TEMPERATURE,
|
||||
})
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
override isAiSdkProvider(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -49,7 +49,13 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan
|
|||
override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } {
|
||||
const id = (this.options.apiModelId ?? cerebrasDefaultModelId) as CerebrasModelId
|
||||
const info = cerebrasModels[id as keyof typeof cerebrasModels] || cerebrasModels[cerebrasDefaultModelId]
|
||||
const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options })
|
||||
const params = getModelParams({
|
||||
format: "openai",
|
||||
modelId: id,
|
||||
model: info,
|
||||
settings: this.options,
|
||||
defaultTemperature: CEREBRAS_DEFAULT_TEMPERATURE,
|
||||
})
|
||||
return { id, info, ...params }
|
||||
}
|
||||
|
||||
|
|
@ -156,4 +162,8 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan
|
|||
|
||||
return text
|
||||
}
|
||||
|
||||
override isAiSdkProvider(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,62 +1,110 @@
|
|||
import { DEEP_SEEK_DEFAULT_TEMPERATURE, chutesDefaultModelId, chutesDefaultModelInfo } from "@roo-code/types"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { streamText, generateText, LanguageModel, ToolSet } from "ai"
|
||||
|
||||
import {
|
||||
DEEP_SEEK_DEFAULT_TEMPERATURE,
|
||||
chutesDefaultModelId,
|
||||
chutesDefaultModelInfo,
|
||||
type ModelInfo,
|
||||
type ModelRecord,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
import { getModelMaxOutputTokens } from "../../shared/api"
|
||||
import { TagMatcher } from "../../utils/tag-matcher"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import {
|
||||
convertToAiSdkMessages,
|
||||
convertToolsForAiSdk,
|
||||
processAiSdkStreamPart,
|
||||
mapToolChoice,
|
||||
handleAiSdkError,
|
||||
} from "../transform/ai-sdk"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
|
||||
import { RouterProvider } from "./router-provider"
|
||||
import { OpenAICompatibleHandler, OpenAICompatibleConfig } from "./openai-compatible"
|
||||
import { getModels, getModelsFromCache } from "./fetchers/modelCache"
|
||||
|
||||
export class ChutesHandler extends OpenAICompatibleHandler implements SingleCompletionHandler {
|
||||
private models: ModelRecord = {}
|
||||
|
||||
export class ChutesHandler extends RouterProvider implements SingleCompletionHandler {
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
super({
|
||||
options,
|
||||
name: "chutes",
|
||||
const modelId = options.apiModelId ?? chutesDefaultModelId
|
||||
|
||||
const config: OpenAICompatibleConfig = {
|
||||
providerName: "chutes",
|
||||
baseURL: "https://llm.chutes.ai/v1",
|
||||
apiKey: options.chutesApiKey,
|
||||
modelId: options.apiModelId,
|
||||
defaultModelId: chutesDefaultModelId,
|
||||
defaultModelInfo: chutesDefaultModelInfo,
|
||||
})
|
||||
apiKey: options.chutesApiKey ?? "not-provided",
|
||||
modelId,
|
||||
modelInfo: chutesDefaultModelInfo,
|
||||
}
|
||||
|
||||
super(options, config)
|
||||
}
|
||||
|
||||
private getCompletionParams(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming {
|
||||
const { id: model, info } = this.getModel()
|
||||
async fetchModel() {
|
||||
this.models = await getModels({ provider: "chutes", apiKey: this.config.apiKey, baseUrl: this.config.baseURL })
|
||||
return this.getModel()
|
||||
}
|
||||
|
||||
// Centralized cap: clamp to 20% of the context window (unless provider-specific exceptions apply)
|
||||
const max_tokens =
|
||||
override getModel(): { id: string; info: ModelInfo; temperature?: number } {
|
||||
const id = this.options.apiModelId ?? chutesDefaultModelId
|
||||
|
||||
let info: ModelInfo | undefined = this.models[id]
|
||||
|
||||
if (!info) {
|
||||
const cachedModels = getModelsFromCache("chutes")
|
||||
if (cachedModels?.[id]) {
|
||||
this.models = cachedModels
|
||||
info = cachedModels[id]
|
||||
}
|
||||
}
|
||||
|
||||
if (!info) {
|
||||
const isDeepSeekR1 = chutesDefaultModelId.includes("DeepSeek-R1")
|
||||
const defaultTemp = isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0.5
|
||||
return {
|
||||
id: chutesDefaultModelId,
|
||||
info: {
|
||||
...chutesDefaultModelInfo,
|
||||
defaultTemperature: defaultTemp,
|
||||
},
|
||||
temperature: this.options.modelTemperature ?? defaultTemp,
|
||||
}
|
||||
}
|
||||
|
||||
const isDeepSeekR1 = id.includes("DeepSeek-R1")
|
||||
const defaultTemp = isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0.5
|
||||
|
||||
return {
|
||||
id,
|
||||
info: {
|
||||
...info,
|
||||
defaultTemperature: defaultTemp,
|
||||
},
|
||||
temperature: this.supportsTemperature(id) ? (this.options.modelTemperature ?? defaultTemp) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
protected override getLanguageModel(): LanguageModel {
|
||||
const { id } = this.getModel()
|
||||
return this.provider(id)
|
||||
}
|
||||
|
||||
protected override getMaxOutputTokens(): number | undefined {
|
||||
const { id, info } = this.getModel()
|
||||
return (
|
||||
getModelMaxOutputTokens({
|
||||
modelId: model,
|
||||
modelId: id,
|
||||
model: info,
|
||||
settings: this.options,
|
||||
format: "openai",
|
||||
}) ?? undefined
|
||||
)
|
||||
}
|
||||
|
||||
const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
|
||||
model,
|
||||
max_tokens,
|
||||
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
tools: metadata?.tools,
|
||||
tool_choice: metadata?.tool_choice,
|
||||
}
|
||||
|
||||
// Only add temperature if model supports it
|
||||
if (this.supportsTemperature(model)) {
|
||||
params.temperature = this.options.modelTemperature ?? info.temperature
|
||||
}
|
||||
|
||||
return params
|
||||
private supportsTemperature(modelId: string): boolean {
|
||||
return !modelId.startsWith("openai/o3-mini")
|
||||
}
|
||||
|
||||
override async *createMessage(
|
||||
|
|
@ -67,125 +115,123 @@ export class ChutesHandler extends RouterProvider implements SingleCompletionHan
|
|||
const model = await this.fetchModel()
|
||||
|
||||
if (model.id.includes("DeepSeek-R1")) {
|
||||
const stream = await this.client.chat.completions.create({
|
||||
...this.getCompletionParams(systemPrompt, messages, metadata),
|
||||
messages: convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]),
|
||||
})
|
||||
|
||||
const matcher = new TagMatcher(
|
||||
"think",
|
||||
(chunk) =>
|
||||
({
|
||||
type: chunk.matched ? "reasoning" : "text",
|
||||
text: chunk.data,
|
||||
}) as const,
|
||||
)
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
|
||||
if (delta?.content) {
|
||||
for (const processedChunk of matcher.update(delta.content)) {
|
||||
yield processedChunk
|
||||
}
|
||||
}
|
||||
|
||||
// Emit raw tool call chunks - NativeToolCallParser handles state management
|
||||
if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
yield {
|
||||
type: "tool_call_partial",
|
||||
index: toolCall.index,
|
||||
id: toolCall.id,
|
||||
name: toolCall.function?.name,
|
||||
arguments: toolCall.function?.arguments,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining content
|
||||
for (const processedChunk of matcher.final()) {
|
||||
yield processedChunk
|
||||
}
|
||||
yield* this.createR1Message(systemPrompt, messages, model, metadata)
|
||||
} else {
|
||||
// For non-DeepSeek-R1 models, use standard OpenAI streaming
|
||||
const stream = await this.client.chat.completions.create(
|
||||
this.getCompletionParams(systemPrompt, messages, metadata),
|
||||
)
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
|
||||
if (delta?.content) {
|
||||
yield { type: "text", text: delta.content }
|
||||
}
|
||||
|
||||
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
|
||||
yield { type: "reasoning", text: (delta.reasoning_content as string | undefined) || "" }
|
||||
}
|
||||
|
||||
// Emit raw tool call chunks - NativeToolCallParser handles state management
|
||||
if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
yield {
|
||||
type: "tool_call_partial",
|
||||
index: toolCall.index,
|
||||
id: toolCall.id,
|
||||
name: toolCall.function?.name,
|
||||
arguments: toolCall.function?.arguments,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
yield* super.createMessage(systemPrompt, messages, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
const model = await this.fetchModel()
|
||||
const { id: modelId, info } = model
|
||||
private async *createR1Message(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
model: { id: string; info: ModelInfo },
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const languageModel = this.getLanguageModel()
|
||||
|
||||
const modifiedMessages = [...messages] as Anthropic.Messages.MessageParam[]
|
||||
|
||||
if (modifiedMessages.length > 0 && modifiedMessages[0].role === "user") {
|
||||
const first = modifiedMessages[0]
|
||||
if (typeof first.content === "string") {
|
||||
modifiedMessages[0] = { role: "user", content: `${systemPrompt}\n\n${first.content}` }
|
||||
} else {
|
||||
modifiedMessages[0] = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: systemPrompt }, ...first.content],
|
||||
}
|
||||
}
|
||||
} else {
|
||||
modifiedMessages.unshift({ role: "user", content: systemPrompt })
|
||||
}
|
||||
|
||||
const aiSdkMessages = convertToAiSdkMessages(modifiedMessages)
|
||||
|
||||
const openAiTools = this.convertToolsForOpenAI(metadata?.tools)
|
||||
const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined
|
||||
|
||||
const maxOutputTokens =
|
||||
getModelMaxOutputTokens({
|
||||
modelId: model.id,
|
||||
model: model.info,
|
||||
settings: this.options,
|
||||
format: "openai",
|
||||
}) ?? undefined
|
||||
|
||||
const temperature = this.supportsTemperature(model.id)
|
||||
? (this.options.modelTemperature ?? model.info.defaultTemperature)
|
||||
: undefined
|
||||
|
||||
const result = streamText({
|
||||
model: languageModel,
|
||||
messages: aiSdkMessages,
|
||||
temperature,
|
||||
maxOutputTokens,
|
||||
tools: aiSdkTools,
|
||||
toolChoice: mapToolChoice(metadata?.tool_choice),
|
||||
})
|
||||
|
||||
const matcher = new TagMatcher(
|
||||
"think",
|
||||
(chunk) =>
|
||||
({
|
||||
type: chunk.matched ? "reasoning" : "text",
|
||||
text: chunk.data,
|
||||
}) as const,
|
||||
)
|
||||
|
||||
try {
|
||||
// Centralized cap: clamp to 20% of the context window (unless provider-specific exceptions apply)
|
||||
const max_tokens =
|
||||
getModelMaxOutputTokens({
|
||||
modelId,
|
||||
model: info,
|
||||
settings: this.options,
|
||||
format: "openai",
|
||||
}) ?? undefined
|
||||
|
||||
const requestParams: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
|
||||
model: modelId,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
max_tokens,
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === "text-delta") {
|
||||
for (const processedChunk of matcher.update(part.text)) {
|
||||
yield processedChunk
|
||||
}
|
||||
} else {
|
||||
for (const chunk of processAiSdkStreamPart(part)) {
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only add temperature if model supports it
|
||||
if (this.supportsTemperature(modelId)) {
|
||||
const isDeepSeekR1 = modelId.includes("DeepSeek-R1")
|
||||
const defaultTemperature = isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0.5
|
||||
requestParams.temperature = this.options.modelTemperature ?? defaultTemperature
|
||||
for (const processedChunk of matcher.final()) {
|
||||
yield processedChunk
|
||||
}
|
||||
|
||||
const response = await this.client.chat.completions.create(requestParams)
|
||||
return response.choices[0]?.message.content || ""
|
||||
const usage = await result.usage
|
||||
if (usage) {
|
||||
yield this.processUsageMetrics(usage)
|
||||
}
|
||||
} catch (error) {
|
||||
throw handleAiSdkError(error, "chutes")
|
||||
}
|
||||
}
|
||||
|
||||
override async completePrompt(prompt: string): Promise<string> {
|
||||
const model = await this.fetchModel()
|
||||
const languageModel = this.getLanguageModel()
|
||||
|
||||
const maxOutputTokens =
|
||||
getModelMaxOutputTokens({
|
||||
modelId: model.id,
|
||||
model: model.info,
|
||||
settings: this.options,
|
||||
format: "openai",
|
||||
}) ?? undefined
|
||||
|
||||
const isDeepSeekR1 = model.id.includes("DeepSeek-R1")
|
||||
const defaultTemperature = isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0.5
|
||||
const temperature = this.supportsTemperature(model.id)
|
||||
? (this.options.modelTemperature ?? defaultTemperature)
|
||||
: undefined
|
||||
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: languageModel,
|
||||
prompt,
|
||||
maxOutputTokens,
|
||||
temperature,
|
||||
})
|
||||
return text
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Chutes completion error: ${error.message}`)
|
||||
|
|
@ -193,17 +239,4 @@ export class ChutesHandler extends RouterProvider implements SingleCompletionHan
|
|||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
override getModel() {
|
||||
const model = super.getModel()
|
||||
const isDeepSeekR1 = model.id.includes("DeepSeek-R1")
|
||||
|
||||
return {
|
||||
...model,
|
||||
info: {
|
||||
...model.info,
|
||||
temperature: isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0.5,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export class DeepInfraHandler extends RouterProvider implements SingleCompletion
|
|||
modelId: id,
|
||||
model: info,
|
||||
settings: this.options,
|
||||
defaultTemperature: 0,
|
||||
})
|
||||
|
||||
return { id, info, ...params }
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan
|
|||
|
||||
// Create the DeepSeek provider using AI SDK
|
||||
this.provider = createDeepSeek({
|
||||
baseURL: options.deepSeekBaseUrl ?? "https://api.deepseek.com/v1",
|
||||
baseURL: options.deepSeekBaseUrl || "https://api.deepseek.com/v1",
|
||||
apiKey: options.deepSeekApiKey ?? "not-provided",
|
||||
headers: DEFAULT_HEADERS,
|
||||
})
|
||||
|
|
@ -43,7 +43,13 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan
|
|||
override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } {
|
||||
const id = this.options.apiModelId ?? deepSeekDefaultModelId
|
||||
const info = deepSeekModels[id as keyof typeof deepSeekModels] || deepSeekModels[deepSeekDefaultModelId]
|
||||
const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options })
|
||||
const params = getModelParams({
|
||||
format: "openai",
|
||||
modelId: id,
|
||||
model: info,
|
||||
settings: this.options,
|
||||
defaultTemperature: DEEP_SEEK_DEFAULT_TEMPERATURE,
|
||||
})
|
||||
return { id, info, ...params }
|
||||
}
|
||||
|
||||
|
|
@ -166,4 +172,8 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan
|
|||
|
||||
return text
|
||||
}
|
||||
|
||||
override isAiSdkProvider(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,13 @@ export class DoubaoHandler extends OpenAiHandler {
|
|||
override getModel() {
|
||||
const id = this.options.apiModelId ?? doubaoDefaultModelId
|
||||
const info = doubaoModels[id as keyof typeof doubaoModels] || doubaoModels[doubaoDefaultModelId]
|
||||
const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options })
|
||||
const params = getModelParams({
|
||||
format: "openai",
|
||||
modelId: id,
|
||||
model: info,
|
||||
settings: this.options,
|
||||
defaultTemperature: 0,
|
||||
})
|
||||
return { id, info, ...params }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,4 +78,8 @@ export class FakeAIHandler implements ApiHandler, SingleCompletionHandler {
|
|||
completePrompt(prompt: string): Promise<string> {
|
||||
return this.ai.completePrompt(prompt)
|
||||
}
|
||||
|
||||
isAiSdkProvider(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,55 +1,88 @@
|
|||
import {
|
||||
DEEP_SEEK_DEFAULT_TEMPERATURE,
|
||||
type FeatherlessModelId,
|
||||
featherlessDefaultModelId,
|
||||
featherlessModels,
|
||||
} from "@roo-code/types"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { streamText } from "ai"
|
||||
|
||||
import { DEEP_SEEK_DEFAULT_TEMPERATURE, featherlessDefaultModelId, featherlessModels } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
import { TagMatcher } from "../../utils/tag-matcher"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { convertToAiSdkMessages, handleAiSdkError } from "../transform/ai-sdk"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { getModelParams } from "../transform/model-params"
|
||||
|
||||
import type { ApiHandlerCreateMessageMetadata } from "../index"
|
||||
import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider"
|
||||
import { OpenAICompatibleHandler, OpenAICompatibleConfig } from "./openai-compatible"
|
||||
|
||||
export class FeatherlessHandler extends BaseOpenAiCompatibleProvider<FeatherlessModelId> {
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
super({
|
||||
...options,
|
||||
providerName: "Featherless",
|
||||
baseURL: "https://api.featherless.ai/v1",
|
||||
apiKey: options.featherlessApiKey,
|
||||
defaultProviderModelId: featherlessDefaultModelId,
|
||||
providerModels: featherlessModels,
|
||||
defaultTemperature: 0.5,
|
||||
})
|
||||
/**
|
||||
* Merge consecutive Anthropic messages that share the same role.
|
||||
* DeepSeek R1 does not support successive messages with the same role,
|
||||
* so this is needed when the system prompt is injected as a user message
|
||||
* before the existing conversation (which may also start with a user message).
|
||||
*/
|
||||
function mergeConsecutiveSameRoleMessages(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
if (messages.length <= 1) {
|
||||
return messages
|
||||
}
|
||||
|
||||
private getCompletionParams(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
): OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming {
|
||||
const {
|
||||
id: model,
|
||||
info: { maxTokens: max_tokens },
|
||||
} = this.getModel()
|
||||
const merged: Anthropic.Messages.MessageParam[] = []
|
||||
|
||||
const temperature = this.options.modelTemperature ?? this.getModel().info.temperature
|
||||
for (const msg of messages) {
|
||||
const prev = merged[merged.length - 1]
|
||||
|
||||
return {
|
||||
model,
|
||||
max_tokens,
|
||||
temperature,
|
||||
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
if (prev && prev.role === msg.role) {
|
||||
const prevBlocks: Anthropic.Messages.ContentBlockParam[] =
|
||||
typeof prev.content === "string" ? [{ type: "text", text: prev.content }] : prev.content
|
||||
const currBlocks: Anthropic.Messages.ContentBlockParam[] =
|
||||
typeof msg.content === "string" ? [{ type: "text", text: msg.content }] : msg.content
|
||||
|
||||
merged[merged.length - 1] = {
|
||||
role: prev.role,
|
||||
content: [...prevBlocks, ...currBlocks],
|
||||
}
|
||||
} else {
|
||||
merged.push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
export class FeatherlessHandler extends OpenAICompatibleHandler {
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
const modelId = options.apiModelId ?? featherlessDefaultModelId
|
||||
const modelInfo =
|
||||
featherlessModels[modelId as keyof typeof featherlessModels] || featherlessModels[featherlessDefaultModelId]
|
||||
|
||||
const config: OpenAICompatibleConfig = {
|
||||
providerName: "Featherless",
|
||||
baseURL: "https://api.featherless.ai/v1",
|
||||
apiKey: options.featherlessApiKey ?? "not-provided",
|
||||
modelId,
|
||||
modelInfo,
|
||||
modelMaxTokens: options.modelMaxTokens ?? undefined,
|
||||
temperature: options.modelTemperature ?? undefined,
|
||||
}
|
||||
|
||||
super(options, config)
|
||||
}
|
||||
|
||||
override getModel() {
|
||||
const id = this.options.apiModelId ?? featherlessDefaultModelId
|
||||
const info =
|
||||
featherlessModels[id as keyof typeof featherlessModels] || featherlessModels[featherlessDefaultModelId]
|
||||
const isDeepSeekR1 = id.includes("DeepSeek-R1")
|
||||
const defaultTemp = isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0.5
|
||||
const params = getModelParams({
|
||||
format: "openai",
|
||||
modelId: id,
|
||||
model: info,
|
||||
settings: this.options,
|
||||
defaultTemperature: defaultTemp,
|
||||
})
|
||||
return { id, info, ...params }
|
||||
}
|
||||
|
||||
override async *createMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
|
|
@ -58,9 +91,17 @@ export class FeatherlessHandler extends BaseOpenAiCompatibleProvider<Featherless
|
|||
const model = this.getModel()
|
||||
|
||||
if (model.id.includes("DeepSeek-R1")) {
|
||||
const stream = await this.client.chat.completions.create({
|
||||
...this.getCompletionParams(systemPrompt, messages),
|
||||
messages: convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]),
|
||||
// R1 path: merge system prompt into user messages, use TagMatcher for <think> tags.
|
||||
// mergeConsecutiveSameRoleMessages ensures no two successive messages share the
|
||||
// same role (e.g. the injected system-as-user + original first user message).
|
||||
const r1Messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: systemPrompt }, ...messages]
|
||||
const aiSdkMessages = convertToAiSdkMessages(mergeConsecutiveSameRoleMessages(r1Messages))
|
||||
|
||||
const result = streamText({
|
||||
model: this.getLanguageModel(),
|
||||
messages: aiSdkMessages,
|
||||
temperature: model.temperature ?? 0,
|
||||
maxOutputTokens: this.getMaxOutputTokens(),
|
||||
})
|
||||
|
||||
const matcher = new TagMatcher(
|
||||
|
|
@ -72,42 +113,28 @@ export class FeatherlessHandler extends BaseOpenAiCompatibleProvider<Featherless
|
|||
}) as const,
|
||||
)
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
|
||||
if (delta?.content) {
|
||||
for (const processedChunk of matcher.update(delta.content)) {
|
||||
yield processedChunk
|
||||
try {
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === "text-delta") {
|
||||
for (const processedChunk of matcher.update(part.text)) {
|
||||
yield processedChunk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
}
|
||||
for (const processedChunk of matcher.final()) {
|
||||
yield processedChunk
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining content
|
||||
for (const processedChunk of matcher.final()) {
|
||||
yield processedChunk
|
||||
const usage = await result.usage
|
||||
if (usage) {
|
||||
yield this.processUsageMetrics(usage)
|
||||
}
|
||||
} catch (error) {
|
||||
throw handleAiSdkError(error, "Featherless")
|
||||
}
|
||||
} else {
|
||||
yield* super.createMessage(systemPrompt, messages, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
override getModel() {
|
||||
const model = super.getModel()
|
||||
const isDeepSeekR1 = model.id.includes("DeepSeek-R1")
|
||||
return {
|
||||
...model,
|
||||
info: {
|
||||
...model.info,
|
||||
temperature: isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : this.defaultTemperature,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise<Model
|
|||
|
||||
switch (provider) {
|
||||
case "openrouter":
|
||||
models = await getOpenRouterModels()
|
||||
models = await getOpenRouterModels({ openRouterBaseUrl: options.baseUrl })
|
||||
break
|
||||
case "requesty":
|
||||
// Requesty models endpoint requires an API key for per-user custom policies.
|
||||
|
|
|
|||
|
|
@ -248,6 +248,16 @@ export const parseOpenRouterModel = ({
|
|||
modelInfo.maxTokens = anthropicModels["claude-opus-4-1-20250805"].maxTokens
|
||||
}
|
||||
|
||||
// Set claude-opus-4.5 model to use the correct configuration
|
||||
if (id === "anthropic/claude-opus-4.5") {
|
||||
modelInfo.maxTokens = anthropicModels["claude-opus-4-5-20251101"].maxTokens
|
||||
}
|
||||
|
||||
// Set claude-opus-4.6 model to use the correct configuration
|
||||
if (id === "anthropic/claude-opus-4.6") {
|
||||
modelInfo.maxTokens = anthropicModels["claude-opus-4-6"].maxTokens
|
||||
}
|
||||
|
||||
// Ensure correct reasoning handling for Claude Haiku 4.5 on OpenRouter
|
||||
// Use budget control and disable effort-based reasoning fallback
|
||||
if (id === "anthropic/claude-haiku-4.5") {
|
||||
|
|
|
|||
|
|
@ -172,4 +172,8 @@ export class FireworksHandler extends BaseProvider implements SingleCompletionHa
|
|||
|
||||
return text
|
||||
}
|
||||
|
||||
override isAiSdkProvider(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,6 @@
|
|||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
import {
|
||||
GoogleGenAI,
|
||||
type GenerateContentResponseUsageMetadata,
|
||||
type GenerateContentParameters,
|
||||
type GenerateContentConfig,
|
||||
type GroundingMetadata,
|
||||
FunctionCallingConfigMode,
|
||||
} from "@google/genai"
|
||||
import type { JWTInput } from "google-auth-library"
|
||||
import { createGoogleGenerativeAI, type GoogleGenerativeAIProvider } from "@ai-sdk/google"
|
||||
import { streamText, generateText, ToolSet } from "ai"
|
||||
|
||||
import {
|
||||
type ModelInfo,
|
||||
|
|
@ -16,59 +9,43 @@ import {
|
|||
geminiModels,
|
||||
ApiProviderError,
|
||||
} from "@roo-code/types"
|
||||
import { safeJsonParse } from "@roo-code/core"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
|
||||
import { convertAnthropicMessageToGemini } from "../transform/gemini-format"
|
||||
import {
|
||||
convertToAiSdkMessages,
|
||||
convertToolsForAiSdk,
|
||||
processAiSdkStreamPart,
|
||||
mapToolChoice,
|
||||
} from "../transform/ai-sdk"
|
||||
import { t } from "i18next"
|
||||
import type { ApiStream, GroundingSource } from "../transform/stream"
|
||||
import type { ApiStream, ApiStreamUsageChunk, GroundingSource } from "../transform/stream"
|
||||
import { getModelParams } from "../transform/model-params"
|
||||
|
||||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
import { BaseProvider } from "./base-provider"
|
||||
|
||||
type GeminiHandlerOptions = ApiHandlerOptions & {
|
||||
isVertex?: boolean
|
||||
}
|
||||
import { DEFAULT_HEADERS } from "./constants"
|
||||
|
||||
export class GeminiHandler extends BaseProvider implements SingleCompletionHandler {
|
||||
protected options: ApiHandlerOptions
|
||||
|
||||
private client: GoogleGenAI
|
||||
private lastThoughtSignature?: string
|
||||
private lastResponseId?: string
|
||||
protected provider: GoogleGenerativeAIProvider
|
||||
private readonly providerName = "Gemini"
|
||||
private lastThoughtSignature: string | undefined
|
||||
|
||||
constructor({ isVertex, ...options }: GeminiHandlerOptions) {
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
super()
|
||||
|
||||
this.options = options
|
||||
|
||||
const project = this.options.vertexProjectId ?? "not-provided"
|
||||
const location = this.options.vertexRegion ?? "not-provided"
|
||||
const apiKey = this.options.geminiApiKey ?? "not-provided"
|
||||
|
||||
this.client = this.options.vertexJsonCredentials
|
||||
? new GoogleGenAI({
|
||||
vertexai: true,
|
||||
project,
|
||||
location,
|
||||
googleAuthOptions: {
|
||||
credentials: safeJsonParse<JWTInput>(this.options.vertexJsonCredentials, undefined),
|
||||
},
|
||||
})
|
||||
: this.options.vertexKeyFile
|
||||
? new GoogleGenAI({
|
||||
vertexai: true,
|
||||
project,
|
||||
location,
|
||||
googleAuthOptions: { keyFile: this.options.vertexKeyFile },
|
||||
})
|
||||
: isVertex
|
||||
? new GoogleGenAI({ vertexai: true, project, location })
|
||||
: new GoogleGenAI({ apiKey })
|
||||
// Create the Google Generative AI provider using AI SDK
|
||||
// For Vertex AI, we still use this provider but with different authentication
|
||||
// (Vertex authentication happens separately)
|
||||
this.provider = createGoogleGenerativeAI({
|
||||
apiKey: this.options.geminiApiKey ?? "not-provided",
|
||||
baseURL: this.options.googleGeminiBaseUrl || undefined,
|
||||
headers: DEFAULT_HEADERS,
|
||||
})
|
||||
}
|
||||
|
||||
async *createMessage(
|
||||
|
|
@ -76,10 +53,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const { id: model, info, reasoning: thinkingConfig, maxTokens } = this.getModel()
|
||||
// Reset per-request metadata that we persist into apiConversationHistory.
|
||||
this.lastThoughtSignature = undefined
|
||||
this.lastResponseId = undefined
|
||||
const { id: modelId, info, reasoning: thinkingConfig, maxTokens } = this.getModel()
|
||||
|
||||
// For hybrid/budget reasoning models (e.g. Gemini 2.5 Pro), respect user-configured
|
||||
// modelMaxTokens so the ThinkingBudget slider can control the cap. For effort-only or
|
||||
|
|
@ -90,58 +64,6 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
? (this.options.modelMaxTokens ?? maxTokens ?? undefined)
|
||||
: (maxTokens ?? undefined)
|
||||
|
||||
// Gemini 3 validates thought signatures for tool/function calling steps.
|
||||
// We must round-trip the signature when tools are in use, even if the user chose
|
||||
// a minimal thinking level (or thinkingConfig is otherwise absent).
|
||||
const includeThoughtSignatures = Boolean(thinkingConfig) || Boolean(metadata?.tools?.length)
|
||||
|
||||
// The message list can include provider-specific meta entries such as
|
||||
// `{ type: "reasoning", ... }` that are intended only for providers like
|
||||
// openai-native. Gemini should never see those; they are not valid
|
||||
// Anthropic.MessageParam values and will cause failures (e.g. missing
|
||||
// `content` for the converter). Filter them out here.
|
||||
type ReasoningMetaLike = { type?: string }
|
||||
|
||||
const geminiMessages = messages.filter((message): message is Anthropic.Messages.MessageParam => {
|
||||
const meta = message as ReasoningMetaLike
|
||||
if (meta.type === "reasoning") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Build a map of tool IDs to names from previous messages
|
||||
// This is needed because Anthropic's tool_result blocks only contain the ID,
|
||||
// but Gemini requires the name in functionResponse
|
||||
const toolIdToName = new Map<string, string>()
|
||||
for (const message of messages) {
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "tool_use") {
|
||||
toolIdToName.set(block.id, block.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contents = geminiMessages
|
||||
.map((message) => convertAnthropicMessageToGemini(message, { includeThoughtSignatures, toolIdToName }))
|
||||
.flat()
|
||||
|
||||
// Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS).
|
||||
// Google built-in tools (Grounding, URL Context) are mutually exclusive
|
||||
// with function declarations in the Gemini API, so we always use
|
||||
// function declarations when tools are provided.
|
||||
const tools: GenerateContentConfig["tools"] = [
|
||||
{
|
||||
functionDeclarations: (metadata?.tools ?? []).map((tool) => ({
|
||||
name: (tool as any).function.name,
|
||||
description: (tool as any).function.description,
|
||||
parametersJsonSchema: (tool as any).function.parameters,
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
// Determine temperature respecting model capabilities and defaults:
|
||||
// - If supportsTemperature is explicitly false, ignore user overrides
|
||||
// and pin to the model's defaultTemperature (or omit if undefined).
|
||||
|
|
@ -152,190 +74,106 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
? (this.options.modelTemperature ?? info.defaultTemperature ?? 1)
|
||||
: info.defaultTemperature
|
||||
|
||||
const config: GenerateContentConfig = {
|
||||
systemInstruction,
|
||||
httpOptions: this.options.googleGeminiBaseUrl ? { baseUrl: this.options.googleGeminiBaseUrl } : undefined,
|
||||
thinkingConfig,
|
||||
maxOutputTokens,
|
||||
// The message list can include provider-specific meta entries such as
|
||||
// `{ type: "reasoning", ... }` that are intended only for providers like
|
||||
// openai-native. Gemini should never see those; they are not valid
|
||||
// Anthropic.MessageParam values and will cause failures.
|
||||
type ReasoningMetaLike = { type?: string }
|
||||
|
||||
const filteredMessages = messages.filter((message): message is Anthropic.Messages.MessageParam => {
|
||||
const meta = message as ReasoningMetaLike
|
||||
if (meta.type === "reasoning") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Convert messages to AI SDK format
|
||||
const aiSdkMessages = convertToAiSdkMessages(filteredMessages)
|
||||
|
||||
// Convert tools to OpenAI format first, then to AI SDK format
|
||||
let openAiTools = this.convertToolsForOpenAI(metadata?.tools)
|
||||
|
||||
// Filter tools based on allowedFunctionNames for mode-restricted tool access
|
||||
if (metadata?.allowedFunctionNames && metadata.allowedFunctionNames.length > 0 && openAiTools) {
|
||||
const allowedSet = new Set(metadata.allowedFunctionNames)
|
||||
openAiTools = openAiTools.filter((tool) => tool.type === "function" && allowedSet.has(tool.function.name))
|
||||
}
|
||||
|
||||
const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined
|
||||
|
||||
// Build tool choice - use 'required' when allowedFunctionNames restricts available tools
|
||||
const toolChoice =
|
||||
metadata?.allowedFunctionNames && metadata.allowedFunctionNames.length > 0
|
||||
? "required"
|
||||
: mapToolChoice(metadata?.tool_choice)
|
||||
|
||||
// Build the request options
|
||||
const requestOptions: Parameters<typeof streamText>[0] = {
|
||||
model: this.provider(modelId),
|
||||
system: systemInstruction,
|
||||
messages: aiSdkMessages,
|
||||
temperature: temperatureConfig,
|
||||
...(tools.length > 0 ? { tools } : {}),
|
||||
maxOutputTokens,
|
||||
tools: aiSdkTools,
|
||||
toolChoice,
|
||||
// Add thinking/reasoning configuration if present
|
||||
// Cast to any to bypass strict JSONObject typing - the AI SDK accepts the correct runtime values
|
||||
...(thinkingConfig && {
|
||||
providerOptions: { google: { thinkingConfig } } as any,
|
||||
}),
|
||||
}
|
||||
|
||||
// Handle allowedFunctionNames for mode-restricted tool access.
|
||||
// When provided, all tool definitions are passed to the model (so it can reference
|
||||
// historical tool calls in conversation), but only the specified tools can be invoked.
|
||||
// This takes precedence over tool_choice to ensure mode restrictions are honored.
|
||||
if (metadata?.allowedFunctionNames && metadata.allowedFunctionNames.length > 0) {
|
||||
config.toolConfig = {
|
||||
functionCallingConfig: {
|
||||
// Use ANY mode to allow calling any of the allowed functions
|
||||
mode: FunctionCallingConfigMode.ANY,
|
||||
allowedFunctionNames: metadata.allowedFunctionNames,
|
||||
},
|
||||
}
|
||||
} else if (metadata?.tool_choice) {
|
||||
const choice = metadata.tool_choice
|
||||
let mode: FunctionCallingConfigMode
|
||||
let allowedFunctionNames: string[] | undefined
|
||||
|
||||
if (choice === "auto") {
|
||||
mode = FunctionCallingConfigMode.AUTO
|
||||
} else if (choice === "none") {
|
||||
mode = FunctionCallingConfigMode.NONE
|
||||
} else if (choice === "required") {
|
||||
// "required" means the model must call at least one tool; Gemini uses ANY for this.
|
||||
mode = FunctionCallingConfigMode.ANY
|
||||
} else if (typeof choice === "object" && "function" in choice && choice.type === "function") {
|
||||
mode = FunctionCallingConfigMode.ANY
|
||||
allowedFunctionNames = [choice.function.name]
|
||||
} else {
|
||||
// Fall back to AUTO for unknown values to avoid unintentionally broadening tool access.
|
||||
mode = FunctionCallingConfigMode.AUTO
|
||||
}
|
||||
|
||||
config.toolConfig = {
|
||||
functionCallingConfig: {
|
||||
mode,
|
||||
...(allowedFunctionNames ? { allowedFunctionNames } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const params: GenerateContentParameters = { model, contents, config }
|
||||
|
||||
try {
|
||||
const result = await this.client.models.generateContentStream(params)
|
||||
// Reset thought signature for this request
|
||||
this.lastThoughtSignature = undefined
|
||||
|
||||
let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined
|
||||
let pendingGroundingMetadata: GroundingMetadata | undefined
|
||||
let finalResponse: { responseId?: string } | undefined
|
||||
let finishReason: string | undefined
|
||||
// Use streamText for streaming responses
|
||||
const result = streamText(requestOptions)
|
||||
|
||||
let toolCallCounter = 0
|
||||
let hasContent = false
|
||||
let hasReasoning = false
|
||||
|
||||
for await (const chunk of result) {
|
||||
// Track the final structured response (per SDK pattern: candidate.finishReason)
|
||||
if (chunk.candidates && chunk.candidates[0]?.finishReason) {
|
||||
finalResponse = chunk as { responseId?: string }
|
||||
finishReason = chunk.candidates[0].finishReason
|
||||
}
|
||||
// Process candidates and their parts to separate thoughts from content
|
||||
if (chunk.candidates && chunk.candidates.length > 0) {
|
||||
const candidate = chunk.candidates[0]
|
||||
|
||||
if (candidate.groundingMetadata) {
|
||||
pendingGroundingMetadata = candidate.groundingMetadata
|
||||
// Process the full stream to get all events including reasoning
|
||||
for await (const part of result.fullStream) {
|
||||
// Capture thoughtSignature from tool-call events (Gemini 3 thought signatures)
|
||||
// The AI SDK's tool-call event includes providerMetadata with the signature
|
||||
if (part.type === "tool-call") {
|
||||
const googleMeta = (part as any).providerMetadata?.google
|
||||
if (googleMeta?.thoughtSignature) {
|
||||
this.lastThoughtSignature = googleMeta.thoughtSignature
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate.content && candidate.content.parts) {
|
||||
for (const part of candidate.content.parts as Array<{
|
||||
thought?: boolean
|
||||
text?: string
|
||||
thoughtSignature?: string
|
||||
functionCall?: { name: string; args: Record<string, unknown> }
|
||||
}>) {
|
||||
// Capture thought signatures so they can be persisted into API history.
|
||||
const thoughtSignature = part.thoughtSignature
|
||||
// Persist thought signatures so they can be round-tripped in the next step.
|
||||
// Gemini 3 requires this during tool calling; other Gemini thinking models
|
||||
// benefit from it for continuity.
|
||||
if (includeThoughtSignatures && thoughtSignature) {
|
||||
this.lastThoughtSignature = thoughtSignature
|
||||
}
|
||||
for (const chunk of processAiSdkStreamPart(part)) {
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
|
||||
if (part.thought) {
|
||||
// This is a thinking/reasoning part
|
||||
if (part.text) {
|
||||
hasReasoning = true
|
||||
yield { type: "reasoning", text: part.text }
|
||||
}
|
||||
} else if (part.functionCall) {
|
||||
hasContent = true
|
||||
// Gemini sends complete function calls in a single chunk
|
||||
// Emit as partial chunks for consistent handling with NativeToolCallParser
|
||||
const callId = `${part.functionCall.name}-${toolCallCounter}`
|
||||
const args = JSON.stringify(part.functionCall.args)
|
||||
|
||||
// Emit name first
|
||||
yield {
|
||||
type: "tool_call_partial",
|
||||
index: toolCallCounter,
|
||||
id: callId,
|
||||
name: part.functionCall.name,
|
||||
arguments: undefined,
|
||||
}
|
||||
|
||||
// Then emit arguments
|
||||
yield {
|
||||
type: "tool_call_partial",
|
||||
index: toolCallCounter,
|
||||
id: callId,
|
||||
name: undefined,
|
||||
arguments: args,
|
||||
}
|
||||
|
||||
toolCallCounter++
|
||||
} else {
|
||||
// This is regular content
|
||||
if (part.text) {
|
||||
hasContent = true
|
||||
yield { type: "text", text: part.text }
|
||||
}
|
||||
}
|
||||
// Extract grounding sources from providerMetadata if available
|
||||
const providerMetadata = await result.providerMetadata
|
||||
const groundingMetadata = providerMetadata?.google as
|
||||
| {
|
||||
groundingMetadata?: {
|
||||
groundingChunks?: Array<{
|
||||
web?: { uri?: string; title?: string }
|
||||
}>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
| undefined
|
||||
|
||||
// Fallback to the original text property if no candidates structure
|
||||
else if (chunk.text) {
|
||||
hasContent = true
|
||||
yield { type: "text", text: chunk.text }
|
||||
}
|
||||
|
||||
if (chunk.usageMetadata) {
|
||||
lastUsageMetadata = chunk.usageMetadata
|
||||
}
|
||||
}
|
||||
|
||||
if (finalResponse?.responseId) {
|
||||
// Capture responseId so Task.addToApiConversationHistory can store it
|
||||
// alongside the assistant message in api_history.json.
|
||||
this.lastResponseId = finalResponse.responseId
|
||||
}
|
||||
|
||||
if (pendingGroundingMetadata) {
|
||||
const sources = this.extractGroundingSources(pendingGroundingMetadata)
|
||||
if (groundingMetadata?.groundingMetadata) {
|
||||
const sources = this.extractGroundingSources(groundingMetadata.groundingMetadata)
|
||||
if (sources.length > 0) {
|
||||
yield { type: "grounding", sources }
|
||||
}
|
||||
}
|
||||
|
||||
if (lastUsageMetadata) {
|
||||
const inputTokens = lastUsageMetadata.promptTokenCount ?? 0
|
||||
const outputTokens = lastUsageMetadata.candidatesTokenCount ?? 0
|
||||
const cacheReadTokens = lastUsageMetadata.cachedContentTokenCount
|
||||
const reasoningTokens = lastUsageMetadata.thoughtsTokenCount
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
reasoningTokens,
|
||||
totalCost: this.calculateCost({
|
||||
info,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
reasoningTokens,
|
||||
}),
|
||||
}
|
||||
// Yield usage metrics at the end
|
||||
const usage = await result.usage
|
||||
if (usage) {
|
||||
yield this.processUsageMetrics(usage, info, providerMetadata)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const apiError = new ApiProviderError(errorMessage, this.providerName, model, "createMessage")
|
||||
const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage")
|
||||
TelemetryService.instance.captureException(apiError)
|
||||
|
||||
if (error instanceof Error) {
|
||||
|
|
@ -366,7 +204,47 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
return { id: id.endsWith(":thinking") ? id.replace(":thinking", "") : id, info, ...params }
|
||||
}
|
||||
|
||||
private extractGroundingSources(groundingMetadata?: GroundingMetadata): GroundingSource[] {
|
||||
/**
|
||||
* Process usage metrics from the AI SDK response.
|
||||
*/
|
||||
protected processUsageMetrics(
|
||||
usage: {
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
details?: {
|
||||
cachedInputTokens?: number
|
||||
reasoningTokens?: number
|
||||
}
|
||||
},
|
||||
info: ModelInfo,
|
||||
providerMetadata?: Record<string, unknown>,
|
||||
): ApiStreamUsageChunk {
|
||||
const inputTokens = usage.inputTokens || 0
|
||||
const outputTokens = usage.outputTokens || 0
|
||||
const cacheReadTokens = usage.details?.cachedInputTokens
|
||||
const reasoningTokens = usage.details?.reasoningTokens
|
||||
|
||||
return {
|
||||
type: "usage",
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
reasoningTokens,
|
||||
totalCost: this.calculateCost({
|
||||
info,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
reasoningTokens,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
private extractGroundingSources(groundingMetadata?: {
|
||||
groundingChunks?: Array<{
|
||||
web?: { uri?: string; title?: string }
|
||||
}>
|
||||
}): GroundingSource[] {
|
||||
const chunks = groundingMetadata?.groundingChunks
|
||||
|
||||
if (!chunks) {
|
||||
|
|
@ -389,7 +267,11 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
.filter((source): source is GroundingSource => source !== null)
|
||||
}
|
||||
|
||||
private extractCitationsOnly(groundingMetadata?: GroundingMetadata): string | null {
|
||||
private extractCitationsOnly(groundingMetadata?: {
|
||||
groundingChunks?: Array<{
|
||||
web?: { uri?: string; title?: string }
|
||||
}>
|
||||
}): string | null {
|
||||
const sources = this.extractGroundingSources(groundingMetadata)
|
||||
|
||||
if (sources.length === 0) {
|
||||
|
|
@ -401,43 +283,36 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
}
|
||||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
const { id: model, info } = this.getModel()
|
||||
const { id: modelId, info } = this.getModel()
|
||||
|
||||
try {
|
||||
const tools: GenerateContentConfig["tools"] = []
|
||||
if (this.options.enableUrlContext) {
|
||||
tools.push({ urlContext: {} })
|
||||
}
|
||||
if (this.options.enableGrounding) {
|
||||
tools.push({ googleSearch: {} })
|
||||
}
|
||||
|
||||
const supportsTemperature = info.supportsTemperature !== false
|
||||
const temperatureConfig: number | undefined = supportsTemperature
|
||||
? (this.options.modelTemperature ?? info.defaultTemperature ?? 1)
|
||||
: info.defaultTemperature
|
||||
|
||||
const promptConfig: GenerateContentConfig = {
|
||||
httpOptions: this.options.googleGeminiBaseUrl
|
||||
? { baseUrl: this.options.googleGeminiBaseUrl }
|
||||
: undefined,
|
||||
const result = await generateText({
|
||||
model: this.provider(modelId),
|
||||
prompt,
|
||||
temperature: temperatureConfig,
|
||||
...(tools.length > 0 ? { tools } : {}),
|
||||
}
|
||||
|
||||
const request = {
|
||||
model,
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
config: promptConfig,
|
||||
}
|
||||
|
||||
const result = await this.client.models.generateContent(request)
|
||||
})
|
||||
|
||||
let text = result.text ?? ""
|
||||
|
||||
const candidate = result.candidates?.[0]
|
||||
if (candidate?.groundingMetadata) {
|
||||
const citations = this.extractCitationsOnly(candidate.groundingMetadata)
|
||||
// Extract grounding citations from providerMetadata if available
|
||||
const providerMetadata = result.providerMetadata
|
||||
const groundingMetadata = providerMetadata?.google as
|
||||
| {
|
||||
groundingMetadata?: {
|
||||
groundingChunks?: Array<{
|
||||
web?: { uri?: string; title?: string }
|
||||
}>
|
||||
}
|
||||
}
|
||||
| undefined
|
||||
|
||||
if (groundingMetadata?.groundingMetadata) {
|
||||
const citations = this.extractCitationsOnly(groundingMetadata.groundingMetadata)
|
||||
if (citations) {
|
||||
text += `\n\n${t("common:errors.gemini.sources")} ${citations}`
|
||||
}
|
||||
|
|
@ -446,7 +321,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
return text
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const apiError = new ApiProviderError(errorMessage, this.providerName, model, "completePrompt")
|
||||
const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt")
|
||||
TelemetryService.instance.captureException(apiError)
|
||||
|
||||
if (error instanceof Error) {
|
||||
|
|
@ -457,14 +332,6 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
}
|
||||
}
|
||||
|
||||
public getThoughtSignature(): string | undefined {
|
||||
return this.lastThoughtSignature
|
||||
}
|
||||
|
||||
public getResponseId(): string | undefined {
|
||||
return this.lastResponseId
|
||||
}
|
||||
|
||||
public calculateCost({
|
||||
info,
|
||||
inputTokens,
|
||||
|
|
@ -528,4 +395,17 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
|
||||
return totalCost
|
||||
}
|
||||
|
||||
override isAiSdkProvider(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the thought signature captured from the last Gemini response.
|
||||
* Gemini 3 models return thoughtSignature on function call parts,
|
||||
* which must be round-tripped back for tool use continuations.
|
||||
*/
|
||||
getThoughtSignature(): string | undefined {
|
||||
return this.lastThoughtSignature
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,4 +174,8 @@ export class GroqHandler extends BaseProvider implements SingleCompletionHandler
|
|||
|
||||
return text
|
||||
}
|
||||
|
||||
override isAiSdkProvider(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,37 @@
|
|||
import OpenAI from "openai"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
|
||||
import { streamText, generateText, ToolSet } from "ai"
|
||||
|
||||
import type { ModelRecord } from "@roo-code/types"
|
||||
import type { ModelRecord, ModelInfo } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
|
||||
import {
|
||||
convertToAiSdkMessages,
|
||||
convertToolsForAiSdk,
|
||||
processAiSdkStreamPart,
|
||||
mapToolChoice,
|
||||
handleAiSdkError,
|
||||
} from "../transform/ai-sdk"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import { getModelParams } from "../transform/model-params"
|
||||
|
||||
import { DEFAULT_HEADERS } from "./constants"
|
||||
import { BaseProvider } from "./base-provider"
|
||||
import { getHuggingFaceModels, getCachedHuggingFaceModels } from "./fetchers/huggingface"
|
||||
import { handleOpenAIError } from "./utils/openai-error-handler"
|
||||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
|
||||
const HUGGINGFACE_DEFAULT_TEMPERATURE = 0.7
|
||||
|
||||
/**
|
||||
* HuggingFace provider using @ai-sdk/openai-compatible for OpenAI-compatible API.
|
||||
* Uses HuggingFace's OpenAI-compatible endpoint to enable tool message support.
|
||||
* @see https://github.com/vercel/ai/issues/10766 - Workaround for tool messages not supported in @ai-sdk/huggingface
|
||||
*/
|
||||
export class HuggingFaceHandler extends BaseProvider implements SingleCompletionHandler {
|
||||
private client: OpenAI
|
||||
private options: ApiHandlerOptions
|
||||
protected options: ApiHandlerOptions
|
||||
protected provider: ReturnType<typeof createOpenAICompatible>
|
||||
private modelCache: ModelRecord | null = null
|
||||
private readonly providerName = "HuggingFace"
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
super()
|
||||
|
|
@ -26,10 +41,14 @@ export class HuggingFaceHandler extends BaseProvider implements SingleCompletion
|
|||
throw new Error("Hugging Face API key is required")
|
||||
}
|
||||
|
||||
this.client = new OpenAI({
|
||||
// Create an OpenAI-compatible provider pointing to HuggingFace's /v1 endpoint
|
||||
// This fixes "tool messages not supported" error - the HuggingFace SDK doesn't
|
||||
// properly handle function_call_output format, but OpenAI SDK does
|
||||
this.provider = createOpenAICompatible({
|
||||
name: "huggingface",
|
||||
baseURL: "https://router.huggingface.co/v1",
|
||||
apiKey: this.options.huggingFaceApiKey,
|
||||
defaultHeaders: DEFAULT_HEADERS,
|
||||
headers: DEFAULT_HEADERS,
|
||||
})
|
||||
|
||||
// Try to get cached models first
|
||||
|
|
@ -47,91 +66,150 @@ export class HuggingFaceHandler extends BaseProvider implements SingleCompletion
|
|||
}
|
||||
}
|
||||
|
||||
override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } {
|
||||
const id = this.options.huggingFaceModelId || "meta-llama/Llama-3.3-70B-Instruct"
|
||||
|
||||
// Try to get model info from cache
|
||||
const cachedInfo = this.modelCache?.[id]
|
||||
|
||||
const info: ModelInfo = cachedInfo || {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
}
|
||||
|
||||
const params = getModelParams({
|
||||
format: "openai",
|
||||
modelId: id,
|
||||
model: info,
|
||||
settings: this.options,
|
||||
defaultTemperature: HUGGINGFACE_DEFAULT_TEMPERATURE,
|
||||
})
|
||||
|
||||
return { id, info, ...params }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the language model for the configured model ID.
|
||||
*/
|
||||
protected getLanguageModel() {
|
||||
const { id } = this.getModel()
|
||||
return this.provider(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process usage metrics from the AI SDK response.
|
||||
*/
|
||||
protected processUsageMetrics(
|
||||
usage: {
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
details?: {
|
||||
cachedInputTokens?: number
|
||||
reasoningTokens?: number
|
||||
}
|
||||
},
|
||||
providerMetadata?: {
|
||||
huggingface?: {
|
||||
promptCacheHitTokens?: number
|
||||
promptCacheMissTokens?: number
|
||||
}
|
||||
},
|
||||
): ApiStreamUsageChunk {
|
||||
// Extract cache metrics from HuggingFace's providerMetadata if available
|
||||
const cacheReadTokens = providerMetadata?.huggingface?.promptCacheHitTokens ?? usage.details?.cachedInputTokens
|
||||
const cacheWriteTokens = providerMetadata?.huggingface?.promptCacheMissTokens
|
||||
|
||||
return {
|
||||
type: "usage",
|
||||
inputTokens: usage.inputTokens || 0,
|
||||
outputTokens: usage.outputTokens || 0,
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens,
|
||||
reasoningTokens: usage.details?.reasoningTokens,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the max tokens parameter to include in the request.
|
||||
*/
|
||||
protected getMaxOutputTokens(): number | undefined {
|
||||
const { info } = this.getModel()
|
||||
return this.options.modelMaxTokens || info.maxTokens || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a message stream using the AI SDK.
|
||||
*/
|
||||
override async *createMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const modelId = this.options.huggingFaceModelId || "meta-llama/Llama-3.3-70B-Instruct"
|
||||
const temperature = this.options.modelTemperature ?? 0.7
|
||||
const { temperature } = this.getModel()
|
||||
const languageModel = this.getLanguageModel()
|
||||
|
||||
const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
|
||||
model: modelId,
|
||||
temperature,
|
||||
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
// Convert messages to AI SDK format
|
||||
const aiSdkMessages = convertToAiSdkMessages(messages)
|
||||
|
||||
// Convert tools to OpenAI format first, then to AI SDK format
|
||||
const openAiTools = this.convertToolsForOpenAI(metadata?.tools)
|
||||
const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined
|
||||
|
||||
// Build the request options
|
||||
const requestOptions: Parameters<typeof streamText>[0] = {
|
||||
model: languageModel,
|
||||
system: systemPrompt,
|
||||
messages: aiSdkMessages,
|
||||
temperature: this.options.modelTemperature ?? temperature ?? HUGGINGFACE_DEFAULT_TEMPERATURE,
|
||||
maxOutputTokens: this.getMaxOutputTokens(),
|
||||
tools: aiSdkTools,
|
||||
toolChoice: mapToolChoice(metadata?.tool_choice),
|
||||
}
|
||||
|
||||
// Add max_tokens if specified
|
||||
if (this.options.includeMaxTokens && this.options.modelMaxTokens) {
|
||||
params.max_tokens = this.options.modelMaxTokens
|
||||
}
|
||||
// Use streamText for streaming responses
|
||||
const result = streamText(requestOptions)
|
||||
|
||||
let stream
|
||||
try {
|
||||
stream = await this.client.chat.completions.create(params)
|
||||
// Process the full stream to get all events
|
||||
for await (const part of result.fullStream) {
|
||||
// Use the processAiSdkStreamPart utility to convert stream parts
|
||||
for (const chunk of processAiSdkStreamPart(part)) {
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
|
||||
// Yield usage metrics at the end, including cache metrics from providerMetadata
|
||||
const usage = await result.usage
|
||||
const providerMetadata = await result.providerMetadata
|
||||
if (usage) {
|
||||
yield this.processUsageMetrics(usage, providerMetadata as any)
|
||||
}
|
||||
} catch (error) {
|
||||
throw handleOpenAIError(error, this.providerName)
|
||||
}
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
}
|
||||
}
|
||||
// Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.)
|
||||
throw handleAiSdkError(error, "HuggingFace")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a prompt using the AI SDK generateText.
|
||||
*/
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
const modelId = this.options.huggingFaceModelId || "meta-llama/Llama-3.3-70B-Instruct"
|
||||
const { temperature } = this.getModel()
|
||||
const languageModel = this.getLanguageModel()
|
||||
|
||||
try {
|
||||
const response = await this.client.chat.completions.create({
|
||||
model: modelId,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
})
|
||||
const { text } = await generateText({
|
||||
model: languageModel,
|
||||
prompt,
|
||||
maxOutputTokens: this.getMaxOutputTokens(),
|
||||
temperature: this.options.modelTemperature ?? temperature ?? HUGGINGFACE_DEFAULT_TEMPERATURE,
|
||||
})
|
||||
|
||||
return response.choices[0]?.message.content || ""
|
||||
} catch (error) {
|
||||
throw handleOpenAIError(error, this.providerName)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
override getModel() {
|
||||
const modelId = this.options.huggingFaceModelId || "meta-llama/Llama-3.3-70B-Instruct"
|
||||
|
||||
// Try to get model info from cache
|
||||
const modelInfo = this.modelCache?.[modelId]
|
||||
|
||||
if (modelInfo) {
|
||||
return {
|
||||
id: modelId,
|
||||
info: modelInfo,
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to default values if model not found in cache
|
||||
return {
|
||||
id: modelId,
|
||||
info: {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
}
|
||||
override isAiSdkProvider(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,44 +1,62 @@
|
|||
import { ioIntelligenceDefaultModelId, ioIntelligenceModels, type IOIntelligenceModelId } from "@roo-code/types"
|
||||
import {
|
||||
ioIntelligenceDefaultModelId,
|
||||
ioIntelligenceModels,
|
||||
type IOIntelligenceModelId,
|
||||
type ModelInfo,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider"
|
||||
|
||||
export class IOIntelligenceHandler extends BaseOpenAiCompatibleProvider<IOIntelligenceModelId> {
|
||||
import { getModelParams } from "../transform/model-params"
|
||||
|
||||
import { OpenAICompatibleHandler, type OpenAICompatibleConfig } from "./openai-compatible"
|
||||
|
||||
export class IOIntelligenceHandler extends OpenAICompatibleHandler {
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
if (!options.ioIntelligenceApiKey) {
|
||||
throw new Error("IO Intelligence API key is required")
|
||||
}
|
||||
|
||||
super({
|
||||
...options,
|
||||
providerName: "IO Intelligence",
|
||||
baseURL: "https://api.intelligence.io.solutions/api/v1",
|
||||
defaultProviderModelId: ioIntelligenceDefaultModelId,
|
||||
providerModels: ioIntelligenceModels,
|
||||
defaultTemperature: 0.7,
|
||||
apiKey: options.ioIntelligenceApiKey,
|
||||
})
|
||||
}
|
||||
|
||||
override getModel() {
|
||||
const modelId = this.options.ioIntelligenceModelId || (ioIntelligenceDefaultModelId as IOIntelligenceModelId)
|
||||
|
||||
const modelInfo =
|
||||
this.providerModels[modelId as IOIntelligenceModelId] ?? this.providerModels[ioIntelligenceDefaultModelId]
|
||||
|
||||
if (modelInfo) {
|
||||
return { id: modelId as IOIntelligenceModelId, info: modelInfo }
|
||||
}
|
||||
|
||||
// Return the requested model ID even if not found, with fallback info.
|
||||
return {
|
||||
id: modelId as IOIntelligenceModelId,
|
||||
info: {
|
||||
const modelId = options.ioIntelligenceModelId ?? ioIntelligenceDefaultModelId
|
||||
const modelInfo: ModelInfo = ioIntelligenceModels[modelId as IOIntelligenceModelId] ??
|
||||
ioIntelligenceModels[ioIntelligenceDefaultModelId] ?? {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 128000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
}
|
||||
|
||||
const config: OpenAICompatibleConfig = {
|
||||
providerName: "IO Intelligence",
|
||||
baseURL: "https://api.intelligence.io.solutions/api/v1",
|
||||
apiKey: options.ioIntelligenceApiKey,
|
||||
modelId,
|
||||
modelInfo,
|
||||
modelMaxTokens: options.modelMaxTokens ?? undefined,
|
||||
temperature: options.modelTemperature ?? 0.7,
|
||||
}
|
||||
|
||||
super(options, config)
|
||||
}
|
||||
|
||||
override getModel() {
|
||||
const modelId = this.options.ioIntelligenceModelId ?? ioIntelligenceDefaultModelId
|
||||
const modelInfo: ModelInfo = ioIntelligenceModels[modelId as IOIntelligenceModelId] ??
|
||||
ioIntelligenceModels[ioIntelligenceDefaultModelId] ?? {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 128000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
}
|
||||
|
||||
const params = getModelParams({
|
||||
format: "openai",
|
||||
modelId,
|
||||
model: modelInfo,
|
||||
settings: this.options,
|
||||
defaultTemperature: 0.7,
|
||||
})
|
||||
|
||||
return { id: modelId, info: modelInfo, ...params }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,13 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand
|
|||
override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } {
|
||||
const id = (this.options.apiModelId ?? mistralDefaultModelId) as MistralModelId
|
||||
const info = mistralModels[id as keyof typeof mistralModels] || mistralModels[mistralDefaultModelId]
|
||||
const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options })
|
||||
const params = getModelParams({
|
||||
format: "openai",
|
||||
modelId: id,
|
||||
model: info,
|
||||
settings: this.options,
|
||||
defaultTemperature: 0,
|
||||
})
|
||||
return { id, info, ...params }
|
||||
}
|
||||
|
||||
|
|
@ -198,4 +204,8 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand
|
|||
|
||||
return text
|
||||
}
|
||||
|
||||
override isAiSdkProvider(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export class MoonshotHandler extends OpenAICompatibleHandler {
|
|||
|
||||
const config: OpenAICompatibleConfig = {
|
||||
providerName: "moonshot",
|
||||
baseURL: options.moonshotBaseUrl ?? "https://api.moonshot.ai/v1",
|
||||
baseURL: options.moonshotBaseUrl || "https://api.moonshot.ai/v1",
|
||||
apiKey: options.moonshotApiKey ?? "not-provided",
|
||||
modelId,
|
||||
modelInfo,
|
||||
|
|
@ -29,7 +29,13 @@ export class MoonshotHandler extends OpenAICompatibleHandler {
|
|||
override getModel() {
|
||||
const id = this.options.apiModelId ?? moonshotDefaultModelId
|
||||
const info = moonshotModels[id as keyof typeof moonshotModels] || moonshotModels[moonshotDefaultModelId]
|
||||
const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options })
|
||||
const params = getModelParams({
|
||||
format: "openai",
|
||||
modelId: id,
|
||||
model: info,
|
||||
settings: this.options,
|
||||
defaultTemperature: 0,
|
||||
})
|
||||
return { id, info, ...params }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -186,4 +186,8 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si
|
|||
|
||||
return text
|
||||
}
|
||||
|
||||
override isAiSdkProvider(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
// Include originator, session_id, and User-Agent headers for API tracking and debugging
|
||||
const userAgent = `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`
|
||||
this.client = new OpenAI({
|
||||
baseURL: this.options.openAiNativeBaseUrl,
|
||||
baseURL: this.options.openAiNativeBaseUrl || undefined,
|
||||
apiKey,
|
||||
defaultHeaders: {
|
||||
originator: "roo-code",
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
super()
|
||||
this.options = options
|
||||
|
||||
const baseURL = this.options.openAiBaseUrl ?? "https://api.openai.com/v1"
|
||||
const baseURL = this.options.openAiBaseUrl || "https://api.openai.com/v1"
|
||||
const apiKey = this.options.openAiApiKey ?? "not-provided"
|
||||
const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl)
|
||||
const urlHost = this._getUrlHost(this.options.openAiBaseUrl)
|
||||
|
|
@ -282,7 +282,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
override getModel() {
|
||||
const id = this.options.openAiModelId ?? ""
|
||||
const info: ModelInfo = this.options.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults
|
||||
const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options })
|
||||
const params = getModelParams({
|
||||
format: "openai",
|
||||
modelId: id,
|
||||
model: info,
|
||||
settings: this.options,
|
||||
defaultTemperature: 0,
|
||||
})
|
||||
return { id, info, ...params }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan
|
|||
modelId: id,
|
||||
model: info,
|
||||
settings: this.options,
|
||||
defaultTemperature: 0,
|
||||
})
|
||||
|
||||
return { id, info, ...params }
|
||||
|
|
|
|||
|
|
@ -1,19 +1,184 @@
|
|||
import { type SambaNovaModelId, sambaNovaDefaultModelId, sambaNovaModels } from "@roo-code/types"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { createSambaNova } from "sambanova-ai-provider"
|
||||
import { streamText, generateText, ToolSet } from "ai"
|
||||
|
||||
import { sambaNovaModels, sambaNovaDefaultModelId, type ModelInfo } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
|
||||
import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider"
|
||||
import {
|
||||
convertToAiSdkMessages,
|
||||
convertToolsForAiSdk,
|
||||
processAiSdkStreamPart,
|
||||
mapToolChoice,
|
||||
handleAiSdkError,
|
||||
flattenAiSdkMessagesToStringContent,
|
||||
} from "../transform/ai-sdk"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import { getModelParams } from "../transform/model-params"
|
||||
|
||||
import { DEFAULT_HEADERS } from "./constants"
|
||||
import { BaseProvider } from "./base-provider"
|
||||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
|
||||
const SAMBANOVA_DEFAULT_TEMPERATURE = 0.7
|
||||
|
||||
/**
|
||||
* SambaNova provider using the dedicated sambanova-ai-provider package.
|
||||
* Provides native support for various models including Llama models.
|
||||
*/
|
||||
export class SambaNovaHandler extends BaseProvider implements SingleCompletionHandler {
|
||||
protected options: ApiHandlerOptions
|
||||
protected provider: ReturnType<typeof createSambaNova>
|
||||
|
||||
export class SambaNovaHandler extends BaseOpenAiCompatibleProvider<SambaNovaModelId> {
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
super({
|
||||
...options,
|
||||
providerName: "SambaNova",
|
||||
super()
|
||||
this.options = options
|
||||
|
||||
// Create the SambaNova provider using AI SDK
|
||||
this.provider = createSambaNova({
|
||||
baseURL: "https://api.sambanova.ai/v1",
|
||||
apiKey: options.sambaNovaApiKey,
|
||||
defaultProviderModelId: sambaNovaDefaultModelId,
|
||||
providerModels: sambaNovaModels,
|
||||
defaultTemperature: 0.7,
|
||||
apiKey: options.sambaNovaApiKey ?? "not-provided",
|
||||
headers: DEFAULT_HEADERS,
|
||||
})
|
||||
}
|
||||
|
||||
override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } {
|
||||
const id = this.options.apiModelId ?? sambaNovaDefaultModelId
|
||||
const info = sambaNovaModels[id as keyof typeof sambaNovaModels] || sambaNovaModels[sambaNovaDefaultModelId]
|
||||
const params = getModelParams({
|
||||
format: "openai",
|
||||
modelId: id,
|
||||
model: info,
|
||||
settings: this.options,
|
||||
defaultTemperature: SAMBANOVA_DEFAULT_TEMPERATURE,
|
||||
})
|
||||
return { id, info, ...params }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the language model for the configured model ID.
|
||||
*/
|
||||
protected getLanguageModel() {
|
||||
const { id } = this.getModel()
|
||||
return this.provider(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process usage metrics from the AI SDK response.
|
||||
*/
|
||||
protected processUsageMetrics(
|
||||
usage: {
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
details?: {
|
||||
cachedInputTokens?: number
|
||||
reasoningTokens?: number
|
||||
}
|
||||
},
|
||||
providerMetadata?: {
|
||||
sambanova?: {
|
||||
promptCacheHitTokens?: number
|
||||
promptCacheMissTokens?: number
|
||||
}
|
||||
},
|
||||
): ApiStreamUsageChunk {
|
||||
// Extract cache metrics from SambaNova's providerMetadata if available
|
||||
const cacheReadTokens = providerMetadata?.sambanova?.promptCacheHitTokens ?? usage.details?.cachedInputTokens
|
||||
const cacheWriteTokens = providerMetadata?.sambanova?.promptCacheMissTokens
|
||||
|
||||
return {
|
||||
type: "usage",
|
||||
inputTokens: usage.inputTokens || 0,
|
||||
outputTokens: usage.outputTokens || 0,
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens,
|
||||
reasoningTokens: usage.details?.reasoningTokens,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the max tokens parameter to include in the request.
|
||||
*/
|
||||
protected getMaxOutputTokens(): number | undefined {
|
||||
const { info } = this.getModel()
|
||||
return this.options.modelMaxTokens || info.maxTokens || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a message stream using the AI SDK.
|
||||
*/
|
||||
override async *createMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const { temperature, info } = this.getModel()
|
||||
const languageModel = this.getLanguageModel()
|
||||
|
||||
// Convert messages to AI SDK format
|
||||
// For models that don't support multi-part content (like DeepSeek), flatten messages to string content
|
||||
// SambaNova's DeepSeek models expect string content, not array content
|
||||
const aiSdkMessages = convertToAiSdkMessages(messages, {
|
||||
transform: info.supportsImages ? undefined : flattenAiSdkMessagesToStringContent,
|
||||
})
|
||||
|
||||
// Convert tools to OpenAI format first, then to AI SDK format
|
||||
const openAiTools = this.convertToolsForOpenAI(metadata?.tools)
|
||||
const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined
|
||||
|
||||
// Build the request options
|
||||
const requestOptions: Parameters<typeof streamText>[0] = {
|
||||
model: languageModel,
|
||||
system: systemPrompt,
|
||||
messages: aiSdkMessages,
|
||||
temperature: this.options.modelTemperature ?? temperature ?? SAMBANOVA_DEFAULT_TEMPERATURE,
|
||||
maxOutputTokens: this.getMaxOutputTokens(),
|
||||
tools: aiSdkTools,
|
||||
toolChoice: mapToolChoice(metadata?.tool_choice),
|
||||
}
|
||||
|
||||
// Use streamText for streaming responses
|
||||
const result = streamText(requestOptions)
|
||||
|
||||
try {
|
||||
// Process the full stream to get all events including reasoning
|
||||
for await (const part of result.fullStream) {
|
||||
for (const chunk of processAiSdkStreamPart(part)) {
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
|
||||
// Yield usage metrics at the end, including cache metrics from providerMetadata
|
||||
const usage = await result.usage
|
||||
const providerMetadata = await result.providerMetadata
|
||||
if (usage) {
|
||||
yield this.processUsageMetrics(usage, providerMetadata as any)
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.)
|
||||
throw handleAiSdkError(error, "SambaNova")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a prompt using the AI SDK generateText.
|
||||
*/
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
const { temperature } = this.getModel()
|
||||
const languageModel = this.getLanguageModel()
|
||||
|
||||
const { text } = await generateText({
|
||||
model: languageModel,
|
||||
prompt,
|
||||
maxOutputTokens: this.getMaxOutputTokens(),
|
||||
temperature: this.options.modelTemperature ?? temperature ?? SAMBANOVA_DEFAULT_TEMPERATURE,
|
||||
})
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
override isAiSdkProvider(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue