mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-10 22:41:14 +00:00
chore: merge origin/main into vk/6325-multi-question and resolve conflicts
This commit is contained in:
commit
621ab32d9a
372 changed files with 23547 additions and 27714 deletions
|
|
@ -1,6 +0,0 @@
|
|||
POSTHOG_API_KEY=key-goes-here
|
||||
|
||||
# Roo Code Cloud / Local Development
|
||||
CLERK_BASE_URL=https://epic-chamois-85.clerk.accounts.dev
|
||||
ROO_CODE_API_URL=http://localhost:3000
|
||||
ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy/v1
|
||||
2
.github/CODEOWNERS
vendored
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
|
||||
|
|
|
|||
3
.github/ISSUE_TEMPLATE/config.yml
vendored
3
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1,8 +1,5 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feature Request
|
||||
url: https://github.com/RooCodeInc/Roo-Code/discussions/categories/feature-requests
|
||||
about: Share and vote on feature requests for Roo Code
|
||||
- name: Leave a Review
|
||||
url: https://marketplace.visualstudio.com/items?itemName=RooVeterinaryInc.roo-cline&ssr=false#review-details
|
||||
about: Enjoying Roo Code? Leave a review here!
|
||||
|
|
|
|||
394
.github/workflows/cli-release.yml
vendored
Normal file
394
.github/workflows/cli-release.yml
vendored
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
name: CLI Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release (e.g., 0.1.0). Leave empty to use package.json version.'
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: 'Dry run (build and test but do not create release).'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
# Build CLI for each platform.
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
platform: darwin-arm64
|
||||
runs-on: macos-latest
|
||||
- os: ubuntu-latest
|
||||
platform: linux-x64
|
||||
runs-on: ubuntu-latest
|
||||
- os: ubuntu-24.04-arm
|
||||
platform: linux-arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js and pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
if [ -n "${{ inputs.version }}" ]; then
|
||||
VERSION="${{ inputs.version }}"
|
||||
else
|
||||
VERSION=$(node -p "require('./apps/cli/package.json').version")
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=cli-v$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Using version: $VERSION"
|
||||
|
||||
- name: Build extension bundle
|
||||
run: pnpm bundle
|
||||
|
||||
- name: Build CLI
|
||||
run: pnpm --filter @roo-code/cli build
|
||||
|
||||
- name: Create release tarball
|
||||
id: tarball
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
RELEASE_DIR="roo-cli-${PLATFORM}"
|
||||
TARBALL="roo-cli-${PLATFORM}.tar.gz"
|
||||
|
||||
# Clean up any previous build.
|
||||
rm -rf "$RELEASE_DIR"
|
||||
rm -f "$TARBALL"
|
||||
|
||||
# Create directory structure.
|
||||
mkdir -p "$RELEASE_DIR/bin"
|
||||
mkdir -p "$RELEASE_DIR/lib"
|
||||
mkdir -p "$RELEASE_DIR/extension"
|
||||
|
||||
# Copy CLI dist files.
|
||||
echo "Copying CLI files..."
|
||||
cp -r apps/cli/dist/* "$RELEASE_DIR/lib/"
|
||||
|
||||
# Create package.json for npm install.
|
||||
echo "Creating package.json..."
|
||||
node -e "
|
||||
const pkg = require('./apps/cli/package.json');
|
||||
const newPkg = {
|
||||
name: '@roo-code/cli',
|
||||
version: '$VERSION',
|
||||
type: 'module',
|
||||
dependencies: {
|
||||
'@inkjs/ui': pkg.dependencies['@inkjs/ui'],
|
||||
'@trpc/client': pkg.dependencies['@trpc/client'],
|
||||
'commander': pkg.dependencies.commander,
|
||||
'fuzzysort': pkg.dependencies.fuzzysort,
|
||||
'ink': pkg.dependencies.ink,
|
||||
'p-wait-for': pkg.dependencies['p-wait-for'],
|
||||
'react': pkg.dependencies.react,
|
||||
'superjson': pkg.dependencies.superjson,
|
||||
'zustand': pkg.dependencies.zustand
|
||||
}
|
||||
};
|
||||
console.log(JSON.stringify(newPkg, null, 2));
|
||||
" > "$RELEASE_DIR/package.json"
|
||||
|
||||
# Copy extension bundle.
|
||||
echo "Copying extension bundle..."
|
||||
cp -r src/dist/* "$RELEASE_DIR/extension/"
|
||||
|
||||
# Add package.json to extension directory for CommonJS.
|
||||
echo '{"type": "commonjs"}' > "$RELEASE_DIR/extension/package.json"
|
||||
|
||||
# Find and copy ripgrep binary.
|
||||
echo "Looking for ripgrep binary..."
|
||||
RIPGREP_PATH=$(find node_modules -path "*/@vscode/ripgrep/bin/rg" -type f 2>/dev/null | head -1)
|
||||
if [ -n "$RIPGREP_PATH" ] && [ -f "$RIPGREP_PATH" ]; then
|
||||
echo "Found ripgrep at: $RIPGREP_PATH"
|
||||
mkdir -p "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin"
|
||||
cp "$RIPGREP_PATH" "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/"
|
||||
chmod +x "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/rg"
|
||||
mkdir -p "$RELEASE_DIR/bin"
|
||||
cp "$RIPGREP_PATH" "$RELEASE_DIR/bin/"
|
||||
chmod +x "$RELEASE_DIR/bin/rg"
|
||||
else
|
||||
echo "Warning: ripgrep binary not found"
|
||||
fi
|
||||
|
||||
# Create the wrapper script
|
||||
echo "Creating wrapper script..."
|
||||
printf '%s\n' '#!/usr/bin/env node' \
|
||||
'' \
|
||||
"import { fileURLToPath } from 'url';" \
|
||||
"import { dirname, join } from 'path';" \
|
||||
'' \
|
||||
'const __filename = fileURLToPath(import.meta.url);' \
|
||||
'const __dirname = dirname(__filename);' \
|
||||
'' \
|
||||
'// Set environment variables for the CLI' \
|
||||
"process.env.ROO_CLI_ROOT = join(__dirname, '..');" \
|
||||
"process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension');" \
|
||||
"process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg');" \
|
||||
'' \
|
||||
'// Import and run the actual CLI' \
|
||||
"await import(join(__dirname, '..', 'lib', 'index.js'));" \
|
||||
> "$RELEASE_DIR/bin/roo"
|
||||
|
||||
chmod +x "$RELEASE_DIR/bin/roo"
|
||||
|
||||
# Create empty .env file.
|
||||
touch "$RELEASE_DIR/.env"
|
||||
|
||||
# Create tarball.
|
||||
echo "Creating tarball..."
|
||||
tar -czvf "$TARBALL" "$RELEASE_DIR"
|
||||
|
||||
# Clean up release directory.
|
||||
rm -rf "$RELEASE_DIR"
|
||||
|
||||
# Create checksum.
|
||||
if command -v sha256sum &> /dev/null; then
|
||||
sha256sum "$TARBALL" > "${TARBALL}.sha256"
|
||||
elif command -v shasum &> /dev/null; then
|
||||
shasum -a 256 "$TARBALL" > "${TARBALL}.sha256"
|
||||
fi
|
||||
|
||||
echo "tarball=$TARBALL" >> $GITHUB_OUTPUT
|
||||
echo "Created: $TARBALL"
|
||||
ls -la "$TARBALL"
|
||||
|
||||
- name: Verify tarball
|
||||
env:
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
TARBALL="roo-cli-${PLATFORM}.tar.gz"
|
||||
|
||||
# Create temp directory for verification.
|
||||
VERIFY_DIR=$(mktemp -d)
|
||||
|
||||
# Extract and verify structure.
|
||||
tar -xzf "$TARBALL" -C "$VERIFY_DIR"
|
||||
|
||||
echo "Verifying tarball contents..."
|
||||
ls -la "$VERIFY_DIR/roo-cli-${PLATFORM}/"
|
||||
|
||||
# Check required files exist.
|
||||
test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/bin/roo" || { echo "Missing bin/roo"; exit 1; }
|
||||
test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/lib/index.js" || { echo "Missing lib/index.js"; exit 1; }
|
||||
test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/package.json" || { echo "Missing package.json"; exit 1; }
|
||||
test -d "$VERIFY_DIR/roo-cli-${PLATFORM}/extension" || { echo "Missing extension directory"; exit 1; }
|
||||
|
||||
echo "Tarball verification passed!"
|
||||
|
||||
# Cleanup.
|
||||
rm -rf "$VERIFY_DIR"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cli-${{ matrix.platform }}
|
||||
path: |
|
||||
roo-cli-${{ matrix.platform }}.tar.gz
|
||||
roo-cli-${{ matrix.platform }}.tar.gz.sha256
|
||||
retention-days: 7
|
||||
|
||||
# Create GitHub release with all platform artifacts.
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !inputs.dry_run }}
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
if [ -n "${{ inputs.version }}" ]; then
|
||||
VERSION="${{ inputs.version }}"
|
||||
else
|
||||
VERSION=$(node -p "require('./apps/cli/package.json').version")
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=cli-v$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Prepare release files
|
||||
run: |
|
||||
mkdir -p release
|
||||
find artifacts -name "*.tar.gz" -exec cp {} release/ \;
|
||||
find artifacts -name "*.sha256" -exec cp {} release/ \;
|
||||
ls -la release/
|
||||
|
||||
- name: Extract changelog
|
||||
id: changelog
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
CHANGELOG_FILE="apps/cli/CHANGELOG.md"
|
||||
|
||||
if [ -f "$CHANGELOG_FILE" ]; then
|
||||
# Extract content between version headers.
|
||||
CONTENT=$(awk -v version="$VERSION" '
|
||||
BEGIN { found = 0; content = ""; target = "[" version "]" }
|
||||
/^## \[/ {
|
||||
if (found) { exit }
|
||||
if (index($0, target) > 0) { found = 1; next }
|
||||
}
|
||||
found { content = content $0 "\n" }
|
||||
END { print content }
|
||||
' "$CHANGELOG_FILE")
|
||||
|
||||
if [ -n "$CONTENT" ]; then
|
||||
echo "Found changelog content"
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "No changelog content found for version $VERSION"
|
||||
echo "content=" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
echo "No changelog file found"
|
||||
echo "content=" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Generate checksums summary
|
||||
id: checksums
|
||||
run: |
|
||||
echo "checksums<<EOF" >> $GITHUB_OUTPUT
|
||||
cat release/*.sha256 >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check for existing release
|
||||
id: check_release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
if gh release view "$TAG" &> /dev/null; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Delete existing release
|
||||
if: steps.check_release.outputs.exists == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
echo "Deleting existing release $TAG..."
|
||||
gh release delete "$TAG" --yes || true
|
||||
git push origin ":refs/tags/$TAG" || true
|
||||
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
CHANGELOG_CONTENT: ${{ steps.changelog.outputs.content }}
|
||||
CHECKSUMS: ${{ steps.checksums.outputs.checksums }}
|
||||
run: |
|
||||
NOTES_FILE=$(mktemp)
|
||||
|
||||
if [ -n "$CHANGELOG_CONTENT" ]; then
|
||||
echo "## What's New" >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo "$CHANGELOG_CONTENT" >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
fi
|
||||
|
||||
echo "## Installation" >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo '```bash' >> "$NOTES_FILE"
|
||||
echo "curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh" >> "$NOTES_FILE"
|
||||
echo '```' >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo "Or install a specific version:" >> "$NOTES_FILE"
|
||||
echo '```bash' >> "$NOTES_FILE"
|
||||
echo "ROO_VERSION=$VERSION curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh" >> "$NOTES_FILE"
|
||||
echo '```' >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo "## Requirements" >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo "- Node.js 20 or higher" >> "$NOTES_FILE"
|
||||
echo "- macOS Apple Silicon (M1/M2/M3/M4), Linux x64, or Linux ARM64" >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo "## Usage" >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo '```bash' >> "$NOTES_FILE"
|
||||
echo "# Run a task" >> "$NOTES_FILE"
|
||||
echo 'roo "What is this project?"' >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo "# See all options" >> "$NOTES_FILE"
|
||||
echo "roo --help" >> "$NOTES_FILE"
|
||||
echo '```' >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo "## Platform Support" >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo "This release includes binaries for:" >> "$NOTES_FILE"
|
||||
echo '- `roo-cli-darwin-arm64.tar.gz` - macOS Apple Silicon (M1/M2/M3)' >> "$NOTES_FILE"
|
||||
echo '- `roo-cli-linux-x64.tar.gz` - Linux x64' >> "$NOTES_FILE"
|
||||
echo '- `roo-cli-linux-arm64.tar.gz` - Linux ARM64' >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo "## Checksums" >> "$NOTES_FILE"
|
||||
echo "" >> "$NOTES_FILE"
|
||||
echo '```' >> "$NOTES_FILE"
|
||||
echo "$CHECKSUMS" >> "$NOTES_FILE"
|
||||
echo '```' >> "$NOTES_FILE"
|
||||
|
||||
gh release create "$TAG" \
|
||||
--title "Roo Code CLI v$VERSION" \
|
||||
--notes-file "$NOTES_FILE" \
|
||||
--prerelease \
|
||||
release/*
|
||||
|
||||
rm -f "$NOTES_FILE"
|
||||
echo "Release created: https://github.com/${{ github.repository }}/releases/tag/$TAG"
|
||||
|
||||
# Summary job for dry runs
|
||||
summary:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ inputs.dry_run }}
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Show build summary
|
||||
run: |
|
||||
echo "## Dry Run Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The following artifacts were built:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
find artifacts -name "*.tar.gz" | while read f; do
|
||||
SIZE=$(ls -lh "$f" | awk '{print $5}')
|
||||
echo "- $(basename $f) ($SIZE)" >> $GITHUB_STEP_SUMMARY
|
||||
done
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Checksums" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
cat artifacts/*/*.sha256 >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
1
.gitignore
vendored
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,41 @@ All notable changes to the `@roo-code/cli` package will be documented in this fi
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.0.52] - 2026-02-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Linux Support**: Added support for `linux-arm64`.
|
||||
|
||||
## [0.0.51] - 2026-02-06
|
||||
|
||||
### Changed
|
||||
|
||||
- **Default Model Update**: Changed the default model from Opus 4.5 to Opus 4.6 for improved performance and capabilities
|
||||
|
||||
## [0.0.50] - 2026-02-05
|
||||
|
||||
### Added
|
||||
|
||||
- **Linux Support**: The CLI now supports Linux platforms in addition to macOS
|
||||
- **Roo Provider API Key Support**: Allow `--api-key` flag and `ROO_API_KEY` environment variable for the roo provider instead of requiring cloud auth token
|
||||
- **Exit on Error**: New `--exit-on-error` flag to exit immediately on API request errors instead of retrying, useful for CI/CD pipelines
|
||||
|
||||
### Changed
|
||||
|
||||
- **Improved Dev Experience**: Dev scripts now use `tsx` for running directly from source without building first
|
||||
- **Path Resolution Fixes**: Fixed path resolution in [`version.ts`](src/lib/utils/version.ts), [`extension.ts`](src/lib/utils/extension.ts), and [`extension-host.ts`](src/agent/extension-host.ts) to work from both source and bundled locations
|
||||
- **Debug Logging**: Debug log file (`~/.roo/cli-debug.log`) is now disabled by default unless `--debug` flag is passed
|
||||
- Updated README with complete environment variable table and dev workflow documentation
|
||||
|
||||
### Fixed
|
||||
|
||||
- Corrected example in install script
|
||||
|
||||
### Removed
|
||||
|
||||
- Dropped macOS 13 support
|
||||
|
||||
## [0.0.49] - 2026-01-18
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -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.52",
|
||||
"description": "Roo Code CLI - Run the Roo Code agent from the command line",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
@ -15,11 +15,8 @@
|
|||
"test": "vitest run",
|
||||
"build": "tsup",
|
||||
"build:extension": "pnpm --filter roo-cline bundle",
|
||||
"build:all": "pnpm --filter roo-cline bundle && tsup",
|
||||
"dev": "tsup --watch",
|
||||
"start": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy node dist/index.js",
|
||||
"start:production": "node dist/index.js",
|
||||
"release": "scripts/release.sh",
|
||||
"dev": "ROO_AUTH_BASE_URL=https://app.roocode.com ROO_SDK_BASE_URL=https://cloud-api.roocode.com ROO_CODE_PROVIDER_URL=https://api.roocode.com/proxy tsx src/index.ts -y",
|
||||
"dev:local": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy tsx src/index.ts",
|
||||
"clean": "rimraf dist .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
343
apps/cli/scripts/build.sh
Executable file
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -48,18 +48,10 @@ function getModelIdForProvider(config: ProviderSettings): string | undefined {
|
|||
return config.requestyModelId
|
||||
case "litellm":
|
||||
return config.litellmModelId
|
||||
case "deepinfra":
|
||||
return config.deepInfraModelId
|
||||
case "huggingface":
|
||||
return config.huggingFaceModelId
|
||||
case "unbound":
|
||||
return config.unboundModelId
|
||||
case "vercel-ai-gateway":
|
||||
return config.vercelAiGatewayModelId
|
||||
case "io-intelligence":
|
||||
return config.ioIntelligenceModelId
|
||||
default:
|
||||
// For anthropic, bedrock, vertex, gemini, xai, groq, etc.
|
||||
// For anthropic, bedrock, vertex, gemini, xai, etc.
|
||||
return config.apiModelId
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@roo-code/evals": "workspace:^",
|
||||
"@roo-code/types": "^1.108.0",
|
||||
"@roo-code/types": "workspace:^",
|
||||
"@tanstack/react-query": "^5.69.0",
|
||||
"archiver": "^7.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import type { NextRequest } from "next/server"
|
||||
import * as fs from "node:fs/promises"
|
||||
import * as path from "node:path"
|
||||
|
||||
import { findTask, findRun } from "@roo-code/evals"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const LOG_BASE_PATH = "/tmp/evals/runs"
|
||||
|
||||
// Sanitize path components to prevent path traversal attacks
|
||||
function sanitizePathComponent(component: string): string {
|
||||
// Remove any path separators, null bytes, and other dangerous characters
|
||||
return component.replace(/[/\\:\0*?"<>|]/g, "_")
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string; taskId: string }> }) {
|
||||
const { id, taskId } = await params
|
||||
|
||||
try {
|
||||
const runId = Number(id)
|
||||
const taskIdNum = Number(taskId)
|
||||
|
||||
if (isNaN(runId) || isNaN(taskIdNum)) {
|
||||
return NextResponse.json({ error: "Invalid run ID or task ID" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify the run exists
|
||||
await findRun(runId)
|
||||
|
||||
// Get the task to find its language and exercise
|
||||
const task = await findTask(taskIdNum)
|
||||
|
||||
// Verify the task belongs to this run
|
||||
if (task.runId !== runId) {
|
||||
return NextResponse.json({ error: "Task does not belong to this run" }, { status: 404 })
|
||||
}
|
||||
|
||||
// Sanitize language and exercise to prevent path traversal
|
||||
const safeLanguage = sanitizePathComponent(task.language)
|
||||
const safeExercise = sanitizePathComponent(task.exercise)
|
||||
|
||||
// Construct the log file path
|
||||
const logFileName = `${safeLanguage}-${safeExercise}.log`
|
||||
const logFilePath = path.join(LOG_BASE_PATH, String(runId), logFileName)
|
||||
|
||||
// Verify the resolved path is within the expected directory (defense in depth)
|
||||
const resolvedPath = path.resolve(logFilePath)
|
||||
const expectedBase = path.resolve(LOG_BASE_PATH)
|
||||
if (!resolvedPath.startsWith(expectedBase)) {
|
||||
return NextResponse.json({ error: "Invalid log path" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if the log file exists and read it (async)
|
||||
try {
|
||||
const logContent = await fs.readFile(logFilePath, "utf-8")
|
||||
return NextResponse.json({ logContent })
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return NextResponse.json({ error: "Log file not found", logContent: null }, { status: 200 })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error reading task log:", error)
|
||||
|
||||
if (error instanceof Error && error.name === "RecordNotFoundError") {
|
||||
return NextResponse.json({ error: "Task or run not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Failed to read log file" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import type { NextRequest } from "next/server"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import archiver from "archiver"
|
||||
|
||||
import { findRun, getTasks } from "@roo-code/evals"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const LOG_BASE_PATH = "/tmp/evals/runs"
|
||||
|
||||
// Sanitize path components to prevent path traversal attacks
|
||||
function sanitizePathComponent(component: string): string {
|
||||
// Remove any path separators, null bytes, and other dangerous characters
|
||||
return component.replace(/[/\\:\0*?"<>|]/g, "_")
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const runId = Number(id)
|
||||
|
||||
if (isNaN(runId)) {
|
||||
return NextResponse.json({ error: "Invalid run ID" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify the run exists
|
||||
await findRun(runId)
|
||||
|
||||
// Get all tasks for this run
|
||||
const tasks = await getTasks(runId)
|
||||
|
||||
// Filter for failed tasks only
|
||||
const failedTasks = tasks.filter((task) => task.passed === false)
|
||||
|
||||
if (failedTasks.length === 0) {
|
||||
return NextResponse.json({ error: "No failed tasks to export" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Create a zip archive
|
||||
const archive = archiver("zip", { zlib: { level: 9 } })
|
||||
|
||||
// Collect chunks to build the response
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
archive.on("data", (chunk: Buffer) => {
|
||||
chunks.push(chunk)
|
||||
})
|
||||
|
||||
// Track archive errors
|
||||
let archiveError: Error | null = null
|
||||
archive.on("error", (err: Error) => {
|
||||
archiveError = err
|
||||
})
|
||||
|
||||
// Set up the end promise before finalizing (proper event listener ordering)
|
||||
const archiveEndPromise = new Promise<void>((resolve, reject) => {
|
||||
archive.on("end", resolve)
|
||||
archive.on("error", reject)
|
||||
})
|
||||
|
||||
// Add each failed task's log file and history files to the archive
|
||||
const logDir = path.join(LOG_BASE_PATH, String(runId))
|
||||
let filesAdded = 0
|
||||
|
||||
for (const task of failedTasks) {
|
||||
// Sanitize language and exercise to prevent path traversal
|
||||
const safeLanguage = sanitizePathComponent(task.language)
|
||||
const safeExercise = sanitizePathComponent(task.exercise)
|
||||
const expectedBase = path.resolve(LOG_BASE_PATH)
|
||||
|
||||
// Add the log file
|
||||
const logFileName = `${safeLanguage}-${safeExercise}.log`
|
||||
const logFilePath = path.join(logDir, logFileName)
|
||||
|
||||
// Verify the resolved path is within the expected directory (defense in depth)
|
||||
const resolvedLogPath = path.resolve(logFilePath)
|
||||
if (resolvedLogPath.startsWith(expectedBase) && fs.existsSync(logFilePath)) {
|
||||
archive.file(logFilePath, { name: logFileName })
|
||||
filesAdded++
|
||||
}
|
||||
|
||||
// Add the API conversation history file
|
||||
// Format: {language}-{exercise}.{iteration}_api_conversation_history.json
|
||||
const apiHistoryFileName = `${safeLanguage}-${safeExercise}.${task.iteration}_api_conversation_history.json`
|
||||
const apiHistoryFilePath = path.join(logDir, apiHistoryFileName)
|
||||
const resolvedApiHistoryPath = path.resolve(apiHistoryFilePath)
|
||||
if (resolvedApiHistoryPath.startsWith(expectedBase) && fs.existsSync(apiHistoryFilePath)) {
|
||||
archive.file(apiHistoryFilePath, { name: apiHistoryFileName })
|
||||
filesAdded++
|
||||
}
|
||||
|
||||
// Add the UI messages file
|
||||
// Format: {language}-{exercise}.{iteration}_ui_messages.json
|
||||
const uiMessagesFileName = `${safeLanguage}-${safeExercise}.${task.iteration}_ui_messages.json`
|
||||
const uiMessagesFilePath = path.join(logDir, uiMessagesFileName)
|
||||
const resolvedUiMessagesPath = path.resolve(uiMessagesFilePath)
|
||||
if (resolvedUiMessagesPath.startsWith(expectedBase) && fs.existsSync(uiMessagesFilePath)) {
|
||||
archive.file(uiMessagesFilePath, { name: uiMessagesFileName })
|
||||
filesAdded++
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any files were actually added
|
||||
if (filesAdded === 0) {
|
||||
archive.abort()
|
||||
return NextResponse.json(
|
||||
{ error: "No log files found - they may have been cleared from disk" },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
// Finalize the archive
|
||||
await archive.finalize()
|
||||
|
||||
// Wait for all data to be collected
|
||||
await archiveEndPromise
|
||||
|
||||
// Check for archive errors
|
||||
if (archiveError) {
|
||||
throw archiveError
|
||||
}
|
||||
|
||||
// Combine all chunks into a single buffer
|
||||
const zipBuffer = Buffer.concat(chunks)
|
||||
|
||||
// Return the zip file
|
||||
return new NextResponse(zipBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="run-${runId}-failed-logs.zip"`,
|
||||
"Content-Length": String(zipBuffer.length),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error exporting failed logs:", error)
|
||||
|
||||
if (error instanceof Error && error.name === "RecordNotFoundError") {
|
||||
return NextResponse.json({ error: "Run not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Failed to export logs" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@ export const runTaskWithCli = async ({ run, task, publish, logger, jobToken }: R
|
|||
|
||||
if (rooTaskId && !isClientDisconnected) {
|
||||
logger.info("cancelling task")
|
||||
client.sendCommand({ commandName: TaskCommandName.CancelTask, data: rooTaskId })
|
||||
client.sendCommand({ commandName: TaskCommandName.CancelTask })
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000))
|
||||
}
|
||||
|
||||
|
|
@ -289,7 +289,7 @@ export const runTaskWithCli = async ({ run, task, publish, logger, jobToken }: R
|
|||
|
||||
if (rooTaskId && !isClientDisconnected) {
|
||||
logger.info("closing task")
|
||||
client.sendCommand({ commandName: TaskCommandName.CloseTask, data: rooTaskId })
|
||||
client.sendCommand({ commandName: TaskCommandName.CloseTask })
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -270,7 +270,7 @@ export const runTaskInVscode = async ({ run, task, publish, logger, jobToken }:
|
|||
|
||||
if (rooTaskId && !isClientDisconnected) {
|
||||
logger.info("cancelling task")
|
||||
client.sendCommand({ commandName: TaskCommandName.CancelTask, data: rooTaskId })
|
||||
client.sendCommand({ commandName: TaskCommandName.CancelTask })
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000)) // Allow some time for the task to cancel.
|
||||
}
|
||||
|
||||
|
|
@ -289,7 +289,7 @@ export const runTaskInVscode = async ({ run, task, publish, logger, jobToken }:
|
|||
|
||||
if (rooTaskId && !isClientDisconnected) {
|
||||
logger.info("closing task")
|
||||
client.sendCommand({ commandName: TaskCommandName.CloseTask, data: rooTaskId })
|
||||
client.sendCommand({ commandName: TaskCommandName.CloseTask })
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000)) // Allow some time for the window to close.
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@roo-code/types",
|
||||
"version": "1.106.0",
|
||||
"version": "1.110.0",
|
||||
"description": "TypeScript type definitions for Roo Code.",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ describe("IPC Types", () => {
|
|||
const result = taskCommandSchema.safeParse(resumeTaskCommand)
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
if (result.success) {
|
||||
if (result.success && result.data.commandName === TaskCommandName.ResumeTask) {
|
||||
expect(result.data.commandName).toBe("ResumeTask")
|
||||
expect(result.data.data).toBe("non-existent-task-id")
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ describe("IPC Types", () => {
|
|||
const result = taskCommandSchema.safeParse(resumeTaskCommand)
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
if (result.success) {
|
||||
if (result.success && result.data.commandName === TaskCommandName.ResumeTask) {
|
||||
expect(result.data.commandName).toBe("ResumeTask")
|
||||
expect(result.data.data).toBe("task-123")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, tokenUsageSchema } from "./message.js"
|
||||
import { clineMessageSchema, queuedMessageSchema, tokenUsageSchema } from "./message.js"
|
||||
import { modelInfoSchema } from "./model.js"
|
||||
import { toolNamesSchema, toolUsageSchema } from "./tool.js"
|
||||
|
||||
/**
|
||||
|
|
@ -35,6 +36,7 @@ export enum RooCodeEventName {
|
|||
TaskModeSwitched = "taskModeSwitched",
|
||||
TaskAskResponded = "taskAskResponded",
|
||||
TaskUserMessage = "taskUserMessage",
|
||||
QueuedMessagesUpdated = "queuedMessagesUpdated",
|
||||
|
||||
// Task Analytics
|
||||
TaskTokenUsageUpdated = "taskTokenUsageUpdated",
|
||||
|
|
@ -44,6 +46,11 @@ export enum RooCodeEventName {
|
|||
ModeChanged = "modeChanged",
|
||||
ProviderProfileChanged = "providerProfileChanged",
|
||||
|
||||
// Query Responses
|
||||
CommandsResponse = "commandsResponse",
|
||||
ModesResponse = "modesResponse",
|
||||
ModelsResponse = "modelsResponse",
|
||||
|
||||
// Evals
|
||||
EvalPass = "evalPass",
|
||||
EvalFail = "evalFail",
|
||||
|
|
@ -100,12 +107,27 @@ export const rooCodeEventsSchema = z.object({
|
|||
[RooCodeEventName.TaskModeSwitched]: z.tuple([z.string(), z.string()]),
|
||||
[RooCodeEventName.TaskAskResponded]: z.tuple([z.string()]),
|
||||
[RooCodeEventName.TaskUserMessage]: z.tuple([z.string()]),
|
||||
[RooCodeEventName.QueuedMessagesUpdated]: z.tuple([z.string(), z.array(queuedMessageSchema)]),
|
||||
|
||||
[RooCodeEventName.TaskToolFailed]: z.tuple([z.string(), toolNamesSchema, z.string()]),
|
||||
[RooCodeEventName.TaskTokenUsageUpdated]: z.tuple([z.string(), tokenUsageSchema, toolUsageSchema]),
|
||||
|
||||
[RooCodeEventName.ModeChanged]: z.tuple([z.string()]),
|
||||
[RooCodeEventName.ProviderProfileChanged]: z.tuple([z.object({ name: z.string(), provider: z.string() })]),
|
||||
|
||||
[RooCodeEventName.CommandsResponse]: z.tuple([
|
||||
z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
source: z.enum(["global", "project", "built-in"]),
|
||||
filePath: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
argumentHint: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
]),
|
||||
[RooCodeEventName.ModesResponse]: z.tuple([z.array(z.object({ slug: z.string(), name: z.string() }))]),
|
||||
[RooCodeEventName.ModelsResponse]: z.tuple([z.record(z.string(), modelInfoSchema)]),
|
||||
})
|
||||
|
||||
export type RooCodeEvents = z.infer<typeof rooCodeEventsSchema>
|
||||
|
|
@ -217,6 +239,11 @@ export const taskEventSchema = z.discriminatedUnion("eventName", [
|
|||
payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskAskResponded],
|
||||
taskId: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
eventName: z.literal(RooCodeEventName.QueuedMessagesUpdated),
|
||||
payload: rooCodeEventsSchema.shape[RooCodeEventName.QueuedMessagesUpdated],
|
||||
taskId: z.number().optional(),
|
||||
}),
|
||||
|
||||
// Task Analytics
|
||||
z.object({
|
||||
|
|
@ -230,6 +257,23 @@ export const taskEventSchema = z.discriminatedUnion("eventName", [
|
|||
taskId: z.number().optional(),
|
||||
}),
|
||||
|
||||
// Query Responses
|
||||
z.object({
|
||||
eventName: z.literal(RooCodeEventName.CommandsResponse),
|
||||
payload: rooCodeEventsSchema.shape[RooCodeEventName.CommandsResponse],
|
||||
taskId: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
eventName: z.literal(RooCodeEventName.ModesResponse),
|
||||
payload: rooCodeEventsSchema.shape[RooCodeEventName.ModesResponse],
|
||||
taskId: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
eventName: z.literal(RooCodeEventName.ModelsResponse),
|
||||
payload: rooCodeEventsSchema.shape[RooCodeEventName.ModelsResponse],
|
||||
taskId: z.number().optional(),
|
||||
}),
|
||||
|
||||
// Evals
|
||||
z.object({
|
||||
eventName: z.literal(RooCodeEventName.EvalPass),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { experimentsSchema } from "./experiment.js"
|
|||
import { telemetrySettingsSchema } from "./telemetry.js"
|
||||
import { modeConfigSchema } from "./mode.js"
|
||||
import { customModePromptsSchema, customSupportPromptsSchema } from "./mode.js"
|
||||
import { toolNamesSchema } from "./tool.js"
|
||||
import { languagesSchema } from "./vscode.js"
|
||||
|
||||
/**
|
||||
|
|
@ -166,6 +167,7 @@ export const globalSettingsSchema = z.object({
|
|||
ttsSpeed: z.number().optional(),
|
||||
soundEnabled: z.boolean().optional(),
|
||||
soundVolume: z.number().optional(),
|
||||
taskHeaderHighlightEnabled: z.boolean().optional(),
|
||||
|
||||
maxOpenTabsContext: z.number().optional(),
|
||||
maxWorkspaceFiles: z.number().optional(),
|
||||
|
|
@ -238,6 +240,12 @@ export const globalSettingsSchema = z.object({
|
|||
* @default true
|
||||
*/
|
||||
showWorktreesInHomeScreen: z.boolean().optional(),
|
||||
|
||||
/**
|
||||
* List of native tool names to globally disable.
|
||||
* Tools in this list will be excluded from prompt generation and rejected at execution time.
|
||||
*/
|
||||
disabledTools: z.array(toolNamesSchema).optional(),
|
||||
})
|
||||
|
||||
export type GlobalSettings = z.infer<typeof globalSettingsSchema>
|
||||
|
|
@ -266,19 +274,13 @@ export const SECRET_STATE_KEYS = [
|
|||
"ollamaApiKey",
|
||||
"geminiApiKey",
|
||||
"openAiNativeApiKey",
|
||||
"cerebrasApiKey",
|
||||
"deepSeekApiKey",
|
||||
"doubaoApiKey",
|
||||
"moonshotApiKey",
|
||||
"mistralApiKey",
|
||||
"minimaxApiKey",
|
||||
"unboundApiKey",
|
||||
"requestyApiKey",
|
||||
"xaiApiKey",
|
||||
"groqApiKey",
|
||||
"chutesApiKey",
|
||||
"litellmApiKey",
|
||||
"deepInfraApiKey",
|
||||
"codeIndexOpenAiKey",
|
||||
"codeIndexQdrantApiKey",
|
||||
"codebaseIndexOpenAiCompatibleApiKey",
|
||||
|
|
@ -286,14 +288,12 @@ export const SECRET_STATE_KEYS = [
|
|||
"codebaseIndexMistralApiKey",
|
||||
"codebaseIndexVercelAiGatewayApiKey",
|
||||
"codebaseIndexOpenRouterApiKey",
|
||||
"huggingFaceApiKey",
|
||||
"sambaNovaApiKey",
|
||||
"zaiApiKey",
|
||||
"fireworksApiKey",
|
||||
"featherlessApiKey",
|
||||
"ioIntelligenceApiKey",
|
||||
"vercelAiGatewayApiKey",
|
||||
"basetenApiKey",
|
||||
"azureApiKey",
|
||||
] as const
|
||||
|
||||
// Global secrets that are part of GlobalSettings (not ProviderSettings)
|
||||
|
|
@ -367,6 +367,7 @@ export const EVALS_SETTINGS: RooCodeSettings = {
|
|||
ttsSpeed: 1,
|
||||
soundEnabled: false,
|
||||
soundVolume: 0.5,
|
||||
taskHeaderHighlightEnabled: false,
|
||||
|
||||
terminalShellIntegrationTimeout: 30000,
|
||||
terminalCommandDelay: 0,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ export enum TaskCommandName {
|
|||
CloseTask = "CloseTask",
|
||||
ResumeTask = "ResumeTask",
|
||||
SendMessage = "SendMessage",
|
||||
GetCommands = "GetCommands",
|
||||
GetModes = "GetModes",
|
||||
GetModels = "GetModels",
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -64,11 +67,9 @@ export const taskCommandSchema = z.discriminatedUnion("commandName", [
|
|||
}),
|
||||
z.object({
|
||||
commandName: z.literal(TaskCommandName.CancelTask),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
commandName: z.literal(TaskCommandName.CloseTask),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
commandName: z.literal(TaskCommandName.ResumeTask),
|
||||
|
|
@ -81,6 +82,15 @@ export const taskCommandSchema = z.discriminatedUnion("commandName", [
|
|||
images: z.array(z.string()).optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
commandName: z.literal(TaskCommandName.GetCommands),
|
||||
}),
|
||||
z.object({
|
||||
commandName: z.literal(TaskCommandName.GetModes),
|
||||
}),
|
||||
z.object({
|
||||
commandName: z.literal(TaskCommandName.GetModels),
|
||||
}),
|
||||
])
|
||||
|
||||
export type TaskCommand = z.infer<typeof taskCommandSchema>
|
||||
|
|
|
|||
|
|
@ -6,14 +6,9 @@ import {
|
|||
anthropicModels,
|
||||
basetenModels,
|
||||
bedrockModels,
|
||||
cerebrasModels,
|
||||
deepSeekModels,
|
||||
doubaoModels,
|
||||
featherlessModels,
|
||||
fireworksModels,
|
||||
geminiModels,
|
||||
groqModels,
|
||||
ioIntelligenceModels,
|
||||
mistralModels,
|
||||
moonshotModels,
|
||||
openAiCodexModels,
|
||||
|
|
@ -39,18 +34,7 @@ export const DEFAULT_CONSECUTIVE_MISTAKE_LIMIT = 3
|
|||
* Dynamic provider requires external API calls in order to get the model list.
|
||||
*/
|
||||
|
||||
export const dynamicProviders = [
|
||||
"openrouter",
|
||||
"vercel-ai-gateway",
|
||||
"huggingface",
|
||||
"litellm",
|
||||
"deepinfra",
|
||||
"io-intelligence",
|
||||
"requesty",
|
||||
"unbound",
|
||||
"roo",
|
||||
"chutes",
|
||||
] as const
|
||||
export const dynamicProviders = ["openrouter", "vercel-ai-gateway", "litellm", "requesty", "roo"] as const
|
||||
|
||||
export type DynamicProvider = (typeof dynamicProviders)[number]
|
||||
|
||||
|
|
@ -119,16 +103,13 @@ export const providerNames = [
|
|||
...customProviders,
|
||||
...fauxProviders,
|
||||
"anthropic",
|
||||
"azure",
|
||||
"bedrock",
|
||||
"baseten",
|
||||
"cerebras",
|
||||
"doubao",
|
||||
"deepseek",
|
||||
"featherless",
|
||||
"fireworks",
|
||||
"gemini",
|
||||
"gemini-cli",
|
||||
"groq",
|
||||
"mistral",
|
||||
"moonshot",
|
||||
"minimax",
|
||||
|
|
@ -149,6 +130,33 @@ export type ProviderName = z.infer<typeof providerNamesSchema>
|
|||
export const isProviderName = (key: unknown): key is ProviderName =>
|
||||
typeof key === "string" && providerNames.includes(key as ProviderName)
|
||||
|
||||
/**
|
||||
* RetiredProviderName
|
||||
*/
|
||||
|
||||
export const retiredProviderNames = [
|
||||
"cerebras",
|
||||
"chutes",
|
||||
"deepinfra",
|
||||
"doubao",
|
||||
"featherless",
|
||||
"groq",
|
||||
"huggingface",
|
||||
"io-intelligence",
|
||||
"unbound",
|
||||
] as const
|
||||
|
||||
export const retiredProviderNamesSchema = z.enum(retiredProviderNames)
|
||||
|
||||
export type RetiredProviderName = z.infer<typeof retiredProviderNamesSchema>
|
||||
|
||||
export const isRetiredProvider = (value: string): value is RetiredProviderName =>
|
||||
retiredProviderNames.includes(value as RetiredProviderName)
|
||||
|
||||
export const providerNamesWithRetiredSchema = z.union([providerNamesSchema, retiredProviderNamesSchema])
|
||||
|
||||
export type ProviderNameWithRetired = z.infer<typeof providerNamesWithRetiredSchema>
|
||||
|
||||
/**
|
||||
* ProviderSettingsEntry
|
||||
*/
|
||||
|
|
@ -156,7 +164,7 @@ export const isProviderName = (key: unknown): key is ProviderName =>
|
|||
export const providerSettingsEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
apiProvider: providerNamesSchema.optional(),
|
||||
apiProvider: providerNamesWithRetiredSchema.optional(),
|
||||
modelId: z.string().optional(),
|
||||
})
|
||||
|
||||
|
|
@ -227,8 +235,6 @@ const vertexSchema = apiModelIdProviderModelSchema.extend({
|
|||
vertexJsonCredentials: z.string().optional(),
|
||||
vertexProjectId: z.string().optional(),
|
||||
vertexRegion: z.string().optional(),
|
||||
enableUrlContext: z.boolean().optional(),
|
||||
enableGrounding: z.boolean().optional(),
|
||||
vertex1MContext: z.boolean().optional(), // Enable 'context-1m-2025-08-07' beta for 1M context window.
|
||||
})
|
||||
|
||||
|
|
@ -273,8 +279,6 @@ const lmStudioSchema = baseProviderSettingsSchema.extend({
|
|||
const geminiSchema = apiModelIdProviderModelSchema.extend({
|
||||
geminiApiKey: z.string().optional(),
|
||||
googleGeminiBaseUrl: z.string().optional(),
|
||||
enableUrlContext: z.boolean().optional(),
|
||||
enableGrounding: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const geminiCliSchema = apiModelIdProviderModelSchema.extend({
|
||||
|
|
@ -304,17 +308,6 @@ const deepSeekSchema = apiModelIdProviderModelSchema.extend({
|
|||
deepSeekApiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
const deepInfraSchema = apiModelIdProviderModelSchema.extend({
|
||||
deepInfraBaseUrl: z.string().optional(),
|
||||
deepInfraApiKey: z.string().optional(),
|
||||
deepInfraModelId: z.string().optional(),
|
||||
})
|
||||
|
||||
const doubaoSchema = apiModelIdProviderModelSchema.extend({
|
||||
doubaoBaseUrl: z.string().optional(),
|
||||
doubaoApiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
const moonshotSchema = apiModelIdProviderModelSchema.extend({
|
||||
moonshotBaseUrl: z
|
||||
.union([z.literal("https://api.moonshot.ai/v1"), z.literal("https://api.moonshot.cn/v1")])
|
||||
|
|
@ -329,11 +322,6 @@ const minimaxSchema = apiModelIdProviderModelSchema.extend({
|
|||
minimaxApiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
const unboundSchema = baseProviderSettingsSchema.extend({
|
||||
unboundApiKey: z.string().optional(),
|
||||
unboundModelId: z.string().optional(),
|
||||
})
|
||||
|
||||
const requestySchema = baseProviderSettingsSchema.extend({
|
||||
requestyBaseUrl: z.string().optional(),
|
||||
requestyApiKey: z.string().optional(),
|
||||
|
|
@ -348,20 +336,6 @@ const xaiSchema = apiModelIdProviderModelSchema.extend({
|
|||
xaiApiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
const groqSchema = apiModelIdProviderModelSchema.extend({
|
||||
groqApiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
const huggingFaceSchema = baseProviderSettingsSchema.extend({
|
||||
huggingFaceApiKey: z.string().optional(),
|
||||
huggingFaceModelId: z.string().optional(),
|
||||
huggingFaceInferenceProvider: z.string().optional(),
|
||||
})
|
||||
|
||||
const chutesSchema = apiModelIdProviderModelSchema.extend({
|
||||
chutesApiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
const litellmSchema = baseProviderSettingsSchema.extend({
|
||||
litellmBaseUrl: z.string().optional(),
|
||||
litellmApiKey: z.string().optional(),
|
||||
|
|
@ -369,10 +343,6 @@ const litellmSchema = baseProviderSettingsSchema.extend({
|
|||
litellmUsePromptCache: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const cerebrasSchema = apiModelIdProviderModelSchema.extend({
|
||||
cerebrasApiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
const sambaNovaSchema = apiModelIdProviderModelSchema.extend({
|
||||
sambaNovaApiKey: z.string().optional(),
|
||||
})
|
||||
|
|
@ -390,15 +360,6 @@ const fireworksSchema = apiModelIdProviderModelSchema.extend({
|
|||
fireworksApiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
const featherlessSchema = apiModelIdProviderModelSchema.extend({
|
||||
featherlessApiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
const ioIntelligenceSchema = apiModelIdProviderModelSchema.extend({
|
||||
ioIntelligenceModelId: z.string().optional(),
|
||||
ioIntelligenceApiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
const qwenCodeSchema = apiModelIdProviderModelSchema.extend({
|
||||
qwenCodeOauthPath: z.string().optional(),
|
||||
})
|
||||
|
|
@ -417,12 +378,20 @@ const basetenSchema = apiModelIdProviderModelSchema.extend({
|
|||
basetenApiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
const azureSchema = apiModelIdProviderModelSchema.extend({
|
||||
azureApiKey: z.string().optional(),
|
||||
azureResourceName: z.string().optional(),
|
||||
azureDeploymentName: z.string().optional(),
|
||||
azureApiVersion: z.string().optional(),
|
||||
})
|
||||
|
||||
const defaultSchema = z.object({
|
||||
apiProvider: z.undefined(),
|
||||
})
|
||||
|
||||
export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProvider", [
|
||||
anthropicSchema.merge(z.object({ apiProvider: z.literal("anthropic") })),
|
||||
azureSchema.merge(z.object({ apiProvider: z.literal("azure") })),
|
||||
openRouterSchema.merge(z.object({ apiProvider: z.literal("openrouter") })),
|
||||
bedrockSchema.merge(z.object({ apiProvider: z.literal("bedrock") })),
|
||||
vertexSchema.merge(z.object({ apiProvider: z.literal("vertex") })),
|
||||
|
|
@ -436,25 +405,16 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
|
|||
openAiNativeSchema.merge(z.object({ apiProvider: z.literal("openai-native") })),
|
||||
mistralSchema.merge(z.object({ apiProvider: z.literal("mistral") })),
|
||||
deepSeekSchema.merge(z.object({ apiProvider: z.literal("deepseek") })),
|
||||
deepInfraSchema.merge(z.object({ apiProvider: z.literal("deepinfra") })),
|
||||
doubaoSchema.merge(z.object({ apiProvider: z.literal("doubao") })),
|
||||
moonshotSchema.merge(z.object({ apiProvider: z.literal("moonshot") })),
|
||||
minimaxSchema.merge(z.object({ apiProvider: z.literal("minimax") })),
|
||||
unboundSchema.merge(z.object({ apiProvider: z.literal("unbound") })),
|
||||
requestySchema.merge(z.object({ apiProvider: z.literal("requesty") })),
|
||||
fakeAiSchema.merge(z.object({ apiProvider: z.literal("fake-ai") })),
|
||||
xaiSchema.merge(z.object({ apiProvider: z.literal("xai") })),
|
||||
groqSchema.merge(z.object({ apiProvider: z.literal("groq") })),
|
||||
basetenSchema.merge(z.object({ apiProvider: z.literal("baseten") })),
|
||||
huggingFaceSchema.merge(z.object({ apiProvider: z.literal("huggingface") })),
|
||||
chutesSchema.merge(z.object({ apiProvider: z.literal("chutes") })),
|
||||
litellmSchema.merge(z.object({ apiProvider: z.literal("litellm") })),
|
||||
cerebrasSchema.merge(z.object({ apiProvider: z.literal("cerebras") })),
|
||||
sambaNovaSchema.merge(z.object({ apiProvider: z.literal("sambanova") })),
|
||||
zaiSchema.merge(z.object({ apiProvider: z.literal("zai") })),
|
||||
fireworksSchema.merge(z.object({ apiProvider: z.literal("fireworks") })),
|
||||
featherlessSchema.merge(z.object({ apiProvider: z.literal("featherless") })),
|
||||
ioIntelligenceSchema.merge(z.object({ apiProvider: z.literal("io-intelligence") })),
|
||||
qwenCodeSchema.merge(z.object({ apiProvider: z.literal("qwen-code") })),
|
||||
rooSchema.merge(z.object({ apiProvider: z.literal("roo") })),
|
||||
vercelAiGatewaySchema.merge(z.object({ apiProvider: z.literal("vercel-ai-gateway") })),
|
||||
|
|
@ -462,8 +422,9 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
|
|||
])
|
||||
|
||||
export const providerSettingsSchema = z.object({
|
||||
apiProvider: providerNamesSchema.optional(),
|
||||
apiProvider: providerNamesWithRetiredSchema.optional(),
|
||||
...anthropicSchema.shape,
|
||||
...azureSchema.shape,
|
||||
...openRouterSchema.shape,
|
||||
...bedrockSchema.shape,
|
||||
...vertexSchema.shape,
|
||||
|
|
@ -477,25 +438,16 @@ export const providerSettingsSchema = z.object({
|
|||
...openAiNativeSchema.shape,
|
||||
...mistralSchema.shape,
|
||||
...deepSeekSchema.shape,
|
||||
...deepInfraSchema.shape,
|
||||
...doubaoSchema.shape,
|
||||
...moonshotSchema.shape,
|
||||
...minimaxSchema.shape,
|
||||
...unboundSchema.shape,
|
||||
...requestySchema.shape,
|
||||
...fakeAiSchema.shape,
|
||||
...xaiSchema.shape,
|
||||
...groqSchema.shape,
|
||||
...basetenSchema.shape,
|
||||
...huggingFaceSchema.shape,
|
||||
...chutesSchema.shape,
|
||||
...litellmSchema.shape,
|
||||
...cerebrasSchema.shape,
|
||||
...sambaNovaSchema.shape,
|
||||
...zaiSchema.shape,
|
||||
...fireworksSchema.shape,
|
||||
...featherlessSchema.shape,
|
||||
...ioIntelligenceSchema.shape,
|
||||
...qwenCodeSchema.shape,
|
||||
...rooSchema.shape,
|
||||
...vercelAiGatewaySchema.shape,
|
||||
|
|
@ -525,13 +477,9 @@ export const modelIdKeys = [
|
|||
"ollamaModelId",
|
||||
"lmStudioModelId",
|
||||
"lmStudioDraftModelId",
|
||||
"unboundModelId",
|
||||
"requestyModelId",
|
||||
"litellmModelId",
|
||||
"huggingFaceModelId",
|
||||
"ioIntelligenceModelId",
|
||||
"vercelAiGatewayModelId",
|
||||
"deepInfraModelId",
|
||||
] as const satisfies readonly (keyof ProviderSettings)[]
|
||||
|
||||
export type ModelIdKey = (typeof modelIdKeys)[number]
|
||||
|
|
@ -552,6 +500,7 @@ export const isTypicalProvider = (key: unknown): key is TypicalProvider =>
|
|||
|
||||
export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
|
||||
anthropic: "apiModelId",
|
||||
azure: "apiModelId",
|
||||
openrouter: "openRouterModelId",
|
||||
bedrock: "apiModelId",
|
||||
vertex: "apiModelId",
|
||||
|
|
@ -565,23 +514,14 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
|
|||
moonshot: "apiModelId",
|
||||
minimax: "apiModelId",
|
||||
deepseek: "apiModelId",
|
||||
deepinfra: "deepInfraModelId",
|
||||
doubao: "apiModelId",
|
||||
"qwen-code": "apiModelId",
|
||||
unbound: "unboundModelId",
|
||||
requesty: "requestyModelId",
|
||||
xai: "apiModelId",
|
||||
groq: "apiModelId",
|
||||
baseten: "apiModelId",
|
||||
chutes: "apiModelId",
|
||||
litellm: "litellmModelId",
|
||||
huggingface: "huggingFaceModelId",
|
||||
cerebras: "apiModelId",
|
||||
sambanova: "apiModelId",
|
||||
zai: "apiModelId",
|
||||
fireworks: "apiModelId",
|
||||
featherless: "apiModelId",
|
||||
"io-intelligence": "ioIntelligenceModelId",
|
||||
roo: "apiModelId",
|
||||
"vercel-ai-gateway": "vercelAiGatewayModelId",
|
||||
}
|
||||
|
|
@ -628,27 +568,22 @@ export const MODELS_BY_PROVIDER: Record<
|
|||
label: "Anthropic",
|
||||
models: Object.keys(anthropicModels),
|
||||
},
|
||||
azure: {
|
||||
id: "azure",
|
||||
label: "Azure AI Foundry",
|
||||
// Azure uses deployment names configured by the user (not a fixed upstream model ID list)
|
||||
models: [],
|
||||
},
|
||||
bedrock: {
|
||||
id: "bedrock",
|
||||
label: "Amazon Bedrock",
|
||||
models: Object.keys(bedrockModels),
|
||||
},
|
||||
cerebras: {
|
||||
id: "cerebras",
|
||||
label: "Cerebras",
|
||||
models: Object.keys(cerebrasModels),
|
||||
},
|
||||
deepseek: {
|
||||
id: "deepseek",
|
||||
label: "DeepSeek",
|
||||
models: Object.keys(deepSeekModels),
|
||||
},
|
||||
doubao: { id: "doubao", label: "Doubao", models: Object.keys(doubaoModels) },
|
||||
featherless: {
|
||||
id: "featherless",
|
||||
label: "Featherless",
|
||||
models: Object.keys(featherlessModels),
|
||||
},
|
||||
fireworks: {
|
||||
id: "fireworks",
|
||||
label: "Fireworks",
|
||||
|
|
@ -659,12 +594,6 @@ export const MODELS_BY_PROVIDER: Record<
|
|||
label: "Google Gemini",
|
||||
models: Object.keys(geminiModels),
|
||||
},
|
||||
groq: { id: "groq", label: "Groq", models: Object.keys(groqModels) },
|
||||
"io-intelligence": {
|
||||
id: "io-intelligence",
|
||||
label: "IO Intelligence",
|
||||
models: Object.keys(ioIntelligenceModels),
|
||||
},
|
||||
mistral: {
|
||||
id: "mistral",
|
||||
label: "Mistral",
|
||||
|
|
@ -712,14 +641,10 @@ export const MODELS_BY_PROVIDER: Record<
|
|||
baseten: { id: "baseten", label: "Baseten", models: Object.keys(basetenModels) },
|
||||
|
||||
// Dynamic providers; models pulled from remote APIs.
|
||||
huggingface: { id: "huggingface", label: "Hugging Face", models: [] },
|
||||
litellm: { id: "litellm", label: "LiteLLM", models: [] },
|
||||
openrouter: { id: "openrouter", label: "OpenRouter", models: [] },
|
||||
requesty: { id: "requesty", label: "Requesty", models: [] },
|
||||
unbound: { id: "unbound", label: "Unbound", models: [] },
|
||||
deepinfra: { id: "deepinfra", label: "DeepInfra", models: [] },
|
||||
"vercel-ai-gateway": { id: "vercel-ai-gateway", label: "Vercel AI Gateway", models: [] },
|
||||
chutes: { id: "chutes", label: "Chutes AI", models: [] },
|
||||
|
||||
// Local providers; models discovered from localhost endpoints.
|
||||
lmstudio: { id: "lmstudio", label: "LM Studio", models: [] },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
403
packages/types/src/providers/azure.ts
Normal file
403
packages/types/src/providers/azure.ts
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
import type { ModelInfo } from "../model.js"
|
||||
|
||||
/**
|
||||
* Azure AI Foundry model metadata.
|
||||
*
|
||||
* NOTE:
|
||||
* - Azure AI Foundry uses *deployment names* at runtime, but Roo still needs underlying model
|
||||
* capabilities (maxTokens/contextWindow/etc.) for validation and parameter shaping.
|
||||
* - This list is derived from https://models.dev/api.json (provider: "azure") and intentionally
|
||||
* restricted to OpenAI/Azure OpenAI-style IDs (gpt-*, o*, codex-*).
|
||||
*/
|
||||
export const azureModels = {
|
||||
"codex-mini": {
|
||||
maxTokens: 100_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 1.5,
|
||||
outputPrice: 6,
|
||||
cacheReadsPrice: 0.375,
|
||||
supportsTemperature: false,
|
||||
description:
|
||||
"Codex Mini: Cloud-based software engineering agent powered by codex-1, a version of o3 optimized for coding tasks",
|
||||
},
|
||||
"gpt-4": {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 8_192,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 60,
|
||||
outputPrice: 120,
|
||||
supportsTemperature: true,
|
||||
description: "GPT-4",
|
||||
},
|
||||
"gpt-4-32k": {
|
||||
maxTokens: 32_768,
|
||||
contextWindow: 32_768,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 60,
|
||||
outputPrice: 120,
|
||||
supportsTemperature: true,
|
||||
description: "GPT-4 32K",
|
||||
},
|
||||
"gpt-4-turbo": {
|
||||
maxTokens: 4_096,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 10,
|
||||
outputPrice: 30,
|
||||
supportsTemperature: true,
|
||||
description: "GPT-4 Turbo",
|
||||
},
|
||||
"gpt-4-turbo-vision": {
|
||||
maxTokens: 4_096,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 10,
|
||||
outputPrice: 30,
|
||||
supportsTemperature: true,
|
||||
description: "GPT-4 Turbo Vision",
|
||||
},
|
||||
"gpt-4.1": {
|
||||
maxTokens: 32_768,
|
||||
contextWindow: 1_047_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 2,
|
||||
outputPrice: 8,
|
||||
cacheReadsPrice: 0.5,
|
||||
supportsTemperature: true,
|
||||
description: "GPT-4.1",
|
||||
},
|
||||
"gpt-4.1-mini": {
|
||||
maxTokens: 32_768,
|
||||
contextWindow: 1_047_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.4,
|
||||
outputPrice: 1.6,
|
||||
cacheReadsPrice: 0.1,
|
||||
supportsTemperature: true,
|
||||
description: "GPT-4.1 mini",
|
||||
},
|
||||
"gpt-4.1-nano": {
|
||||
maxTokens: 32_768,
|
||||
contextWindow: 1_047_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.1,
|
||||
outputPrice: 0.4,
|
||||
cacheReadsPrice: 0.03,
|
||||
supportsTemperature: true,
|
||||
description: "GPT-4.1 nano",
|
||||
},
|
||||
"gpt-4o": {
|
||||
maxTokens: 16_384,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 2.5,
|
||||
outputPrice: 10,
|
||||
cacheReadsPrice: 1.25,
|
||||
supportsTemperature: true,
|
||||
description: "GPT-4o",
|
||||
},
|
||||
"gpt-4o-mini": {
|
||||
maxTokens: 16_384,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.15,
|
||||
outputPrice: 0.6,
|
||||
cacheReadsPrice: 0.08,
|
||||
supportsTemperature: true,
|
||||
description: "GPT-4o mini",
|
||||
},
|
||||
"gpt-5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 272_000,
|
||||
includedTools: ["apply_patch"],
|
||||
excludedTools: ["apply_diff", "write_to_file"],
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoningEffort: ["minimal", "low", "medium", "high"],
|
||||
reasoningEffort: "medium",
|
||||
inputPrice: 1.25,
|
||||
outputPrice: 10,
|
||||
cacheReadsPrice: 0.13,
|
||||
supportsVerbosity: true,
|
||||
supportsTemperature: false,
|
||||
description: "GPT-5: The best model for coding and agentic tasks across domains",
|
||||
},
|
||||
"gpt-5-codex": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 400_000,
|
||||
includedTools: ["apply_patch"],
|
||||
excludedTools: ["apply_diff", "write_to_file"],
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoningEffort: ["low", "medium", "high"],
|
||||
reasoningEffort: "medium",
|
||||
inputPrice: 1.25,
|
||||
outputPrice: 10,
|
||||
cacheReadsPrice: 0.13,
|
||||
supportsTemperature: false,
|
||||
description: "GPT-5-Codex: A version of GPT-5 optimized for agentic coding in Codex",
|
||||
},
|
||||
"gpt-5-mini": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 272_000,
|
||||
includedTools: ["apply_patch"],
|
||||
excludedTools: ["apply_diff", "write_to_file"],
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoningEffort: ["minimal", "low", "medium", "high"],
|
||||
reasoningEffort: "medium",
|
||||
inputPrice: 0.25,
|
||||
outputPrice: 2,
|
||||
cacheReadsPrice: 0.03,
|
||||
supportsVerbosity: true,
|
||||
supportsTemperature: false,
|
||||
description: "GPT-5 Mini: A faster, more cost-efficient version of GPT-5 for well-defined tasks",
|
||||
},
|
||||
"gpt-5-nano": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 272_000,
|
||||
includedTools: ["apply_patch"],
|
||||
excludedTools: ["apply_diff", "write_to_file"],
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoningEffort: ["minimal", "low", "medium", "high"],
|
||||
reasoningEffort: "medium",
|
||||
inputPrice: 0.05,
|
||||
outputPrice: 0.4,
|
||||
cacheReadsPrice: 0.01,
|
||||
supportsVerbosity: true,
|
||||
supportsTemperature: false,
|
||||
description: "GPT-5 Nano: Fastest, most cost-efficient version of GPT-5",
|
||||
},
|
||||
"gpt-5-pro": {
|
||||
maxTokens: 272_000,
|
||||
contextWindow: 400_000,
|
||||
includedTools: ["apply_patch"],
|
||||
excludedTools: ["apply_diff", "write_to_file"],
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
supportsReasoningEffort: ["minimal", "low", "medium", "high"],
|
||||
reasoningEffort: "medium",
|
||||
inputPrice: 15,
|
||||
outputPrice: 120,
|
||||
supportsVerbosity: true,
|
||||
supportsTemperature: false,
|
||||
description: "GPT-5 Pro",
|
||||
},
|
||||
"gpt-5.1": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 272_000,
|
||||
includedTools: ["apply_patch"],
|
||||
excludedTools: ["apply_diff", "write_to_file"],
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
promptCacheRetention: "24h",
|
||||
supportsReasoningEffort: ["none", "low", "medium", "high"],
|
||||
reasoningEffort: "medium",
|
||||
inputPrice: 1.25,
|
||||
outputPrice: 10,
|
||||
cacheReadsPrice: 0.125,
|
||||
supportsVerbosity: true,
|
||||
supportsTemperature: false,
|
||||
description: "GPT-5.1: The best model for coding and agentic tasks across domains",
|
||||
},
|
||||
"gpt-5.1-chat": {
|
||||
maxTokens: 16_384,
|
||||
contextWindow: 128_000,
|
||||
includedTools: ["apply_patch"],
|
||||
excludedTools: ["apply_diff", "write_to_file"],
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
promptCacheRetention: "24h",
|
||||
inputPrice: 1.25,
|
||||
outputPrice: 10,
|
||||
cacheReadsPrice: 0.125,
|
||||
supportsTemperature: false,
|
||||
description: "GPT-5.1 Chat: Optimized for conversational AI and chat use cases",
|
||||
},
|
||||
"gpt-5.1-codex": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 400_000,
|
||||
includedTools: ["apply_patch"],
|
||||
excludedTools: ["apply_diff", "write_to_file"],
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
promptCacheRetention: "24h",
|
||||
supportsReasoningEffort: ["low", "medium", "high"],
|
||||
reasoningEffort: "medium",
|
||||
inputPrice: 1.25,
|
||||
outputPrice: 10,
|
||||
cacheReadsPrice: 0.125,
|
||||
supportsTemperature: false,
|
||||
description: "GPT-5.1 Codex: A version of GPT-5.1 optimized for agentic coding in Codex",
|
||||
},
|
||||
"gpt-5.1-codex-max": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 400_000,
|
||||
includedTools: ["apply_patch"],
|
||||
excludedTools: ["apply_diff", "write_to_file"],
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
promptCacheRetention: "24h",
|
||||
supportsReasoningEffort: ["low", "medium", "high", "xhigh"],
|
||||
reasoningEffort: "medium",
|
||||
inputPrice: 1.25,
|
||||
outputPrice: 10,
|
||||
cacheReadsPrice: 0.125,
|
||||
supportsTemperature: false,
|
||||
description:
|
||||
"GPT-5.1 Codex Max: Our most intelligent coding model optimized for long-horizon, agentic coding tasks",
|
||||
},
|
||||
"gpt-5.1-codex-mini": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 400_000,
|
||||
includedTools: ["apply_patch"],
|
||||
excludedTools: ["apply_diff", "write_to_file"],
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
promptCacheRetention: "24h",
|
||||
supportsReasoningEffort: ["low", "medium", "high"],
|
||||
reasoningEffort: "medium",
|
||||
inputPrice: 0.25,
|
||||
outputPrice: 2,
|
||||
cacheReadsPrice: 0.025,
|
||||
supportsTemperature: false,
|
||||
description: "GPT-5.1 Codex mini: A version of GPT-5.1 optimized for agentic coding in Codex",
|
||||
},
|
||||
"gpt-5.2": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 400_000,
|
||||
includedTools: ["apply_patch"],
|
||||
excludedTools: ["apply_diff", "write_to_file"],
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
promptCacheRetention: "24h",
|
||||
supportsReasoningEffort: ["none", "low", "medium", "high", "xhigh"],
|
||||
reasoningEffort: "medium",
|
||||
inputPrice: 1.75,
|
||||
outputPrice: 14,
|
||||
cacheReadsPrice: 0.125,
|
||||
supportsVerbosity: true,
|
||||
supportsTemperature: false,
|
||||
description: "GPT-5.2: Our flagship model for coding and agentic tasks across industries",
|
||||
},
|
||||
"gpt-5.2-chat": {
|
||||
maxTokens: 16_384,
|
||||
contextWindow: 128_000,
|
||||
includedTools: ["apply_patch"],
|
||||
excludedTools: ["apply_diff", "write_to_file"],
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 1.75,
|
||||
outputPrice: 14,
|
||||
cacheReadsPrice: 0.175,
|
||||
supportsTemperature: false,
|
||||
description: "GPT-5.2 Chat: Optimized for conversational AI and chat use cases",
|
||||
},
|
||||
"gpt-5.2-codex": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 400_000,
|
||||
includedTools: ["apply_patch"],
|
||||
excludedTools: ["apply_diff", "write_to_file"],
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
promptCacheRetention: "24h",
|
||||
supportsReasoningEffort: ["low", "medium", "high", "xhigh"],
|
||||
reasoningEffort: "medium",
|
||||
inputPrice: 1.75,
|
||||
outputPrice: 14,
|
||||
cacheReadsPrice: 0.175,
|
||||
supportsTemperature: false,
|
||||
description:
|
||||
"GPT-5.2 Codex: Our most intelligent coding model optimized for long-horizon, agentic coding tasks",
|
||||
},
|
||||
o1: {
|
||||
maxTokens: 100_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 15,
|
||||
outputPrice: 60,
|
||||
cacheReadsPrice: 7.5,
|
||||
supportsTemperature: false,
|
||||
description: "o1",
|
||||
},
|
||||
"o1-mini": {
|
||||
maxTokens: 65_536,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 1.1,
|
||||
outputPrice: 4.4,
|
||||
cacheReadsPrice: 0.55,
|
||||
supportsTemperature: false,
|
||||
description: "o1-mini",
|
||||
},
|
||||
"o1-preview": {
|
||||
maxTokens: 32_768,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 16.5,
|
||||
outputPrice: 66,
|
||||
cacheReadsPrice: 8.25,
|
||||
supportsTemperature: false,
|
||||
description: "o1-preview",
|
||||
},
|
||||
o3: {
|
||||
maxTokens: 100_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoningEffort: ["low", "medium", "high"],
|
||||
reasoningEffort: "medium",
|
||||
inputPrice: 2,
|
||||
outputPrice: 8,
|
||||
cacheReadsPrice: 0.5,
|
||||
supportsTemperature: false,
|
||||
description: "o3",
|
||||
},
|
||||
"o3-mini": {
|
||||
maxTokens: 100_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoningEffort: ["low", "medium", "high"],
|
||||
reasoningEffort: "medium",
|
||||
inputPrice: 1.1,
|
||||
outputPrice: 4.4,
|
||||
cacheReadsPrice: 0.55,
|
||||
supportsTemperature: false,
|
||||
description: "o3-mini",
|
||||
},
|
||||
"o4-mini": {
|
||||
maxTokens: 100_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoningEffort: ["low", "medium", "high"],
|
||||
reasoningEffort: "medium",
|
||||
inputPrice: 1.1,
|
||||
outputPrice: 4.4,
|
||||
cacheReadsPrice: 0.28,
|
||||
supportsTemperature: false,
|
||||
description: "o4-mini",
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
export type AzureModelId = keyof typeof azureModels
|
||||
|
||||
export const azureDefaultModelId: AzureModelId = "gpt-4o"
|
||||
|
||||
export const azureDefaultModelInfo: ModelInfo = azureModels[azureDefaultModelId]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
import type { ModelInfo } from "../model.js"
|
||||
|
||||
// https://inference-docs.cerebras.ai/api-reference/chat-completions
|
||||
export type CerebrasModelId = keyof typeof cerebrasModels
|
||||
|
||||
export const cerebrasDefaultModelId: CerebrasModelId = "gpt-oss-120b"
|
||||
|
||||
export const cerebrasModels = {
|
||||
"zai-glm-4.7": {
|
||||
maxTokens: 16384, // Conservative default to avoid premature rate limiting (Cerebras reserves quota upfront)
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
supportsTemperature: true,
|
||||
defaultTemperature: 1.0,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description:
|
||||
"Highly capable general-purpose model on Cerebras (up to 1,000 tokens/s), competitive with leading proprietary models on coding tasks.",
|
||||
},
|
||||
"qwen-3-235b-a22b-instruct-2507": {
|
||||
maxTokens: 16384, // Conservative default to avoid premature rate limiting
|
||||
contextWindow: 64000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Intelligent model with ~1400 tokens/s",
|
||||
},
|
||||
"llama-3.3-70b": {
|
||||
maxTokens: 16384, // Conservative default to avoid premature rate limiting
|
||||
contextWindow: 64000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Powerful model with ~2600 tokens/s",
|
||||
},
|
||||
"qwen-3-32b": {
|
||||
maxTokens: 16384, // Conservative default to avoid premature rate limiting
|
||||
contextWindow: 64000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "SOTA coding performance with ~2500 tokens/s",
|
||||
},
|
||||
"gpt-oss-120b": {
|
||||
maxTokens: 16384, // Conservative default to avoid premature rate limiting
|
||||
contextWindow: 64000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description:
|
||||
"OpenAI GPT OSS model with ~2800 tokens/s\n\n• 64K context window\n• Excels at efficient reasoning across science, math, and coding",
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
|
@ -1,421 +0,0 @@
|
|||
import type { ModelInfo } from "../model.js"
|
||||
|
||||
// https://llm.chutes.ai/v1 (OpenAI compatible)
|
||||
export type ChutesModelId =
|
||||
| "deepseek-ai/DeepSeek-R1-0528"
|
||||
| "deepseek-ai/DeepSeek-R1"
|
||||
| "deepseek-ai/DeepSeek-V3"
|
||||
| "deepseek-ai/DeepSeek-V3.1"
|
||||
| "deepseek-ai/DeepSeek-V3.1-Terminus"
|
||||
| "deepseek-ai/DeepSeek-V3.1-turbo"
|
||||
| "deepseek-ai/DeepSeek-V3.2-Exp"
|
||||
| "unsloth/Llama-3.3-70B-Instruct"
|
||||
| "chutesai/Llama-4-Scout-17B-16E-Instruct"
|
||||
| "unsloth/Mistral-Nemo-Instruct-2407"
|
||||
| "unsloth/gemma-3-12b-it"
|
||||
| "NousResearch/DeepHermes-3-Llama-3-8B-Preview"
|
||||
| "unsloth/gemma-3-4b-it"
|
||||
| "nvidia/Llama-3_3-Nemotron-Super-49B-v1"
|
||||
| "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1"
|
||||
| "chutesai/Llama-4-Maverick-17B-128E-Instruct-FP8"
|
||||
| "deepseek-ai/DeepSeek-V3-Base"
|
||||
| "deepseek-ai/DeepSeek-R1-Zero"
|
||||
| "deepseek-ai/DeepSeek-V3-0324"
|
||||
| "Qwen/Qwen3-235B-A22B"
|
||||
| "Qwen/Qwen3-235B-A22B-Instruct-2507"
|
||||
| "Qwen/Qwen3-32B"
|
||||
| "Qwen/Qwen3-30B-A3B"
|
||||
| "Qwen/Qwen3-14B"
|
||||
| "Qwen/Qwen3-8B"
|
||||
| "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8"
|
||||
| "microsoft/MAI-DS-R1-FP8"
|
||||
| "tngtech/DeepSeek-R1T-Chimera"
|
||||
| "zai-org/GLM-4.5-Air"
|
||||
| "zai-org/GLM-4.5-FP8"
|
||||
| "zai-org/GLM-4.5-turbo"
|
||||
| "zai-org/GLM-4.6-FP8"
|
||||
| "zai-org/GLM-4.6-turbo"
|
||||
| "meituan-longcat/LongCat-Flash-Thinking-FP8"
|
||||
| "moonshotai/Kimi-K2-Instruct-75k"
|
||||
| "moonshotai/Kimi-K2-Instruct-0905"
|
||||
| "Qwen/Qwen3-235B-A22B-Thinking-2507"
|
||||
| "Qwen/Qwen3-Next-80B-A3B-Instruct"
|
||||
| "Qwen/Qwen3-Next-80B-A3B-Thinking"
|
||||
| "Qwen/Qwen3-VL-235B-A22B-Thinking"
|
||||
|
||||
export const chutesDefaultModelId: ChutesModelId = "deepseek-ai/DeepSeek-R1-0528"
|
||||
|
||||
export const chutesModels = {
|
||||
"deepseek-ai/DeepSeek-R1-0528": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 163840,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "DeepSeek R1 0528 model.",
|
||||
},
|
||||
"deepseek-ai/DeepSeek-R1": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 163840,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "DeepSeek R1 model.",
|
||||
},
|
||||
"deepseek-ai/DeepSeek-V3": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 163840,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "DeepSeek V3 model.",
|
||||
},
|
||||
"deepseek-ai/DeepSeek-V3.1": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 163840,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "DeepSeek V3.1 model.",
|
||||
},
|
||||
"deepseek-ai/DeepSeek-V3.1-Terminus": {
|
||||
maxTokens: 163840,
|
||||
contextWindow: 163840,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.23,
|
||||
outputPrice: 0.9,
|
||||
description:
|
||||
"DeepSeek‑V3.1‑Terminus is an update to V3.1 that improves language consistency by reducing CN/EN mix‑ups and eliminating random characters, while strengthening agent capabilities with notably better Code Agent and Search Agent performance.",
|
||||
},
|
||||
"deepseek-ai/DeepSeek-V3.1-turbo": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 163840,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 1.0,
|
||||
outputPrice: 3.0,
|
||||
description:
|
||||
"DeepSeek-V3.1-turbo is an FP8, speculative-decoding turbo variant optimized for ultra-fast single-shot queries (~200 TPS), with outputs close to the originals and solid function calling/reasoning/structured output, priced at $1/M input and $3/M output tokens, using 2× quota per request and not intended for bulk workloads.",
|
||||
},
|
||||
"deepseek-ai/DeepSeek-V3.2-Exp": {
|
||||
maxTokens: 163840,
|
||||
contextWindow: 163840,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.25,
|
||||
outputPrice: 0.35,
|
||||
description:
|
||||
"DeepSeek-V3.2-Exp is an experimental LLM that introduces DeepSeek Sparse Attention to improve long‑context training and inference efficiency while maintaining performance comparable to V3.1‑Terminus.",
|
||||
},
|
||||
"unsloth/Llama-3.3-70B-Instruct": {
|
||||
maxTokens: 32768, // From Groq
|
||||
contextWindow: 131072, // From Groq
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Unsloth Llama 3.3 70B Instruct model.",
|
||||
},
|
||||
"chutesai/Llama-4-Scout-17B-16E-Instruct": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 512000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "ChutesAI Llama 4 Scout 17B Instruct model, 512K context.",
|
||||
},
|
||||
"unsloth/Mistral-Nemo-Instruct-2407": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 128000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Unsloth Mistral Nemo Instruct model.",
|
||||
},
|
||||
"unsloth/gemma-3-12b-it": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Unsloth Gemma 3 12B IT model.",
|
||||
},
|
||||
"NousResearch/DeepHermes-3-Llama-3-8B-Preview": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Nous DeepHermes 3 Llama 3 8B Preview model.",
|
||||
},
|
||||
"unsloth/gemma-3-4b-it": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Unsloth Gemma 3 4B IT model.",
|
||||
},
|
||||
"nvidia/Llama-3_3-Nemotron-Super-49B-v1": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Nvidia Llama 3.3 Nemotron Super 49B model.",
|
||||
},
|
||||
"nvidia/Llama-3_1-Nemotron-Ultra-253B-v1": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Nvidia Llama 3.1 Nemotron Ultra 253B model.",
|
||||
},
|
||||
"chutesai/Llama-4-Maverick-17B-128E-Instruct-FP8": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 256000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "ChutesAI Llama 4 Maverick 17B Instruct FP8 model.",
|
||||
},
|
||||
"deepseek-ai/DeepSeek-V3-Base": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 163840,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "DeepSeek V3 Base model.",
|
||||
},
|
||||
"deepseek-ai/DeepSeek-R1-Zero": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 163840,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "DeepSeek R1 Zero model.",
|
||||
},
|
||||
"deepseek-ai/DeepSeek-V3-0324": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 163840,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "DeepSeek V3 (0324) model.",
|
||||
},
|
||||
"Qwen/Qwen3-235B-A22B-Instruct-2507": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 262144,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Qwen3 235B A22B Instruct 2507 model with 262K context window.",
|
||||
},
|
||||
"Qwen/Qwen3-235B-A22B": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 40960,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Qwen3 235B A22B model.",
|
||||
},
|
||||
"Qwen/Qwen3-32B": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 40960,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Qwen3 32B model.",
|
||||
},
|
||||
"Qwen/Qwen3-30B-A3B": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 40960,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Qwen3 30B A3B model.",
|
||||
},
|
||||
"Qwen/Qwen3-14B": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 40960,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Qwen3 14B model.",
|
||||
},
|
||||
"Qwen/Qwen3-8B": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 40960,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Qwen3 8B model.",
|
||||
},
|
||||
"microsoft/MAI-DS-R1-FP8": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 163840,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Microsoft MAI-DS-R1 FP8 model.",
|
||||
},
|
||||
"tngtech/DeepSeek-R1T-Chimera": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 163840,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "TNGTech DeepSeek R1T Chimera model.",
|
||||
},
|
||||
"zai-org/GLM-4.5-Air": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 151329,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description:
|
||||
"GLM-4.5-Air model with 151,329 token context window and 106B total parameters with 12B activated.",
|
||||
},
|
||||
"zai-org/GLM-4.5-FP8": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description:
|
||||
"GLM-4.5-FP8 model with 128k token context window, optimized for agent-based applications with MoE architecture.",
|
||||
},
|
||||
"zai-org/GLM-4.5-turbo": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 1,
|
||||
outputPrice: 3,
|
||||
description: "GLM-4.5-turbo model with 128K token context window, optimized for fast inference.",
|
||||
},
|
||||
"zai-org/GLM-4.6-FP8": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 202752,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description:
|
||||
"GLM-4.6 introduces major upgrades over GLM-4.5, including a longer 200K-token context window for complex tasks, stronger coding performance in benchmarks and real-world tools (such as Claude Code, Cline, Roo Code, and Kilo Code), improved reasoning with tool use during inference, more capable and efficient agent integration, and refined writing that better matches human style, readability, and natural role-play scenarios.",
|
||||
},
|
||||
"zai-org/GLM-4.6-turbo": {
|
||||
maxTokens: 202752, // From Chutes /v1/models: max_output_length
|
||||
contextWindow: 202752,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 1.15,
|
||||
outputPrice: 3.25,
|
||||
description: "GLM-4.6-turbo model with 200K-token context window, optimized for fast inference.",
|
||||
},
|
||||
"meituan-longcat/LongCat-Flash-Thinking-FP8": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 128000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description:
|
||||
"LongCat Flash Thinking FP8 model with 128K context window, optimized for complex reasoning and coding tasks.",
|
||||
},
|
||||
"Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 262144,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Qwen3 Coder 480B A35B Instruct FP8 model, optimized for coding tasks.",
|
||||
},
|
||||
"moonshotai/Kimi-K2-Instruct-75k": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 75000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.1481,
|
||||
outputPrice: 0.5926,
|
||||
description: "Moonshot AI Kimi K2 Instruct model with 75k context window.",
|
||||
},
|
||||
"moonshotai/Kimi-K2-Instruct-0905": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 262144,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.1999,
|
||||
outputPrice: 0.8001,
|
||||
description: "Moonshot AI Kimi K2 Instruct 0905 model with 256k context window.",
|
||||
},
|
||||
"Qwen/Qwen3-235B-A22B-Thinking-2507": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 262144,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.077968332,
|
||||
outputPrice: 0.31202496,
|
||||
description: "Qwen3 235B A22B Thinking 2507 model with 262K context window.",
|
||||
},
|
||||
"Qwen/Qwen3-Next-80B-A3B-Instruct": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description:
|
||||
"Fast, stable instruction-tuned model optimized for complex tasks, RAG, and tool use without thinking traces.",
|
||||
},
|
||||
"Qwen/Qwen3-Next-80B-A3B-Thinking": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description:
|
||||
"Reasoning-first model with structured thinking traces for multi-step problems, math proofs, and code synthesis.",
|
||||
},
|
||||
"Qwen/Qwen3-VL-235B-A22B-Thinking": {
|
||||
maxTokens: 262144,
|
||||
contextWindow: 262144,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.16,
|
||||
outputPrice: 0.65,
|
||||
description:
|
||||
"Qwen3‑VL‑235B‑A22B‑Thinking is an open‑weight MoE vision‑language model (235B total, ~22B activated) optimized for deliberate multi‑step reasoning with strong text‑image‑video understanding and long‑context capabilities.",
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
export const chutesDefaultModelInfo: ModelInfo = chutesModels[chutesDefaultModelId]
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import type { ModelInfo } from "../model.js"
|
||||
|
||||
// Default fallback values for DeepInfra when model metadata is not yet loaded.
|
||||
export const deepInfraDefaultModelId = "Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo"
|
||||
|
||||
export const deepInfraDefaultModelInfo: ModelInfo = {
|
||||
maxTokens: 16384,
|
||||
contextWindow: 262144,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.3,
|
||||
outputPrice: 1.2,
|
||||
description: "Qwen 3 Coder 480B A35B Instruct Turbo model, 256K context.",
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import type { ModelInfo } from "../model.js"
|
||||
|
||||
export const doubaoDefaultModelId = "doubao-seed-1-6-250615"
|
||||
|
||||
export const doubaoModels = {
|
||||
"doubao-seed-1-6-250615": {
|
||||
maxTokens: 32_768,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.0001, // $0.0001 per million tokens (cache miss)
|
||||
outputPrice: 0.0004, // $0.0004 per million tokens
|
||||
cacheWritesPrice: 0.0001, // $0.0001 per million tokens (cache miss)
|
||||
cacheReadsPrice: 0.00002, // $0.00002 per million tokens (cache hit)
|
||||
description: `Doubao Seed 1.6 is a powerful model designed for high-performance tasks with extensive context handling.`,
|
||||
},
|
||||
"doubao-seed-1-6-thinking-250715": {
|
||||
maxTokens: 32_768,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.0002, // $0.0002 per million tokens
|
||||
outputPrice: 0.0008, // $0.0008 per million tokens
|
||||
cacheWritesPrice: 0.0002, // $0.0002 per million
|
||||
cacheReadsPrice: 0.00004, // $0.00004 per million tokens (cache hit)
|
||||
description: `Doubao Seed 1.6 Thinking is optimized for reasoning tasks, providing enhanced performance in complex problem-solving scenarios.`,
|
||||
},
|
||||
"doubao-seed-1-6-flash-250715": {
|
||||
maxTokens: 32_768,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.00015, // $0.00015 per million tokens
|
||||
outputPrice: 0.0006, // $0.0006 per million tokens
|
||||
cacheWritesPrice: 0.00015, // $0.00015 per million
|
||||
cacheReadsPrice: 0.00003, // $0.00003 per million tokens (cache hit)
|
||||
description: `Doubao Seed 1.6 Flash is tailored for speed and efficiency, making it ideal for applications requiring rapid responses.`,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
export const doubaoDefaultModelInfo: ModelInfo = doubaoModels[doubaoDefaultModelId]
|
||||
|
||||
export const DOUBAO_API_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
export const DOUBAO_API_CHAT_PATH = "/chat/completions"
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import type { ModelInfo } from "../model.js"
|
||||
|
||||
export type FeatherlessModelId =
|
||||
| "deepseek-ai/DeepSeek-V3-0324"
|
||||
| "deepseek-ai/DeepSeek-R1-0528"
|
||||
| "moonshotai/Kimi-K2-Instruct"
|
||||
| "openai/gpt-oss-120b"
|
||||
| "Qwen/Qwen3-Coder-480B-A35B-Instruct"
|
||||
|
||||
export const featherlessModels = {
|
||||
"deepseek-ai/DeepSeek-V3-0324": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 32678,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "DeepSeek V3 0324 model.",
|
||||
},
|
||||
"deepseek-ai/DeepSeek-R1-0528": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 32678,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "DeepSeek R1 0528 model.",
|
||||
},
|
||||
"moonshotai/Kimi-K2-Instruct": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 32678,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Kimi K2 Instruct model.",
|
||||
},
|
||||
"openai/gpt-oss-120b": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 32678,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "GPT-OSS 120B model.",
|
||||
},
|
||||
"Qwen/Qwen3-Coder-480B-A35B-Instruct": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 32678,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Qwen3 Coder 480B A35B Instruct model.",
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
export const featherlessDefaultModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct"
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
import type { ModelInfo } from "../model.js"
|
||||
|
||||
// https://console.groq.com/docs/models
|
||||
export type GroqModelId =
|
||||
| "llama-3.1-8b-instant"
|
||||
| "llama-3.3-70b-versatile"
|
||||
| "meta-llama/llama-4-scout-17b-16e-instruct"
|
||||
| "qwen/qwen3-32b"
|
||||
| "moonshotai/kimi-k2-instruct-0905"
|
||||
| "openai/gpt-oss-120b"
|
||||
| "openai/gpt-oss-20b"
|
||||
|
||||
export const groqDefaultModelId: GroqModelId = "moonshotai/kimi-k2-instruct-0905"
|
||||
|
||||
export const groqModels = {
|
||||
// Models based on API response: https://api.groq.com/openai/v1/models
|
||||
"llama-3.1-8b-instant": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.05,
|
||||
outputPrice: 0.08,
|
||||
description: "Meta Llama 3.1 8B Instant model, 128K context.",
|
||||
},
|
||||
"llama-3.3-70b-versatile": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.59,
|
||||
outputPrice: 0.79,
|
||||
description: "Meta Llama 3.3 70B Versatile model, 128K context.",
|
||||
},
|
||||
"meta-llama/llama-4-scout-17b-16e-instruct": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.11,
|
||||
outputPrice: 0.34,
|
||||
description: "Meta Llama 4 Scout 17B Instruct model, 128K context.",
|
||||
},
|
||||
"qwen/qwen3-32b": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.29,
|
||||
outputPrice: 0.59,
|
||||
description: "Alibaba Qwen 3 32B model, 128K context.",
|
||||
},
|
||||
"moonshotai/kimi-k2-instruct-0905": {
|
||||
maxTokens: 16384,
|
||||
contextWindow: 262144,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.6,
|
||||
outputPrice: 2.5,
|
||||
cacheReadsPrice: 0.15,
|
||||
description:
|
||||
"Kimi K2 model gets a new version update: Agentic coding: more accurate, better generalization across scaffolds. Frontend coding: improved aesthetics and functionalities on web, 3d, and other tasks. Context length: extended from 128k to 256k, providing better long-horizon support.",
|
||||
},
|
||||
"openai/gpt-oss-120b": {
|
||||
maxTokens: 32766,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.15,
|
||||
outputPrice: 0.75,
|
||||
description:
|
||||
"GPT-OSS 120B is OpenAI's flagship open source model, built on a Mixture-of-Experts (MoE) architecture with 20 billion parameters and 128 experts.",
|
||||
},
|
||||
"openai/gpt-oss-20b": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.1,
|
||||
outputPrice: 0.5,
|
||||
description:
|
||||
"GPT-OSS 20B is OpenAI's flagship open source model, built on a Mixture-of-Experts (MoE) architecture with 20 billion parameters and 32 experts.",
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
/**
|
||||
* HuggingFace provider constants
|
||||
*/
|
||||
|
||||
// Default values for HuggingFace models
|
||||
export const HUGGINGFACE_DEFAULT_MAX_TOKENS = 2048
|
||||
export const HUGGINGFACE_MAX_TOKENS_FALLBACK = 8192
|
||||
export const HUGGINGFACE_DEFAULT_CONTEXT_WINDOW = 128_000
|
||||
|
||||
// UI constants
|
||||
export const HUGGINGFACE_SLIDER_STEP = 256
|
||||
export const HUGGINGFACE_SLIDER_MIN = 1
|
||||
export const HUGGINGFACE_TEMPERATURE_MAX_VALUE = 2
|
||||
|
||||
// API constants
|
||||
export const HUGGINGFACE_API_URL = "https://router.huggingface.co/v1/models?collection=roocode"
|
||||
export const HUGGINGFACE_CACHE_DURATION = 1000 * 60 * 60 // 1 hour
|
||||
|
|
@ -1,16 +1,10 @@
|
|||
export * from "./anthropic.js"
|
||||
export * from "./azure.js"
|
||||
export * from "./baseten.js"
|
||||
export * from "./bedrock.js"
|
||||
export * from "./cerebras.js"
|
||||
export * from "./chutes.js"
|
||||
export * from "./deepseek.js"
|
||||
export * from "./doubao.js"
|
||||
export * from "./featherless.js"
|
||||
export * from "./fireworks.js"
|
||||
export * from "./gemini.js"
|
||||
export * from "./groq.js"
|
||||
export * from "./huggingface.js"
|
||||
export * from "./io-intelligence.js"
|
||||
export * from "./lite-llm.js"
|
||||
export * from "./lm-studio.js"
|
||||
export * from "./mistral.js"
|
||||
|
|
@ -24,27 +18,20 @@ export * from "./qwen-code.js"
|
|||
export * from "./requesty.js"
|
||||
export * from "./roo.js"
|
||||
export * from "./sambanova.js"
|
||||
export * from "./unbound.js"
|
||||
export * from "./vertex.js"
|
||||
export * from "./vscode-llm.js"
|
||||
export * from "./xai.js"
|
||||
export * from "./vercel-ai-gateway.js"
|
||||
export * from "./zai.js"
|
||||
export * from "./deepinfra.js"
|
||||
export * from "./minimax.js"
|
||||
|
||||
import { anthropicDefaultModelId } from "./anthropic.js"
|
||||
import { azureDefaultModelId } from "./azure.js"
|
||||
import { basetenDefaultModelId } from "./baseten.js"
|
||||
import { bedrockDefaultModelId } from "./bedrock.js"
|
||||
import { cerebrasDefaultModelId } from "./cerebras.js"
|
||||
import { chutesDefaultModelId } from "./chutes.js"
|
||||
import { deepSeekDefaultModelId } from "./deepseek.js"
|
||||
import { doubaoDefaultModelId } from "./doubao.js"
|
||||
import { featherlessDefaultModelId } from "./featherless.js"
|
||||
import { fireworksDefaultModelId } from "./fireworks.js"
|
||||
import { geminiDefaultModelId } from "./gemini.js"
|
||||
import { groqDefaultModelId } from "./groq.js"
|
||||
import { ioIntelligenceDefaultModelId } from "./io-intelligence.js"
|
||||
import { litellmDefaultModelId } from "./lite-llm.js"
|
||||
import { mistralDefaultModelId } from "./mistral.js"
|
||||
import { moonshotDefaultModelId } from "./moonshot.js"
|
||||
|
|
@ -54,13 +41,11 @@ import { qwenCodeDefaultModelId } from "./qwen-code.js"
|
|||
import { requestyDefaultModelId } from "./requesty.js"
|
||||
import { rooDefaultModelId } from "./roo.js"
|
||||
import { sambaNovaDefaultModelId } from "./sambanova.js"
|
||||
import { unboundDefaultModelId } from "./unbound.js"
|
||||
import { vertexDefaultModelId } from "./vertex.js"
|
||||
import { vscodeLlmDefaultModelId } from "./vscode-llm.js"
|
||||
import { xaiDefaultModelId } from "./xai.js"
|
||||
import { vercelAiGatewayDefaultModelId } from "./vercel-ai-gateway.js"
|
||||
import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId } from "./zai.js"
|
||||
import { deepInfraDefaultModelId } from "./deepinfra.js"
|
||||
import { minimaxDefaultModelId } from "./minimax.js"
|
||||
|
||||
// Import the ProviderName type from provider-settings to avoid duplication
|
||||
|
|
@ -80,18 +65,10 @@ export function getProviderDefaultModelId(
|
|||
return openRouterDefaultModelId
|
||||
case "requesty":
|
||||
return requestyDefaultModelId
|
||||
case "unbound":
|
||||
return unboundDefaultModelId
|
||||
case "litellm":
|
||||
return litellmDefaultModelId
|
||||
case "xai":
|
||||
return xaiDefaultModelId
|
||||
case "groq":
|
||||
return groqDefaultModelId
|
||||
case "huggingface":
|
||||
return "meta-llama/Llama-3.3-70B-Instruct"
|
||||
case "chutes":
|
||||
return chutesDefaultModelId
|
||||
case "baseten":
|
||||
return basetenDefaultModelId
|
||||
case "bedrock":
|
||||
|
|
@ -102,8 +79,6 @@ export function getProviderDefaultModelId(
|
|||
return geminiDefaultModelId
|
||||
case "deepseek":
|
||||
return deepSeekDefaultModelId
|
||||
case "doubao":
|
||||
return doubaoDefaultModelId
|
||||
case "moonshot":
|
||||
return moonshotDefaultModelId
|
||||
case "minimax":
|
||||
|
|
@ -122,26 +97,20 @@ export function getProviderDefaultModelId(
|
|||
return "" // Ollama uses dynamic model selection
|
||||
case "lmstudio":
|
||||
return "" // LMStudio uses dynamic model selection
|
||||
case "deepinfra":
|
||||
return deepInfraDefaultModelId
|
||||
case "vscode-lm":
|
||||
return vscodeLlmDefaultModelId
|
||||
case "cerebras":
|
||||
return cerebrasDefaultModelId
|
||||
case "sambanova":
|
||||
return sambaNovaDefaultModelId
|
||||
case "fireworks":
|
||||
return fireworksDefaultModelId
|
||||
case "featherless":
|
||||
return featherlessDefaultModelId
|
||||
case "io-intelligence":
|
||||
return ioIntelligenceDefaultModelId
|
||||
case "roo":
|
||||
return rooDefaultModelId
|
||||
case "qwen-code":
|
||||
return qwenCodeDefaultModelId
|
||||
case "vercel-ai-gateway":
|
||||
return vercelAiGatewayDefaultModelId
|
||||
case "azure":
|
||||
return azureDefaultModelId
|
||||
case "anthropic":
|
||||
case "gemini-cli":
|
||||
case "fake-ai":
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
import type { ModelInfo } from "../model.js"
|
||||
|
||||
export type IOIntelligenceModelId =
|
||||
| "deepseek-ai/DeepSeek-R1-0528"
|
||||
| "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8"
|
||||
| "Intel/Qwen3-Coder-480B-A35B-Instruct-int4-mixed-ar"
|
||||
| "openai/gpt-oss-120b"
|
||||
|
||||
export const ioIntelligenceDefaultModelId: IOIntelligenceModelId = "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8"
|
||||
|
||||
export const ioIntelligenceDefaultBaseUrl = "https://api.intelligence.io.solutions/api/v1"
|
||||
|
||||
export const IO_INTELLIGENCE_CACHE_DURATION = 1000 * 60 * 60 // 1 hour
|
||||
|
||||
export const ioIntelligenceModels = {
|
||||
"deepseek-ai/DeepSeek-R1-0528": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 128000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
description: "DeepSeek R1 reasoning model",
|
||||
},
|
||||
"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 430000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
description: "Llama 4 Maverick 17B model",
|
||||
},
|
||||
"Intel/Qwen3-Coder-480B-A35B-Instruct-int4-mixed-ar": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 106000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
description: "Qwen3 Coder 480B specialized for coding",
|
||||
},
|
||||
"openai/gpt-oss-120b": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
description: "OpenAI GPT-OSS 120B model",
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -506,9 +506,8 @@ export const openAiModelInfoSaneDefaults: ModelInfo = {
|
|||
outputPrice: 0,
|
||||
}
|
||||
|
||||
// https://learn.microsoft.com/en-us/azure/ai-services/openai/api-version-deprecation
|
||||
// https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#api-specs
|
||||
export const azureOpenAiDefaultApiVersion = "2024-08-01-preview"
|
||||
// https://learn.microsoft.com/en-us/azure/ai-foundry/openai/api-version-lifecycle
|
||||
export const azureOpenAiDefaultApiVersion = "2025-04-01-preview"
|
||||
|
||||
export const OPENAI_NATIVE_DEFAULT_TEMPERATURE = 0
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import type { ModelInfo } from "../model.js"
|
||||
|
||||
export const unboundDefaultModelId = "anthropic/claude-sonnet-4-5"
|
||||
|
||||
export const unboundDefaultModelInfo: ModelInfo = {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
}
|
||||
|
|
@ -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[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -95,6 +95,9 @@ export interface CreateTaskOptions {
|
|||
initialTodos?: TodoItem[]
|
||||
/** Initial status for the task's history item (e.g., "active" for child tasks) */
|
||||
initialStatus?: "active" | "delegated" | "completed"
|
||||
/** Whether to start the task loop immediately (default: true).
|
||||
* When false, the caller must invoke `task.start()` manually. */
|
||||
startTask?: boolean
|
||||
}
|
||||
|
||||
export enum TaskStatus {
|
||||
|
|
@ -154,6 +157,7 @@ export type TaskEvents = {
|
|||
[RooCodeEventName.TaskModeSwitched]: [taskId: string, mode: string]
|
||||
[RooCodeEventName.TaskAskResponded]: []
|
||||
[RooCodeEventName.TaskUserMessage]: [taskId: string]
|
||||
[RooCodeEventName.QueuedMessagesUpdated]: [taskId: string, messages: QueuedMessage[]]
|
||||
|
||||
// Task Analytics
|
||||
[RooCodeEventName.TaskToolFailed]: [taskId: string, tool: ToolName, error: string]
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ export interface ExtensionMessage {
|
|||
| "ollamaModels"
|
||||
| "lmStudioModels"
|
||||
| "vsCodeLmModels"
|
||||
| "huggingFaceModels"
|
||||
| "vsCodeLmApiAvailable"
|
||||
| "updatePrompt"
|
||||
| "systemPrompt"
|
||||
|
|
@ -144,23 +143,6 @@ export interface ExtensionMessage {
|
|||
ollamaModels?: ModelRecord
|
||||
lmStudioModels?: ModelRecord
|
||||
vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]
|
||||
huggingFaceModels?: Array<{
|
||||
id: string
|
||||
object: string
|
||||
created: number
|
||||
owned_by: string
|
||||
providers: Array<{
|
||||
provider: string
|
||||
status: "live" | "staging" | "error"
|
||||
supports_tools?: boolean
|
||||
supports_structured_output?: boolean
|
||||
context_length?: number
|
||||
pricing?: {
|
||||
input: number
|
||||
output: number
|
||||
}
|
||||
}>
|
||||
}>
|
||||
mcpServers?: McpServer[]
|
||||
commits?: GitCommit[]
|
||||
listApiConfig?: ProviderSettingsEntry[]
|
||||
|
|
@ -303,6 +285,7 @@ export type ExtensionState = Pick<
|
|||
| "ttsSpeed"
|
||||
| "soundEnabled"
|
||||
| "soundVolume"
|
||||
| "taskHeaderHighlightEnabled"
|
||||
| "terminalOutputPreviewSize"
|
||||
| "terminalShellIntegrationTimeout"
|
||||
| "terminalShellIntegrationDisabled"
|
||||
|
|
@ -335,7 +318,9 @@ export type ExtensionState = Pick<
|
|||
| "maxGitStatusFiles"
|
||||
| "requestDelaySeconds"
|
||||
| "showWorktreesInHomeScreen"
|
||||
| "disabledTools"
|
||||
> & {
|
||||
lockApiConfigAcrossModes?: boolean
|
||||
version: string
|
||||
clineMessages: ClineMessage[]
|
||||
currentTaskItem?: HistoryItem
|
||||
|
|
@ -467,7 +452,6 @@ export interface WebviewMessage {
|
|||
| "requestRooModels"
|
||||
| "requestRooCreditBalance"
|
||||
| "requestVsCodeLmModels"
|
||||
| "requestHuggingFaceModels"
|
||||
| "openImage"
|
||||
| "saveImage"
|
||||
| "openFile"
|
||||
|
|
@ -524,6 +508,7 @@ export interface WebviewMessage {
|
|||
| "searchFiles"
|
||||
| "toggleApiConfigPin"
|
||||
| "hasOpenedModeSelector"
|
||||
| "lockApiConfigAcrossModes"
|
||||
| "clearCloudAuthSkipModel"
|
||||
| "cloudButtonClicked"
|
||||
| "rooCloudSignIn"
|
||||
|
|
@ -606,6 +591,7 @@ export interface WebviewMessage {
|
|||
| "createSkill"
|
||||
| "deleteSkill"
|
||||
| "moveSkill"
|
||||
| "updateSkillModes"
|
||||
| "openSkillFile"
|
||||
text?: string
|
||||
editedMessageContent?: string
|
||||
|
|
@ -642,9 +628,15 @@ export interface WebviewMessage {
|
|||
payload?: WebViewMessagePayload
|
||||
source?: "global" | "project" | "built-in"
|
||||
skillName?: string // For skill operations (createSkill, deleteSkill, moveSkill, openSkillFile)
|
||||
/** @deprecated Use skillModeSlugs instead */
|
||||
skillMode?: string // For skill operations (current mode restriction)
|
||||
/** @deprecated Use newSkillModeSlugs instead */
|
||||
newSkillMode?: string // For moveSkill (target mode)
|
||||
skillDescription?: string // For createSkill (skill description)
|
||||
/** Mode slugs for skill operations. undefined/empty = any mode */
|
||||
skillModeSlugs?: string[] // For skill operations (mode restrictions)
|
||||
/** Target mode slugs for updateSkillModes */
|
||||
newSkillModeSlugs?: string[] // For updateSkillModes (new mode restrictions)
|
||||
requestId?: string
|
||||
ids?: string[]
|
||||
hasSystemPromptOverride?: boolean
|
||||
|
|
@ -839,6 +831,12 @@ export interface ClineSayTool {
|
|||
startLine?: number
|
||||
}>
|
||||
}>
|
||||
batchDirs?: Array<{
|
||||
path: string
|
||||
recursive: boolean
|
||||
isOutsideWorkspace?: boolean
|
||||
key: string
|
||||
}>
|
||||
question?: string
|
||||
imageData?: string // Base64 encoded image data for generated images
|
||||
// Properties for runSlashCommand tool
|
||||
|
|
|
|||
957
pnpm-lock.yaml
generated
957
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 |
|
|
@ -387,6 +387,7 @@ describe("History resume delegation - parent metadata transitions", () => {
|
|||
|
||||
it("reopenParentFromDelegation emits events in correct order: TaskDelegationCompleted → TaskDelegationResumed", async () => {
|
||||
const emitSpy = vi.fn()
|
||||
const updateTaskHistory = vi.fn().mockResolvedValue([])
|
||||
|
||||
const provider = {
|
||||
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
|
||||
|
|
@ -411,7 +412,7 @@ describe("History resume delegation - parent metadata transitions", () => {
|
|||
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
updateTaskHistory: vi.fn().mockResolvedValue([]),
|
||||
updateTaskHistory,
|
||||
} as unknown as ClineProvider
|
||||
|
||||
vi.mocked(readTaskMessages).mockResolvedValue([])
|
||||
|
|
@ -433,6 +434,92 @@ describe("History resume delegation - parent metadata transitions", () => {
|
|||
const resumedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationResumed)
|
||||
expect(completedIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(resumedIdx).toBeGreaterThan(completedIdx)
|
||||
|
||||
// RPD-05: verify parent metadata persistence happens before TaskDelegationCompleted emit
|
||||
const parentUpdateCallIdx = updateTaskHistory.mock.calls.findIndex((call) => {
|
||||
const item = call[0] as { id?: string; status?: string } | undefined
|
||||
return item?.id === "p3" && item.status === "active"
|
||||
})
|
||||
expect(parentUpdateCallIdx).toBeGreaterThanOrEqual(0)
|
||||
|
||||
const parentUpdateCallOrder = updateTaskHistory.mock.invocationCallOrder[parentUpdateCallIdx]
|
||||
const completedEmitCallOrder = emitSpy.mock.invocationCallOrder[completedIdx]
|
||||
expect(parentUpdateCallOrder).toBeLessThan(completedEmitCallOrder)
|
||||
})
|
||||
|
||||
it("reopenParentFromDelegation continues when overwrite operations fail and still resumes/emits (RPD-06)", async () => {
|
||||
const emitSpy = vi.fn()
|
||||
const parentInstance = {
|
||||
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteClineMessages: vi.fn().mockRejectedValue(new Error("ui overwrite failed")),
|
||||
overwriteApiConversationHistory: vi.fn().mockRejectedValue(new Error("api overwrite failed")),
|
||||
}
|
||||
|
||||
const provider = {
|
||||
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
|
||||
getTaskWithId: vi.fn().mockImplementation(async (id: string) => {
|
||||
if (id === "parent-rpd06") {
|
||||
return {
|
||||
historyItem: {
|
||||
id: "parent-rpd06",
|
||||
status: "delegated",
|
||||
awaitingChildId: "child-rpd06",
|
||||
childIds: ["child-rpd06"],
|
||||
ts: 800,
|
||||
task: "Parent RPD-06",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
historyItem: {
|
||||
id: "child-rpd06",
|
||||
status: "active",
|
||||
ts: 801,
|
||||
task: "Child RPD-06",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
}
|
||||
}),
|
||||
emit: emitSpy,
|
||||
getCurrentTask: vi.fn(() => ({ taskId: "child-rpd06" })),
|
||||
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
|
||||
createTaskWithHistoryItem: vi.fn().mockResolvedValue(parentInstance),
|
||||
updateTaskHistory: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as ClineProvider
|
||||
|
||||
vi.mocked(readTaskMessages).mockResolvedValue([])
|
||||
vi.mocked(readApiMessages).mockResolvedValue([])
|
||||
|
||||
await expect(
|
||||
(ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, {
|
||||
parentTaskId: "parent-rpd06",
|
||||
childTaskId: "child-rpd06",
|
||||
completionResultSummary: "Subtask finished despite overwrite failures",
|
||||
}),
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(parentInstance.overwriteClineMessages).toHaveBeenCalledTimes(1)
|
||||
expect(parentInstance.overwriteApiConversationHistory).toHaveBeenCalledTimes(1)
|
||||
expect(parentInstance.resumeAfterDelegation).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
RooCodeEventName.TaskDelegationCompleted,
|
||||
"parent-rpd06",
|
||||
"child-rpd06",
|
||||
"Subtask finished despite overwrite failures",
|
||||
)
|
||||
expect(emitSpy).toHaveBeenCalledWith(RooCodeEventName.TaskDelegationResumed, "parent-rpd06", "child-rpd06")
|
||||
|
||||
const completedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationCompleted)
|
||||
const resumedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationResumed)
|
||||
expect(completedIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(resumedIdx).toBeGreaterThan(completedIdx)
|
||||
})
|
||||
|
||||
it("reopenParentFromDelegation does NOT emit TaskPaused or TaskUnpaused (new flow only)", async () => {
|
||||
|
|
@ -480,6 +567,162 @@ describe("History resume delegation - parent metadata transitions", () => {
|
|||
expect(eventNames).not.toContain(RooCodeEventName.TaskSpawned)
|
||||
})
|
||||
|
||||
it("reopenParentFromDelegation skips child close when current task differs and still reopens parent (RPD-02)", async () => {
|
||||
const parentInstance = {
|
||||
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
const updateTaskHistory = vi.fn().mockResolvedValue([])
|
||||
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
|
||||
const createTaskWithHistoryItem = vi.fn().mockResolvedValue(parentInstance)
|
||||
|
||||
const provider = {
|
||||
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
|
||||
getTaskWithId: vi.fn().mockImplementation(async (id: string) => {
|
||||
if (id === "parent-rpd02") {
|
||||
return {
|
||||
historyItem: {
|
||||
id: "parent-rpd02",
|
||||
status: "delegated",
|
||||
awaitingChildId: "child-rpd02",
|
||||
childIds: ["child-rpd02"],
|
||||
ts: 600,
|
||||
task: "Parent RPD-02",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
historyItem: {
|
||||
id: "child-rpd02",
|
||||
status: "active",
|
||||
ts: 601,
|
||||
task: "Child RPD-02",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
}
|
||||
}),
|
||||
emit: vi.fn(),
|
||||
getCurrentTask: vi.fn(() => ({ taskId: "different-open-task" })),
|
||||
removeClineFromStack,
|
||||
createTaskWithHistoryItem,
|
||||
updateTaskHistory,
|
||||
} as unknown as ClineProvider
|
||||
|
||||
vi.mocked(readTaskMessages).mockResolvedValue([])
|
||||
vi.mocked(readApiMessages).mockResolvedValue([])
|
||||
|
||||
await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, {
|
||||
parentTaskId: "parent-rpd02",
|
||||
childTaskId: "child-rpd02",
|
||||
completionResultSummary: "Child done without being current",
|
||||
})
|
||||
|
||||
expect(removeClineFromStack).not.toHaveBeenCalled()
|
||||
expect(updateTaskHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "child-rpd02",
|
||||
status: "completed",
|
||||
}),
|
||||
)
|
||||
expect(createTaskWithHistoryItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "parent-rpd02",
|
||||
status: "active",
|
||||
completedByChildId: "child-rpd02",
|
||||
}),
|
||||
{ startTask: false },
|
||||
)
|
||||
expect(parentInstance.resumeAfterDelegation).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("reopenParentFromDelegation logs child status persistence failure and continues reopen flow (RPD-04)", async () => {
|
||||
const logSpy = vi.fn()
|
||||
const emitSpy = vi.fn()
|
||||
const parentInstance = {
|
||||
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
const updateTaskHistory = vi.fn().mockImplementation(async (historyItem: { id?: string }) => {
|
||||
if (historyItem.id === "child-rpd04") {
|
||||
throw new Error("child status persist failed")
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const provider = {
|
||||
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
|
||||
getTaskWithId: vi.fn().mockImplementation(async (id: string) => {
|
||||
if (id === "parent-rpd04") {
|
||||
return {
|
||||
historyItem: {
|
||||
id: "parent-rpd04",
|
||||
status: "delegated",
|
||||
awaitingChildId: "child-rpd04",
|
||||
childIds: ["child-rpd04"],
|
||||
ts: 700,
|
||||
task: "Parent RPD-04",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
historyItem: {
|
||||
id: "child-rpd04",
|
||||
status: "active",
|
||||
ts: 701,
|
||||
task: "Child RPD-04",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
}
|
||||
}),
|
||||
emit: emitSpy,
|
||||
log: logSpy,
|
||||
getCurrentTask: vi.fn(() => ({ taskId: "child-rpd04" })),
|
||||
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
|
||||
createTaskWithHistoryItem: vi.fn().mockResolvedValue(parentInstance),
|
||||
updateTaskHistory,
|
||||
} as unknown as ClineProvider
|
||||
|
||||
vi.mocked(readTaskMessages).mockResolvedValue([])
|
||||
vi.mocked(readApiMessages).mockResolvedValue([])
|
||||
|
||||
await expect(
|
||||
(ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, {
|
||||
parentTaskId: "parent-rpd04",
|
||||
childTaskId: "child-rpd04",
|
||||
completionResultSummary: "Child completion with persistence failure",
|
||||
}),
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"[reopenParentFromDelegation] Failed to persist child completed status for child-rpd04:",
|
||||
),
|
||||
)
|
||||
expect(updateTaskHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "parent-rpd04",
|
||||
status: "active",
|
||||
completedByChildId: "child-rpd04",
|
||||
}),
|
||||
)
|
||||
expect(parentInstance.resumeAfterDelegation).toHaveBeenCalledTimes(1)
|
||||
expect(emitSpy).toHaveBeenCalledWith(RooCodeEventName.TaskDelegationResumed, "parent-rpd04", "child-rpd04")
|
||||
})
|
||||
|
||||
it("handles empty history gracefully when injecting synthetic messages", async () => {
|
||||
const provider = {
|
||||
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
|
|||
const providerEmit = vi.fn()
|
||||
const parentTask = { taskId: "parent-1", emit: vi.fn() } as any
|
||||
|
||||
const childStart = vi.fn()
|
||||
const updateTaskHistory = vi.fn()
|
||||
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
|
||||
const createTask = vi.fn().mockResolvedValue({ taskId: "child-1" })
|
||||
const createTask = vi.fn().mockResolvedValue({ taskId: "child-1", start: childStart })
|
||||
const handleModeSwitch = vi.fn().mockResolvedValue(undefined)
|
||||
const getTaskWithId = vi.fn().mockImplementation(async (id: string) => {
|
||||
if (id === "parent-1") {
|
||||
|
|
@ -62,10 +63,11 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
|
|||
|
||||
// Invariant: parent closed before child creation
|
||||
expect(removeClineFromStack).toHaveBeenCalledTimes(1)
|
||||
// Child task is created with initialStatus: "active" to avoid race conditions
|
||||
// Child task is created with startTask: false and initialStatus: "active"
|
||||
expect(createTask).toHaveBeenCalledWith("Do something", undefined, parentTask, {
|
||||
initialTodos: [],
|
||||
initialStatus: "active",
|
||||
startTask: false,
|
||||
})
|
||||
|
||||
// Metadata persistence - parent gets "delegated" status (child status is set at creation via initialStatus)
|
||||
|
|
@ -83,10 +85,61 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
// child.start() must be called AFTER parent metadata is persisted
|
||||
expect(childStart).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Event emission (provider-level)
|
||||
expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1")
|
||||
|
||||
// Mode switch
|
||||
expect(handleModeSwitch).toHaveBeenCalledWith("code")
|
||||
})
|
||||
|
||||
it("calls child.start() only after parent metadata is persisted (no race condition)", async () => {
|
||||
const callOrder: string[] = []
|
||||
|
||||
const parentTask = { taskId: "parent-1", emit: vi.fn() } as any
|
||||
const childStart = vi.fn(() => callOrder.push("child.start"))
|
||||
|
||||
const updateTaskHistory = vi.fn(async () => {
|
||||
callOrder.push("updateTaskHistory")
|
||||
})
|
||||
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
|
||||
const createTask = vi.fn(async () => {
|
||||
callOrder.push("createTask")
|
||||
return { taskId: "child-1", start: childStart }
|
||||
})
|
||||
const handleModeSwitch = vi.fn().mockResolvedValue(undefined)
|
||||
const getTaskWithId = vi.fn().mockResolvedValue({
|
||||
historyItem: {
|
||||
id: "parent-1",
|
||||
task: "Parent",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
childIds: [],
|
||||
},
|
||||
})
|
||||
|
||||
const provider = {
|
||||
emit: vi.fn(),
|
||||
getCurrentTask: vi.fn(() => parentTask),
|
||||
removeClineFromStack,
|
||||
createTask,
|
||||
getTaskWithId,
|
||||
updateTaskHistory,
|
||||
handleModeSwitch,
|
||||
log: vi.fn(),
|
||||
} as unknown as ClineProvider
|
||||
|
||||
await (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, {
|
||||
parentTaskId: "parent-1",
|
||||
message: "Do something",
|
||||
initialTodos: [],
|
||||
mode: "code",
|
||||
})
|
||||
|
||||
// Verify ordering: createTask → updateTaskHistory → child.start
|
||||
expect(callOrder).toEqual(["createTask", "updateTaskHistory", "child.start"])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
|
||||
import type { ProviderSettings, ModelInfo } from "@roo-code/types"
|
||||
import { isRetiredProvider, type ProviderSettings, type ModelInfo } from "@roo-code/types"
|
||||
|
||||
import { ApiStream } from "./transform/stream"
|
||||
|
||||
import {
|
||||
AnthropicHandler,
|
||||
AwsBedrockHandler,
|
||||
CerebrasHandler,
|
||||
AzureHandler,
|
||||
OpenRouterHandler,
|
||||
VertexHandler,
|
||||
AnthropicVertexHandler,
|
||||
|
|
@ -21,24 +21,16 @@ import {
|
|||
MoonshotHandler,
|
||||
MistralHandler,
|
||||
VsCodeLmHandler,
|
||||
UnboundHandler,
|
||||
RequestyHandler,
|
||||
FakeAIHandler,
|
||||
XAIHandler,
|
||||
GroqHandler,
|
||||
HuggingFaceHandler,
|
||||
ChutesHandler,
|
||||
LiteLLMHandler,
|
||||
QwenCodeHandler,
|
||||
SambaNovaHandler,
|
||||
IOIntelligenceHandler,
|
||||
DoubaoHandler,
|
||||
ZAiHandler,
|
||||
FireworksHandler,
|
||||
RooHandler,
|
||||
FeatherlessHandler,
|
||||
VercelAiGatewayHandler,
|
||||
DeepInfraHandler,
|
||||
MiniMaxHandler,
|
||||
BasetenHandler,
|
||||
} from "./providers"
|
||||
|
|
@ -51,16 +43,13 @@ export interface SingleCompletionHandler {
|
|||
export interface ApiHandlerCreateMessageMetadata {
|
||||
/**
|
||||
* Task ID used for tracking and provider-specific features:
|
||||
* - DeepInfra: Used as prompt_cache_key for caching
|
||||
* - Roo: Sent as X-Roo-Task-ID header
|
||||
* - Requesty: Sent as trace_id
|
||||
* - Unbound: Sent in unbound_metadata
|
||||
*/
|
||||
taskId: string
|
||||
/**
|
||||
* Current mode slug for provider-specific tracking:
|
||||
* - Requesty: Sent in extra metadata
|
||||
* - Unbound: Sent in unbound_metadata
|
||||
*/
|
||||
mode?: string
|
||||
suppressPreviousResponseId?: boolean
|
||||
|
|
@ -117,14 +106,31 @@ export interface ApiHandler {
|
|||
* @returns A promise resolving to the token count
|
||||
*/
|
||||
countTokens(content: Array<Anthropic.Messages.ContentBlockParam>): Promise<number>
|
||||
|
||||
/**
|
||||
* Indicates whether this provider uses the Vercel AI SDK for streaming.
|
||||
* AI SDK providers handle reasoning blocks differently and need to preserve
|
||||
* them in conversation history for proper round-tripping.
|
||||
*
|
||||
* @returns true if the provider uses AI SDK, false otherwise
|
||||
*/
|
||||
isAiSdkProvider(): boolean
|
||||
}
|
||||
|
||||
export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
|
||||
const { apiProvider, ...options } = configuration
|
||||
|
||||
if (apiProvider && isRetiredProvider(apiProvider)) {
|
||||
throw new Error(
|
||||
`Sorry, this provider is no longer supported. We saw very few Roo users actually using it and we need to reduce the surface area of our codebase so we can keep shipping fast and serving our community well in this space. It was a really hard decision but it lets us focus on what matters most to you. It sucks, we know.\n\nPlease select a different provider in your API profile settings.`,
|
||||
)
|
||||
}
|
||||
|
||||
switch (apiProvider) {
|
||||
case "anthropic":
|
||||
return new AnthropicHandler(options)
|
||||
case "azure":
|
||||
return new AzureHandler(options)
|
||||
case "openrouter":
|
||||
return new OpenRouterHandler(options)
|
||||
case "bedrock":
|
||||
|
|
@ -147,8 +153,6 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
|
|||
return new OpenAiNativeHandler(options)
|
||||
case "deepseek":
|
||||
return new DeepSeekHandler(options)
|
||||
case "doubao":
|
||||
return new DoubaoHandler(options)
|
||||
case "qwen-code":
|
||||
return new QwenCodeHandler(options)
|
||||
case "moonshot":
|
||||
|
|
@ -157,40 +161,24 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
|
|||
return new VsCodeLmHandler(options)
|
||||
case "mistral":
|
||||
return new MistralHandler(options)
|
||||
case "unbound":
|
||||
return new UnboundHandler(options)
|
||||
case "requesty":
|
||||
return new RequestyHandler(options)
|
||||
case "fake-ai":
|
||||
return new FakeAIHandler(options)
|
||||
case "xai":
|
||||
return new XAIHandler(options)
|
||||
case "groq":
|
||||
return new GroqHandler(options)
|
||||
case "deepinfra":
|
||||
return new DeepInfraHandler(options)
|
||||
case "huggingface":
|
||||
return new HuggingFaceHandler(options)
|
||||
case "chutes":
|
||||
return new ChutesHandler(options)
|
||||
case "litellm":
|
||||
return new LiteLLMHandler(options)
|
||||
case "cerebras":
|
||||
return new CerebrasHandler(options)
|
||||
case "sambanova":
|
||||
return new SambaNovaHandler(options)
|
||||
case "zai":
|
||||
return new ZAiHandler(options)
|
||||
case "fireworks":
|
||||
return new FireworksHandler(options)
|
||||
case "io-intelligence":
|
||||
return new IOIntelligenceHandler(options)
|
||||
case "roo":
|
||||
// Never throw exceptions from provider constructors
|
||||
// The provider-proxy server will handle authentication and return appropriate error codes
|
||||
return new RooHandler(options)
|
||||
case "featherless":
|
||||
return new FeatherlessHandler(options)
|
||||
case "vercel-ai-gateway":
|
||||
return new VercelAiGatewayHandler(options)
|
||||
case "minimax":
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
431
src/api/providers/__tests__/azure.spec.ts
Normal file
431
src/api/providers/__tests__/azure.spec.ts
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
|
||||
const { mockStreamText, mockGenerateText, mockCreateAzure } = vi.hoisted(() => ({
|
||||
mockStreamText: vi.fn(),
|
||||
mockGenerateText: vi.fn(),
|
||||
mockCreateAzure: vi.fn(() => {
|
||||
// Return a provider function that supports Responses API model creation
|
||||
const mockProvider = vi.fn(() => ({
|
||||
modelId: "gpt-4o",
|
||||
provider: "azure",
|
||||
}))
|
||||
;(mockProvider as any).responses = vi.fn(() => ({
|
||||
modelId: "gpt-4o",
|
||||
provider: "azure.responses",
|
||||
}))
|
||||
return mockProvider
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
...actual,
|
||||
streamText: mockStreamText,
|
||||
generateText: mockGenerateText,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@ai-sdk/azure", () => ({
|
||||
createAzure: mockCreateAzure,
|
||||
}))
|
||||
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
import { AzureHandler } from "../azure"
|
||||
|
||||
describe("AzureHandler", () => {
|
||||
let handler: AzureHandler
|
||||
let mockOptions: ApiHandlerOptions
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockOptions = {
|
||||
azureApiKey: "test-api-key",
|
||||
azureResourceName: "test-resource",
|
||||
azureDeploymentName: "gpt-4o",
|
||||
azureApiVersion: "2024-08-01-preview",
|
||||
}
|
||||
handler = new AzureHandler(mockOptions)
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should initialize with provided options", () => {
|
||||
expect(handler).toBeInstanceOf(AzureHandler)
|
||||
expect(handler.getModel().id).toBe(mockOptions.azureDeploymentName)
|
||||
})
|
||||
|
||||
it("should use apiModelId if azureDeploymentName is not provided", () => {
|
||||
const handlerWithModelId = new AzureHandler({
|
||||
...mockOptions,
|
||||
azureDeploymentName: undefined,
|
||||
apiModelId: "gpt-35-turbo",
|
||||
})
|
||||
expect(handlerWithModelId.getModel().id).toBe("gpt-35-turbo")
|
||||
})
|
||||
|
||||
it("should use empty string if neither azureDeploymentName nor apiModelId is provided", () => {
|
||||
const handlerWithoutModel = new AzureHandler({
|
||||
...mockOptions,
|
||||
azureDeploymentName: undefined,
|
||||
apiModelId: undefined,
|
||||
})
|
||||
expect(handlerWithoutModel.getModel().id).toBe("")
|
||||
})
|
||||
|
||||
it("should use default API version if not provided", () => {
|
||||
const handlerWithoutVersion = new AzureHandler({
|
||||
...mockOptions,
|
||||
azureApiVersion: undefined,
|
||||
})
|
||||
expect(handlerWithoutVersion).toBeInstanceOf(AzureHandler)
|
||||
expect(mockCreateAzure).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ apiVersion: "2025-04-01-preview" }),
|
||||
)
|
||||
})
|
||||
|
||||
it("should normalize query-style API version input", () => {
|
||||
new AzureHandler({
|
||||
...mockOptions,
|
||||
azureApiVersion: " ?api-version=2024-10-21&foo=bar ",
|
||||
})
|
||||
|
||||
expect(mockCreateAzure).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
apiVersion: "2024-10-21",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use default API version when configured value is blank", () => {
|
||||
new AzureHandler({
|
||||
...mockOptions,
|
||||
azureApiVersion: " ",
|
||||
})
|
||||
|
||||
expect(mockCreateAzure).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ apiVersion: "2025-04-01-preview" }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return model info with deployment name as ID", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe(mockOptions.azureDeploymentName)
|
||||
expect(model.info).toBeDefined()
|
||||
})
|
||||
|
||||
it("should include model parameters from getModelParams", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model).toHaveProperty("temperature")
|
||||
expect(model).toHaveProperty("maxTokens")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isAiSdkProvider", () => {
|
||||
it("should return true", () => {
|
||||
expect(handler.isAiSdkProvider()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "Hello!",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
it("should use the Responses API language model", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 1, outputTokens: 1 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
for await (const _chunk of stream) {
|
||||
// exhaust stream
|
||||
}
|
||||
|
||||
expect(mockStreamText).toHaveBeenCalled()
|
||||
const requestOptions = mockStreamText.mock.calls[0][0]
|
||||
expect((requestOptions.model as any).provider).toBe("azure.responses")
|
||||
})
|
||||
|
||||
it("should handle streaming responses", async () => {
|
||||
// Mock the fullStream async generator
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
// Mock usage and providerMetadata promises
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({
|
||||
azure: {
|
||||
promptCacheHitTokens: 2,
|
||||
promptCacheMissTokens: 8,
|
||||
},
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks).toHaveLength(1)
|
||||
expect(textChunks[0].text).toBe("Test response")
|
||||
})
|
||||
|
||||
it("should include usage information", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({
|
||||
azure: {
|
||||
promptCacheHitTokens: 2,
|
||||
promptCacheMissTokens: 8,
|
||||
},
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0].inputTokens).toBe(10)
|
||||
expect(usageChunks[0].outputTokens).toBe(5)
|
||||
})
|
||||
|
||||
it("should include cache metrics in usage information from providerMetadata", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
// Azure provides cache metrics via providerMetadata
|
||||
const mockProviderMetadata = Promise.resolve({
|
||||
azure: {
|
||||
promptCacheHitTokens: 2,
|
||||
promptCacheMissTokens: 8,
|
||||
},
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0].cacheWriteTokens).toBeUndefined()
|
||||
expect(usageChunks[0].cacheReadTokens).toBe(2) // promptCacheHitTokens
|
||||
})
|
||||
|
||||
it("should handle tool calls via tool-input-start/delta/end events", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "tool-input-start", id: "tool-1", toolName: "test_tool" }
|
||||
yield { type: "tool-input-delta", id: "tool-1", delta: '{"arg":' }
|
||||
yield { type: "tool-input-delta", id: "tool-1", delta: '"value"}' }
|
||||
yield { type: "tool-input-end", id: "tool-1" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const toolStartChunks = chunks.filter((chunk) => chunk.type === "tool_call_start")
|
||||
expect(toolStartChunks).toHaveLength(1)
|
||||
expect(toolStartChunks[0].id).toBe("tool-1")
|
||||
expect(toolStartChunks[0].name).toBe("test_tool")
|
||||
|
||||
const toolDeltaChunks = chunks.filter((chunk) => chunk.type === "tool_call_delta")
|
||||
expect(toolDeltaChunks).toHaveLength(2)
|
||||
|
||||
const toolEndChunks = chunks.filter((chunk) => chunk.type === "tool_call_end")
|
||||
expect(toolEndChunks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should handle errors from AI SDK", async () => {
|
||||
const mockError = new Error("API Error")
|
||||
;(mockError as any).name = "AI_APICallError"
|
||||
;(mockError as any).status = 500
|
||||
|
||||
async function* mockFullStream(): AsyncGenerator<any> {
|
||||
yield { type: "text-delta", text: "" }
|
||||
throw mockError
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({}),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
await expect(async () => {
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
}).rejects.toThrow("Azure AI Foundry")
|
||||
})
|
||||
})
|
||||
|
||||
describe("completePrompt", () => {
|
||||
it("should complete a prompt using generateText", async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test completion",
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
|
||||
expect(result).toBe("Test completion")
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "Test prompt",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use configured temperature", async () => {
|
||||
const handlerWithTemp = new AzureHandler({
|
||||
...mockOptions,
|
||||
modelTemperature: 0.7,
|
||||
})
|
||||
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test completion",
|
||||
})
|
||||
|
||||
await handlerWithTemp.completePrompt("Test prompt")
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 0.7,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("tools", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text" as const, text: "Use a tool" }],
|
||||
},
|
||||
]
|
||||
|
||||
it("should pass tools to streamText", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Using tool" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const tools = [
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "test_tool",
|
||||
description: "A test tool",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
arg: { type: "string" },
|
||||
},
|
||||
required: ["arg"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages, {
|
||||
taskId: "test-task",
|
||||
tools,
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.any(Object),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
// npx vitest run src/api/providers/__tests__/baseten.spec.ts
|
||||
|
||||
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
|
||||
const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
|
||||
mockStreamText: vi.fn(),
|
||||
|
|
@ -13,83 +15,81 @@ vi.mock("ai", async (importOriginal) => {
|
|||
}
|
||||
})
|
||||
|
||||
vi.mock("@ai-sdk/cerebras", () => ({
|
||||
createCerebras: vi.fn(() => {
|
||||
// Return a function that returns a mock language model
|
||||
vi.mock("@ai-sdk/baseten", () => ({
|
||||
createBaseten: vi.fn(() => {
|
||||
return vi.fn(() => ({
|
||||
modelId: "llama-3.3-70b",
|
||||
provider: "cerebras",
|
||||
modelId: "zai-org/GLM-4.6",
|
||||
provider: "baseten",
|
||||
}))
|
||||
}),
|
||||
}))
|
||||
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { cerebrasDefaultModelId, cerebrasModels, type CerebrasModelId } from "@roo-code/types"
|
||||
import { basetenDefaultModelId, basetenModels, type BasetenModelId } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
import { CerebrasHandler } from "../cerebras"
|
||||
import { BasetenHandler } from "../baseten"
|
||||
|
||||
describe("CerebrasHandler", () => {
|
||||
let handler: CerebrasHandler
|
||||
describe("BasetenHandler", () => {
|
||||
let handler: BasetenHandler
|
||||
let mockOptions: ApiHandlerOptions
|
||||
|
||||
beforeEach(() => {
|
||||
mockOptions = {
|
||||
cerebrasApiKey: "test-api-key",
|
||||
apiModelId: "llama-3.3-70b" as CerebrasModelId,
|
||||
basetenApiKey: "test-baseten-api-key",
|
||||
apiModelId: "zai-org/GLM-4.6",
|
||||
}
|
||||
handler = new CerebrasHandler(mockOptions)
|
||||
handler = new BasetenHandler(mockOptions)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should initialize with provided options", () => {
|
||||
expect(handler).toBeInstanceOf(CerebrasHandler)
|
||||
expect(handler).toBeInstanceOf(BasetenHandler)
|
||||
expect(handler.getModel().id).toBe(mockOptions.apiModelId)
|
||||
})
|
||||
|
||||
it("should use default model ID if not provided", () => {
|
||||
const handlerWithoutModel = new CerebrasHandler({
|
||||
const handlerWithoutModel = new BasetenHandler({
|
||||
...mockOptions,
|
||||
apiModelId: undefined,
|
||||
})
|
||||
expect(handlerWithoutModel.getModel().id).toBe(cerebrasDefaultModelId)
|
||||
expect(handlerWithoutModel.getModel().id).toBe(basetenDefaultModelId)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return model info for valid model ID", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe(mockOptions.apiModelId)
|
||||
expect(model.info).toBeDefined()
|
||||
expect(model.info.maxTokens).toBe(16384)
|
||||
expect(model.info.contextWindow).toBe(64000)
|
||||
expect(model.info.supportsImages).toBe(false)
|
||||
expect(model.info.supportsPromptCache).toBe(false)
|
||||
it("should return default model when no model is specified", () => {
|
||||
const handlerWithoutModel = new BasetenHandler({
|
||||
basetenApiKey: "test-baseten-api-key",
|
||||
})
|
||||
const model = handlerWithoutModel.getModel()
|
||||
expect(model.id).toBe(basetenDefaultModelId)
|
||||
expect(model.info).toEqual(basetenModels[basetenDefaultModelId])
|
||||
})
|
||||
|
||||
it("should return specified model when valid model is provided", () => {
|
||||
const testModelId: BasetenModelId = "deepseek-ai/DeepSeek-R1"
|
||||
const handlerWithModel = new BasetenHandler({
|
||||
apiModelId: testModelId,
|
||||
basetenApiKey: "test-baseten-api-key",
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.id).toBe(testModelId)
|
||||
expect(model.info).toEqual(basetenModels[testModelId])
|
||||
})
|
||||
|
||||
it("should return provided model ID with default model info if model does not exist", () => {
|
||||
const handlerWithInvalidModel = new CerebrasHandler({
|
||||
const handlerWithInvalidModel = new BasetenHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "invalid-model",
|
||||
})
|
||||
const model = handlerWithInvalidModel.getModel()
|
||||
expect(model.id).toBe("invalid-model") // Returns provided ID
|
||||
expect(model.info).toBeDefined()
|
||||
// Should have the same base properties as default model
|
||||
expect(model.info.contextWindow).toBe(cerebrasModels[cerebrasDefaultModelId].contextWindow)
|
||||
})
|
||||
|
||||
it("should return default model if no model ID is provided", () => {
|
||||
const handlerWithoutModel = new CerebrasHandler({
|
||||
...mockOptions,
|
||||
apiModelId: undefined,
|
||||
})
|
||||
const model = handlerWithoutModel.getModel()
|
||||
expect(model.id).toBe(cerebrasDefaultModelId)
|
||||
expect(model.id).toBe("invalid-model")
|
||||
expect(model.info).toBeDefined()
|
||||
expect(model.info).toBe(basetenModels[basetenDefaultModelId])
|
||||
})
|
||||
|
||||
it("should include model parameters from getModelParams", () => {
|
||||
|
|
@ -114,12 +114,10 @@ describe("CerebrasHandler", () => {
|
|||
]
|
||||
|
||||
it("should handle streaming responses", async () => {
|
||||
// Mock the fullStream async generator
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
yield { type: "text-delta", text: "Test response from Baseten" }
|
||||
}
|
||||
|
||||
// Mock usage promise
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
|
|
@ -139,7 +137,7 @@ describe("CerebrasHandler", () => {
|
|||
expect(chunks.length).toBeGreaterThan(0)
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks).toHaveLength(1)
|
||||
expect(textChunks[0].text).toBe("Test response")
|
||||
expect(textChunks[0].text).toBe("Test response from Baseten")
|
||||
})
|
||||
|
||||
it("should include usage information", async () => {
|
||||
|
|
@ -149,7 +147,7 @@ describe("CerebrasHandler", () => {
|
|||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
outputTokens: 20,
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
|
|
@ -166,28 +164,73 @@ describe("CerebrasHandler", () => {
|
|||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0].inputTokens).toBe(10)
|
||||
expect(usageChunks[0].outputTokens).toBe(5)
|
||||
expect(usageChunks[0].outputTokens).toBe(20)
|
||||
})
|
||||
|
||||
it("should handle reasoning content in streaming responses", async () => {
|
||||
// Mock the fullStream async generator with reasoning content
|
||||
it("should pass correct temperature (0.5 default) to streamText", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "reasoning", text: "Let me think about this..." }
|
||||
yield { type: "reasoning", text: " I'll analyze step by step." }
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
yield { type: "text-delta", text: "Test" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
details: {
|
||||
reasoningTokens: 15,
|
||||
},
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
})
|
||||
|
||||
const handlerWithDefaultTemp = new BasetenHandler({
|
||||
basetenApiKey: "test-key",
|
||||
apiModelId: "zai-org/GLM-4.6",
|
||||
})
|
||||
|
||||
const stream = handlerWithDefaultTemp.createMessage(systemPrompt, messages)
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 0.5,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use user-specified temperature over default", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
})
|
||||
|
||||
const handlerWithCustomTemp = new BasetenHandler({
|
||||
basetenApiKey: "test-key",
|
||||
apiModelId: "zai-org/GLM-4.6",
|
||||
modelTemperature: 0.9,
|
||||
})
|
||||
|
||||
const stream = handlerWithCustomTemp.createMessage(systemPrompt, messages)
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 0.9,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle stream with multiple chunks", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Hello" }
|
||||
yield { type: "text-delta", text: " world" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 5, outputTokens: 10 }),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
|
|
@ -196,133 +239,43 @@ describe("CerebrasHandler", () => {
|
|||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Should have reasoning chunks
|
||||
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
|
||||
expect(reasoningChunks.length).toBe(2)
|
||||
expect(reasoningChunks[0].text).toBe("Let me think about this...")
|
||||
expect(reasoningChunks[1].text).toBe(" I'll analyze step by step.")
|
||||
const textChunks = chunks.filter((c) => c.type === "text")
|
||||
expect(textChunks[0]).toEqual({ type: "text", text: "Hello" })
|
||||
expect(textChunks[1]).toEqual({ type: "text", text: " world" })
|
||||
|
||||
// Should also have text chunks
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks.length).toBe(1)
|
||||
expect(textChunks[0].text).toBe("Test response")
|
||||
const usageChunks = chunks.filter((c) => c.type === "usage")
|
||||
expect(usageChunks[0]).toMatchObject({ type: "usage", inputTokens: 5, outputTokens: 10 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("completePrompt", () => {
|
||||
it("should complete a prompt using generateText", async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test completion",
|
||||
text: "Test completion from Baseten",
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
|
||||
expect(result).toBe("Test completion")
|
||||
expect(result).toBe("Test completion from Baseten")
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "Test prompt",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("processUsageMetrics", () => {
|
||||
it("should correctly process usage metrics", () => {
|
||||
// We need to access the protected method, so we'll create a test subclass
|
||||
class TestCerebrasHandler extends CerebrasHandler {
|
||||
public testProcessUsageMetrics(usage: any) {
|
||||
return this.processUsageMetrics(usage)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestCerebrasHandler(mockOptions)
|
||||
|
||||
const usage = {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
details: {
|
||||
cachedInputTokens: 20,
|
||||
reasoningTokens: 30,
|
||||
},
|
||||
}
|
||||
|
||||
const result = testHandler.testProcessUsageMetrics(usage)
|
||||
|
||||
expect(result.type).toBe("usage")
|
||||
expect(result.inputTokens).toBe(100)
|
||||
expect(result.outputTokens).toBe(50)
|
||||
expect(result.cacheReadTokens).toBe(20)
|
||||
expect(result.reasoningTokens).toBe(30)
|
||||
})
|
||||
|
||||
it("should handle missing cache metrics gracefully", () => {
|
||||
class TestCerebrasHandler extends CerebrasHandler {
|
||||
public testProcessUsageMetrics(usage: any) {
|
||||
return this.processUsageMetrics(usage)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestCerebrasHandler(mockOptions)
|
||||
|
||||
const usage = {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
}
|
||||
|
||||
const result = testHandler.testProcessUsageMetrics(usage)
|
||||
|
||||
expect(result.type).toBe("usage")
|
||||
expect(result.inputTokens).toBe(100)
|
||||
expect(result.outputTokens).toBe(50)
|
||||
expect(result.cacheReadTokens).toBeUndefined()
|
||||
expect(result.reasoningTokens).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getMaxOutputTokens", () => {
|
||||
it("should return maxTokens from model info", () => {
|
||||
class TestCerebrasHandler extends CerebrasHandler {
|
||||
public testGetMaxOutputTokens() {
|
||||
return this.getMaxOutputTokens()
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestCerebrasHandler(mockOptions)
|
||||
const result = testHandler.testGetMaxOutputTokens()
|
||||
|
||||
// llama-3.3-70b maxTokens is 16384
|
||||
expect(result).toBe(16384)
|
||||
})
|
||||
|
||||
it("should use modelMaxTokens when provided", () => {
|
||||
class TestCerebrasHandler extends CerebrasHandler {
|
||||
public testGetMaxOutputTokens() {
|
||||
return this.getMaxOutputTokens()
|
||||
}
|
||||
}
|
||||
|
||||
const customMaxTokens = 5000
|
||||
const testHandler = new TestCerebrasHandler({
|
||||
...mockOptions,
|
||||
modelMaxTokens: customMaxTokens,
|
||||
it("should use default temperature in completePrompt", async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test completion",
|
||||
})
|
||||
|
||||
const result = testHandler.testGetMaxOutputTokens()
|
||||
expect(result).toBe(customMaxTokens)
|
||||
})
|
||||
await handler.completePrompt("Test prompt")
|
||||
|
||||
it("should fall back to modelInfo.maxTokens when modelMaxTokens is not provided", () => {
|
||||
class TestCerebrasHandler extends CerebrasHandler {
|
||||
public testGetMaxOutputTokens() {
|
||||
return this.getMaxOutputTokens()
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestCerebrasHandler(mockOptions)
|
||||
const result = testHandler.testGetMaxOutputTokens()
|
||||
|
||||
// llama-3.3-70b has maxTokens of 16384
|
||||
expect(result).toBe(16384)
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 0.5,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -402,9 +355,6 @@ describe("CerebrasHandler", () => {
|
|||
})
|
||||
|
||||
it("should ignore tool-call events to prevent duplicate tools in UI", async () => {
|
||||
// tool-call events are intentionally ignored because tool-input-start/delta/end
|
||||
// already provide complete tool call information. Emitting tool-call would cause
|
||||
// duplicate tools in the UI for AI SDK providers (e.g., DeepSeek, Moonshot, Cerebras).
|
||||
async function* mockFullStream() {
|
||||
yield {
|
||||
type: "tool-call",
|
||||
|
|
@ -424,32 +374,73 @@ describe("CerebrasHandler", () => {
|
|||
usage: mockUsage,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages, {
|
||||
taskId: "test-task",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
description: "Read a file",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string" } },
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// tool-call events are ignored, so no tool_call chunks should be emitted
|
||||
const toolCallChunks = chunks.filter((c) => c.type === "tool_call")
|
||||
const toolCallChunks = chunks.filter(
|
||||
(c) => c.type === "tool_call_start" || c.type === "tool_call_delta" || c.type === "tool_call_end",
|
||||
)
|
||||
expect(toolCallChunks.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text" as const, text: "Hello!" }],
|
||||
},
|
||||
]
|
||||
|
||||
it("should handle AI SDK errors with handleAiSdkError", async () => {
|
||||
// eslint-disable-next-line require-yield
|
||||
async function* mockFullStream(): AsyncGenerator<any> {
|
||||
throw new Error("API Error")
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
}).rejects.toThrow("Baseten: API Error")
|
||||
})
|
||||
|
||||
it("should preserve status codes in error handling", async () => {
|
||||
const apiError = new Error("Rate limit exceeded")
|
||||
;(apiError as any).status = 429
|
||||
|
||||
// eslint-disable-next-line require-yield
|
||||
async function* mockFullStream(): AsyncGenerator<any> {
|
||||
throw apiError
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
|
||||
try {
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
expect.fail("Should have thrown an error")
|
||||
} catch (error: any) {
|
||||
expect(error.message).toContain("Baseten")
|
||||
expect(error.status).toBe(429)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 +0,0 @@
|
|||
// npx vitest run api/providers/__tests__/chutes.spec.ts
|
||||
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
|
||||
import { chutesDefaultModelId, chutesDefaultModelInfo, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types"
|
||||
|
||||
import { ChutesHandler } from "../chutes"
|
||||
|
||||
// Create mock functions
|
||||
const mockCreate = vi.fn()
|
||||
const mockFetchModel = vi.fn()
|
||||
|
||||
// Mock OpenAI module
|
||||
vi.mock("openai", () => ({
|
||||
default: vi.fn(() => ({
|
||||
chat: {
|
||||
completions: {
|
||||
create: mockCreate,
|
||||
},
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
describe("ChutesHandler", () => {
|
||||
let handler: ChutesHandler
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Set up default mock implementation
|
||||
mockCreate.mockImplementation(async () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "Test response" },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
handler = new ChutesHandler({ chutesApiKey: "test-key" })
|
||||
// Mock fetchModel to return default model
|
||||
mockFetchModel.mockResolvedValue({
|
||||
id: chutesDefaultModelId,
|
||||
info: chutesDefaultModelInfo,
|
||||
})
|
||||
handler.fetchModel = mockFetchModel
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should use the correct Chutes base URL", () => {
|
||||
new ChutesHandler({ chutesApiKey: "test-chutes-api-key" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://llm.chutes.ai/v1" }))
|
||||
})
|
||||
|
||||
it("should use the provided API key", () => {
|
||||
const chutesApiKey = "test-chutes-api-key"
|
||||
new ChutesHandler({ chutesApiKey })
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: chutesApiKey }))
|
||||
})
|
||||
|
||||
it("should handle DeepSeek R1 reasoning format", async () => {
|
||||
// Override the mock for this specific test
|
||||
mockCreate.mockImplementationOnce(async () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "<think>Thinking..." },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "</think>Hello" },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5 },
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }]
|
||||
mockFetchModel.mockResolvedValueOnce({
|
||||
id: "deepseek-ai/DeepSeek-R1-0528",
|
||||
info: { maxTokens: 1024, temperature: 0.7 },
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks).toEqual([
|
||||
{ type: "reasoning", text: "Thinking..." },
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "usage", inputTokens: 10, outputTokens: 5 },
|
||||
])
|
||||
})
|
||||
|
||||
it("should handle non-DeepSeek models", async () => {
|
||||
// Use default mock implementation which returns text content
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }]
|
||||
mockFetchModel.mockResolvedValueOnce({
|
||||
id: "some-other-model",
|
||||
info: { maxTokens: 1024, temperature: 0.7 },
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks).toEqual([
|
||||
{ type: "text", text: "Test response" },
|
||||
{ type: "usage", inputTokens: 10, outputTokens: 5 },
|
||||
])
|
||||
})
|
||||
|
||||
it("should return default model when no model is specified", async () => {
|
||||
const model = await handler.fetchModel()
|
||||
expect(model.id).toBe(chutesDefaultModelId)
|
||||
expect(model.info).toEqual(expect.objectContaining(chutesDefaultModelInfo))
|
||||
})
|
||||
|
||||
it("should return specified model when valid model is provided", async () => {
|
||||
const testModelId = "deepseek-ai/DeepSeek-R1"
|
||||
const handlerWithModel = new ChutesHandler({
|
||||
apiModelId: testModelId,
|
||||
chutesApiKey: "test-chutes-api-key",
|
||||
})
|
||||
// Mock fetchModel for this handler to return the test model from dynamic fetch
|
||||
handlerWithModel.fetchModel = vi.fn().mockResolvedValue({
|
||||
id: testModelId,
|
||||
info: { maxTokens: 32768, contextWindow: 163840, supportsImages: false, supportsPromptCache: false },
|
||||
})
|
||||
const model = await handlerWithModel.fetchModel()
|
||||
expect(model.id).toBe(testModelId)
|
||||
})
|
||||
|
||||
it("completePrompt method should return text from Chutes API", async () => {
|
||||
const expectedResponse = "This is a test response from Chutes"
|
||||
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] })
|
||||
const result = await handler.completePrompt("test prompt")
|
||||
expect(result).toBe(expectedResponse)
|
||||
})
|
||||
|
||||
it("should handle errors in completePrompt", async () => {
|
||||
const errorMessage = "Chutes API error"
|
||||
mockCreate.mockRejectedValueOnce(new Error(errorMessage))
|
||||
await expect(handler.completePrompt("test prompt")).rejects.toThrow(`Chutes completion error: ${errorMessage}`)
|
||||
})
|
||||
|
||||
it("createMessage should yield text content from stream", async () => {
|
||||
const testContent = "This is test content from Chutes stream"
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: { content: testContent } }] },
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({ type: "text", text: testContent })
|
||||
})
|
||||
|
||||
it("createMessage should yield usage data from stream", async () => {
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } },
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({ type: "usage", inputTokens: 10, outputTokens: 20 })
|
||||
})
|
||||
|
||||
it("createMessage should yield tool_call_partial from stream", async () => {
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_123",
|
||||
function: { name: "test_tool", arguments: '{"arg":"value"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({
|
||||
type: "tool_call_partial",
|
||||
index: 0,
|
||||
id: "call_123",
|
||||
name: "test_tool",
|
||||
arguments: '{"arg":"value"}',
|
||||
})
|
||||
})
|
||||
|
||||
it("createMessage should pass tools and tool_choice to API", async () => {
|
||||
const tools = [
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "test_tool",
|
||||
description: "A test tool",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
]
|
||||
const tool_choice = "auto" as const
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi.fn().mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [], { tools, tool_choice, taskId: "test-task-id" })
|
||||
// Consume stream
|
||||
for await (const _ of stream) {
|
||||
// noop
|
||||
}
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools,
|
||||
tool_choice,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should apply DeepSeek default temperature for R1 models", () => {
|
||||
const testModelId = "deepseek-ai/DeepSeek-R1"
|
||||
const handlerWithModel = new ChutesHandler({
|
||||
apiModelId: testModelId,
|
||||
chutesApiKey: "test-chutes-api-key",
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.info.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE)
|
||||
})
|
||||
|
||||
it("should use default temperature for non-DeepSeek models", () => {
|
||||
const testModelId = "unsloth/Llama-3.3-70B-Instruct"
|
||||
const handlerWithModel = new ChutesHandler({
|
||||
apiModelId: testModelId,
|
||||
chutesApiKey: "test-chutes-api-key",
|
||||
})
|
||||
// Note: getModel() returns fallback default without calling fetchModel
|
||||
// Since we haven't called fetchModel, it returns the default chutesDefaultModelId
|
||||
// which is DeepSeek-R1-0528, therefore temperature will be DEEP_SEEK_DEFAULT_TEMPERATURE
|
||||
const model = handlerWithModel.getModel()
|
||||
// The default model is DeepSeek-R1, so it returns DEEP_SEEK_DEFAULT_TEMPERATURE
|
||||
expect(model.info.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,386 +0,0 @@
|
|||
// npx vitest api/providers/__tests__/deepinfra.spec.ts
|
||||
|
||||
import { deepInfraDefaultModelId, deepInfraDefaultModelInfo } from "@roo-code/types"
|
||||
|
||||
const mockCreate = vitest.fn()
|
||||
const mockWithResponse = vitest.fn()
|
||||
|
||||
vitest.mock("openai", () => {
|
||||
const mockConstructor = vitest.fn()
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: mockConstructor.mockImplementation(() => ({
|
||||
chat: {
|
||||
completions: {
|
||||
create: mockCreate.mockImplementation(() => ({
|
||||
withResponse: mockWithResponse,
|
||||
})),
|
||||
},
|
||||
},
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vitest.mock("../fetchers/modelCache", () => ({
|
||||
getModels: vitest.fn().mockResolvedValue({
|
||||
[deepInfraDefaultModelId]: deepInfraDefaultModelInfo,
|
||||
}),
|
||||
getModelsFromCache: vitest.fn().mockReturnValue(undefined),
|
||||
}))
|
||||
|
||||
import OpenAI from "openai"
|
||||
import { DeepInfraHandler } from "../deepinfra"
|
||||
|
||||
describe("DeepInfraHandler", () => {
|
||||
let handler: DeepInfraHandler
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreate.mockClear()
|
||||
mockWithResponse.mockClear()
|
||||
|
||||
handler = new DeepInfraHandler({})
|
||||
})
|
||||
|
||||
it("should use the correct DeepInfra base URL", () => {
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://api.deepinfra.com/v1/openai",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use the provided API key", () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
const deepInfraApiKey = "test-api-key"
|
||||
new DeepInfraHandler({ deepInfraApiKey })
|
||||
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: deepInfraApiKey,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should return default model when no model is specified", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe(deepInfraDefaultModelId)
|
||||
expect(model.info).toEqual(deepInfraDefaultModelInfo)
|
||||
})
|
||||
|
||||
it("createMessage should yield text content from stream", async () => {
|
||||
const testContent = "This is test content"
|
||||
|
||||
mockWithResponse.mockResolvedValueOnce({
|
||||
data: {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
choices: [{ delta: { content: testContent } }],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({
|
||||
type: "text",
|
||||
text: testContent,
|
||||
})
|
||||
})
|
||||
|
||||
it("createMessage should yield reasoning content from stream", async () => {
|
||||
const testReasoning = "Test reasoning content"
|
||||
|
||||
mockWithResponse.mockResolvedValueOnce({
|
||||
data: {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
choices: [{ delta: { reasoning_content: testReasoning } }],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({
|
||||
type: "reasoning",
|
||||
text: testReasoning,
|
||||
})
|
||||
})
|
||||
|
||||
it("createMessage should yield usage data from stream", async () => {
|
||||
mockWithResponse.mockResolvedValueOnce({
|
||||
data: {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
choices: [{ delta: {} }],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 20,
|
||||
prompt_tokens_details: {
|
||||
cache_write_tokens: 15,
|
||||
cached_tokens: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
cacheWriteTokens: 15,
|
||||
cacheReadTokens: 5,
|
||||
totalCost: expect.any(Number),
|
||||
})
|
||||
})
|
||||
|
||||
describe("Native Tool Calling", () => {
|
||||
const testTools = [
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "test_tool",
|
||||
description: "A test tool",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
arg1: { type: "string", description: "First argument" },
|
||||
},
|
||||
required: ["arg1"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
it("should include tools in request when model supports native tools and tools are provided", async () => {
|
||||
mockWithResponse.mockResolvedValueOnce({
|
||||
data: {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const messageGenerator = handler.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
tools: testTools,
|
||||
})
|
||||
await messageGenerator.next()
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "function",
|
||||
function: expect.objectContaining({
|
||||
name: "test_tool",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
// parallel_tool_calls should be true by default when not explicitly set
|
||||
const callArgs = mockCreate.mock.calls[0][0]
|
||||
expect(callArgs).toHaveProperty("parallel_tool_calls", true)
|
||||
})
|
||||
|
||||
it("should include tool_choice when provided", async () => {
|
||||
mockWithResponse.mockResolvedValueOnce({
|
||||
data: {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const messageGenerator = handler.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
tools: testTools,
|
||||
tool_choice: "auto",
|
||||
})
|
||||
await messageGenerator.next()
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tool_choice: "auto",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should always include tools and tool_choice in request (tools are always present after PR #10841)", async () => {
|
||||
mockWithResponse.mockResolvedValueOnce({
|
||||
data: {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const messageGenerator = handler.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
})
|
||||
await messageGenerator.next()
|
||||
|
||||
const callArgs = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0]
|
||||
// Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
|
||||
expect(callArgs).toHaveProperty("tools")
|
||||
expect(callArgs).toHaveProperty("tool_choice")
|
||||
// parallel_tool_calls should be true by default when not explicitly set
|
||||
expect(callArgs).toHaveProperty("parallel_tool_calls", true)
|
||||
})
|
||||
|
||||
it("should yield tool_call_partial chunks during streaming", async () => {
|
||||
mockWithResponse.mockResolvedValueOnce({
|
||||
data: {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_123",
|
||||
function: {
|
||||
name: "test_tool",
|
||||
arguments: '{"arg1":',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
function: {
|
||||
arguments: '"value"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
tools: testTools,
|
||||
})
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks).toContainEqual({
|
||||
type: "tool_call_partial",
|
||||
index: 0,
|
||||
id: "call_123",
|
||||
name: "test_tool",
|
||||
arguments: '{"arg1":',
|
||||
})
|
||||
|
||||
expect(chunks).toContainEqual({
|
||||
type: "tool_call_partial",
|
||||
index: 0,
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
arguments: '"value"}',
|
||||
})
|
||||
})
|
||||
|
||||
it("should set parallel_tool_calls based on metadata", async () => {
|
||||
mockWithResponse.mockResolvedValueOnce({
|
||||
data: {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const messageGenerator = handler.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
tools: testTools,
|
||||
parallelToolCalls: true,
|
||||
})
|
||||
await messageGenerator.next()
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parallel_tool_calls: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("completePrompt", () => {
|
||||
it("should return text from API", async () => {
|
||||
const expectedResponse = "This is a test response"
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: expectedResponse } }],
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("test prompt")
|
||||
expect(result).toBe(expectedResponse)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 +0,0 @@
|
|||
// npx vitest run api/providers/__tests__/featherless.spec.ts
|
||||
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
|
||||
import { type FeatherlessModelId, featherlessDefaultModelId, featherlessModels } from "@roo-code/types"
|
||||
|
||||
import { FeatherlessHandler } from "../featherless"
|
||||
|
||||
// Create mock functions
|
||||
const mockCreate = vi.fn()
|
||||
|
||||
// Mock OpenAI module
|
||||
vi.mock("openai", () => ({
|
||||
default: vi.fn(() => ({
|
||||
chat: {
|
||||
completions: {
|
||||
create: mockCreate,
|
||||
},
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
describe("FeatherlessHandler", () => {
|
||||
let handler: FeatherlessHandler
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Set up default mock implementation
|
||||
mockCreate.mockImplementation(async () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "Test response" },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
handler = new FeatherlessHandler({ featherlessApiKey: "test-key" })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should use the correct Featherless base URL", () => {
|
||||
new FeatherlessHandler({ featherlessApiKey: "test-featherless-api-key" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://api.featherless.ai/v1" }))
|
||||
})
|
||||
|
||||
it("should use the provided API key", () => {
|
||||
const featherlessApiKey = "test-featherless-api-key"
|
||||
new FeatherlessHandler({ featherlessApiKey })
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: featherlessApiKey }))
|
||||
})
|
||||
|
||||
it("should handle reasoning format from models that use <think> tags", async () => {
|
||||
// Override the mock for this specific test
|
||||
mockCreate.mockImplementationOnce(async () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "<think>Thinking..." },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "</think>Hello" },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5 },
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }]
|
||||
vi.spyOn(handler, "getModel").mockReturnValue({
|
||||
id: "some-reasoning-model",
|
||||
info: { maxTokens: 1024, temperature: 0.7 },
|
||||
} as any)
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks[0]).toEqual({ type: "reasoning", text: "Thinking..." })
|
||||
expect(chunks[1]).toEqual({ type: "text", text: "Hello" })
|
||||
expect(chunks[2]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5 })
|
||||
})
|
||||
|
||||
it("should fall back to base provider for non-DeepSeek models", async () => {
|
||||
// Use default mock implementation which returns text content
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }]
|
||||
vi.spyOn(handler, "getModel").mockReturnValue({
|
||||
id: "some-other-model",
|
||||
info: { maxTokens: 1024, temperature: 0.7 },
|
||||
} as any)
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks[0]).toEqual({ type: "text", text: "Test response" })
|
||||
expect(chunks[1]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5 })
|
||||
})
|
||||
|
||||
it("should return default model when no model is specified", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe(featherlessDefaultModelId)
|
||||
expect(model.info).toEqual(expect.objectContaining(featherlessModels[featherlessDefaultModelId]))
|
||||
})
|
||||
|
||||
it("should return specified model when valid model is provided", () => {
|
||||
const testModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct"
|
||||
const handlerWithModel = new FeatherlessHandler({
|
||||
apiModelId: testModelId,
|
||||
featherlessApiKey: "test-featherless-api-key",
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.id).toBe(testModelId)
|
||||
expect(model.info).toEqual(expect.objectContaining(featherlessModels[testModelId]))
|
||||
})
|
||||
|
||||
it("completePrompt method should return text from Featherless API", async () => {
|
||||
const expectedResponse = "This is a test response from Featherless"
|
||||
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] })
|
||||
const result = await handler.completePrompt("test prompt")
|
||||
expect(result).toBe(expectedResponse)
|
||||
})
|
||||
|
||||
it("should handle errors in completePrompt", async () => {
|
||||
const errorMessage = "Featherless API error"
|
||||
mockCreate.mockRejectedValueOnce(new Error(errorMessage))
|
||||
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
|
||||
`Featherless completion error: ${errorMessage}`,
|
||||
)
|
||||
})
|
||||
|
||||
it("createMessage should yield text content from stream", async () => {
|
||||
const testContent = "This is test content from Featherless stream"
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: { content: testContent } }] },
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({ type: "text", text: testContent })
|
||||
})
|
||||
|
||||
it("createMessage should yield usage data from stream", async () => {
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } },
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 })
|
||||
})
|
||||
|
||||
it("createMessage should pass correct parameters to Featherless client", async () => {
|
||||
const modelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct"
|
||||
|
||||
// Clear previous mocks and set up new implementation
|
||||
mockCreate.mockClear()
|
||||
mockCreate.mockImplementationOnce(async () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
// Empty stream for this test
|
||||
},
|
||||
}))
|
||||
|
||||
const handlerWithModel = new FeatherlessHandler({
|
||||
apiModelId: modelId,
|
||||
featherlessApiKey: "test-featherless-api-key",
|
||||
})
|
||||
|
||||
const systemPrompt = "Test system prompt for Featherless"
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for Featherless" }]
|
||||
|
||||
const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages)
|
||||
await messageGenerator.next()
|
||||
|
||||
expect(mockCreate).toHaveBeenCalled()
|
||||
const callArgs = mockCreate.mock.calls[0][0]
|
||||
expect(callArgs.model).toBe(modelId)
|
||||
})
|
||||
|
||||
it("should use default temperature for non-DeepSeek models", () => {
|
||||
const testModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct"
|
||||
const handlerWithModel = new FeatherlessHandler({
|
||||
apiModelId: testModelId,
|
||||
featherlessApiKey: "test-featherless-api-key",
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.info.temperature).toBe(0.5)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
// npx vitest run src/api/providers/__tests__/gemini.spec.ts
|
||||
|
||||
import { NoOutputGeneratedError } from "ai"
|
||||
|
||||
const mockCaptureException = vitest.fn()
|
||||
|
||||
vitest.mock("@roo-code/telemetry", () => ({
|
||||
|
|
@ -10,6 +12,30 @@ vitest.mock("@roo-code/telemetry", () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
// Mock the AI SDK functions
|
||||
const mockStreamText = vitest.fn()
|
||||
const mockGenerateText = vitest.fn()
|
||||
|
||||
vitest.mock("ai", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
...original,
|
||||
streamText: (...args: unknown[]) => mockStreamText(...args),
|
||||
generateText: (...args: unknown[]) => mockGenerateText(...args),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock createGoogleGenerativeAI to capture constructor options
|
||||
const mockCreateGoogleGenerativeAI = vitest.fn().mockReturnValue(() => ({}))
|
||||
|
||||
vitest.mock("@ai-sdk/google", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@ai-sdk/google")>()
|
||||
return {
|
||||
...original,
|
||||
createGoogleGenerativeAI: (...args: unknown[]) => mockCreateGoogleGenerativeAI(...args),
|
||||
}
|
||||
})
|
||||
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { type ModelInfo, geminiDefaultModelId, ApiProviderError } from "@roo-code/types"
|
||||
|
|
@ -25,26 +51,16 @@ describe("GeminiHandler", () => {
|
|||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
mockCaptureException.mockClear()
|
||||
|
||||
// Create mock functions
|
||||
const mockGenerateContentStream = vitest.fn()
|
||||
const mockGenerateContent = vitest.fn()
|
||||
const mockGetGenerativeModel = vitest.fn()
|
||||
mockStreamText.mockClear()
|
||||
mockGenerateText.mockClear()
|
||||
mockCreateGoogleGenerativeAI.mockClear()
|
||||
mockCreateGoogleGenerativeAI.mockReturnValue(() => ({}))
|
||||
|
||||
handler = new GeminiHandler({
|
||||
apiKey: "test-key",
|
||||
apiModelId: GEMINI_MODEL_NAME,
|
||||
geminiApiKey: "test-key",
|
||||
})
|
||||
|
||||
// Replace the client with our mock
|
||||
handler["client"] = {
|
||||
models: {
|
||||
generateContentStream: mockGenerateContentStream,
|
||||
generateContent: mockGenerateContent,
|
||||
getGenerativeModel: mockGetGenerativeModel,
|
||||
},
|
||||
} as any
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
|
|
@ -52,6 +68,37 @@ describe("GeminiHandler", () => {
|
|||
expect(handler["options"].geminiApiKey).toBe("test-key")
|
||||
expect(handler["options"].apiModelId).toBe(GEMINI_MODEL_NAME)
|
||||
})
|
||||
|
||||
it("should pass undefined baseURL when googleGeminiBaseUrl is empty string", () => {
|
||||
mockCreateGoogleGenerativeAI.mockClear()
|
||||
new GeminiHandler({
|
||||
apiModelId: GEMINI_MODEL_NAME,
|
||||
geminiApiKey: "test-key",
|
||||
googleGeminiBaseUrl: "",
|
||||
})
|
||||
expect(mockCreateGoogleGenerativeAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: undefined }))
|
||||
})
|
||||
|
||||
it("should pass undefined baseURL when googleGeminiBaseUrl is not provided", () => {
|
||||
mockCreateGoogleGenerativeAI.mockClear()
|
||||
new GeminiHandler({
|
||||
apiModelId: GEMINI_MODEL_NAME,
|
||||
geminiApiKey: "test-key",
|
||||
})
|
||||
expect(mockCreateGoogleGenerativeAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: undefined }))
|
||||
})
|
||||
|
||||
it("should pass custom baseURL when googleGeminiBaseUrl is a valid URL", () => {
|
||||
mockCreateGoogleGenerativeAI.mockClear()
|
||||
new GeminiHandler({
|
||||
apiModelId: GEMINI_MODEL_NAME,
|
||||
geminiApiKey: "test-key",
|
||||
googleGeminiBaseUrl: "https://custom-gemini.example.com/v1beta",
|
||||
})
|
||||
expect(mockCreateGoogleGenerativeAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseURL: "https://custom-gemini.example.com/v1beta" }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
|
|
@ -69,13 +116,17 @@ describe("GeminiHandler", () => {
|
|||
const systemPrompt = "You are a helpful assistant"
|
||||
|
||||
it("should handle text messages correctly", async () => {
|
||||
// Setup the mock implementation to return an async generator
|
||||
;(handler["client"].models.generateContentStream as any).mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield { text: "Hello" }
|
||||
yield { text: " world!" }
|
||||
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } }
|
||||
},
|
||||
// Setup the mock implementation to return an async generator for fullStream
|
||||
// AI SDK text-delta events have a 'text' property (processAiSdkStreamPart casts to this)
|
||||
const mockFullStream = (async function* () {
|
||||
yield { type: "text-delta", text: "Hello" }
|
||||
yield { type: "text-delta", text: " world!" }
|
||||
})()
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream,
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, mockMessages)
|
||||
|
|
@ -91,21 +142,105 @@ describe("GeminiHandler", () => {
|
|||
expect(chunks[1]).toEqual({ type: "text", text: " world!" })
|
||||
expect(chunks[2]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5 })
|
||||
|
||||
// Verify the call to generateContentStream
|
||||
expect(handler["client"].models.generateContentStream).toHaveBeenCalledWith(
|
||||
// Verify the call to streamText
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: GEMINI_MODEL_NAME,
|
||||
config: expect.objectContaining({
|
||||
temperature: 1,
|
||||
systemInstruction: systemPrompt,
|
||||
}),
|
||||
system: systemPrompt,
|
||||
temperature: 1,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should yield informative message when stream produces no text content", async () => {
|
||||
// Stream with only reasoning (no text-delta) simulates thinking-only response
|
||||
const mockFullStream = (async function* () {
|
||||
yield { type: "reasoning-delta", id: "1", text: "thinking..." }
|
||||
})()
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream,
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 0 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, mockMessages)
|
||||
const chunks = []
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Should have: reasoning chunk, empty-stream informative message, usage
|
||||
const textChunks = chunks.filter((c) => c.type === "text")
|
||||
expect(textChunks).toHaveLength(1)
|
||||
expect(textChunks[0]).toEqual({
|
||||
type: "text",
|
||||
text: "Model returned an empty response. This may be caused by an unsupported thinking configuration or content filtering.",
|
||||
})
|
||||
})
|
||||
|
||||
it("should suppress NoOutputGeneratedError when no text content was yielded", async () => {
|
||||
// Empty stream - nothing yielded at all
|
||||
const mockFullStream = (async function* () {
|
||||
// empty stream
|
||||
})()
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream,
|
||||
usage: Promise.reject(new NoOutputGeneratedError({ message: "No output generated." })),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, mockMessages)
|
||||
const chunks = []
|
||||
|
||||
// Should NOT throw - the error is suppressed
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Should have the informative empty-stream message only (no usage since it errored)
|
||||
const textChunks = chunks.filter((c) => c.type === "text")
|
||||
expect(textChunks).toHaveLength(1)
|
||||
expect(textChunks[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("empty response"),
|
||||
})
|
||||
})
|
||||
|
||||
it("should re-throw NoOutputGeneratedError when text content was yielded", async () => {
|
||||
// Stream yields text content but usage still throws NoOutputGeneratedError (unexpected)
|
||||
const mockFullStream = (async function* () {
|
||||
yield { type: "text-delta", text: "Hello" }
|
||||
})()
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream,
|
||||
usage: Promise.reject(new NoOutputGeneratedError({ message: "No output generated." })),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, mockMessages)
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _chunk of stream) {
|
||||
// consume stream
|
||||
}
|
||||
}).rejects.toThrow()
|
||||
})
|
||||
|
||||
it("should handle API errors", async () => {
|
||||
const mockError = new Error("Gemini API error")
|
||||
;(handler["client"].models.generateContentStream as any).mockRejectedValue(mockError)
|
||||
// eslint-disable-next-line require-yield
|
||||
const mockFullStream = (async function* () {
|
||||
throw mockError
|
||||
})()
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream,
|
||||
usage: Promise.resolve({}),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, mockMessages)
|
||||
|
||||
|
|
@ -119,28 +254,26 @@ describe("GeminiHandler", () => {
|
|||
|
||||
describe("completePrompt", () => {
|
||||
it("should complete prompt successfully", async () => {
|
||||
// Mock the response with text property
|
||||
;(handler["client"].models.generateContent as any).mockResolvedValue({
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test response",
|
||||
providerMetadata: {},
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
expect(result).toBe("Test response")
|
||||
|
||||
// Verify the call to generateContent
|
||||
expect(handler["client"].models.generateContent).toHaveBeenCalledWith({
|
||||
model: GEMINI_MODEL_NAME,
|
||||
contents: [{ role: "user", parts: [{ text: "Test prompt" }] }],
|
||||
config: {
|
||||
httpOptions: undefined,
|
||||
// Verify the call to generateText
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "Test prompt",
|
||||
temperature: 1,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle API errors", async () => {
|
||||
const mockError = new Error("Gemini API error")
|
||||
;(handler["client"].models.generateContent as any).mockRejectedValue(mockError)
|
||||
mockGenerateText.mockRejectedValue(mockError)
|
||||
|
||||
await expect(handler.completePrompt("Test prompt")).rejects.toThrow(
|
||||
t("common:errors.gemini.generate_complete_prompt", { error: "Gemini API error" }),
|
||||
|
|
@ -148,9 +281,9 @@ describe("GeminiHandler", () => {
|
|||
})
|
||||
|
||||
it("should handle empty response", async () => {
|
||||
// Mock the response with empty text
|
||||
;(handler["client"].models.generateContent as any).mockResolvedValue({
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "",
|
||||
providerMetadata: {},
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
|
|
@ -255,7 +388,16 @@ describe("GeminiHandler", () => {
|
|||
|
||||
it("should capture telemetry on createMessage error", async () => {
|
||||
const mockError = new Error("Gemini API error")
|
||||
;(handler["client"].models.generateContentStream as any).mockRejectedValue(mockError)
|
||||
// eslint-disable-next-line require-yield
|
||||
const mockFullStream = (async function* () {
|
||||
throw mockError
|
||||
})()
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream,
|
||||
usage: Promise.resolve({}),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, mockMessages)
|
||||
|
||||
|
|
@ -283,7 +425,7 @@ describe("GeminiHandler", () => {
|
|||
|
||||
it("should capture telemetry on completePrompt error", async () => {
|
||||
const mockError = new Error("Gemini completion error")
|
||||
;(handler["client"].models.generateContent as any).mockRejectedValue(mockError)
|
||||
mockGenerateText.mockRejectedValue(mockError)
|
||||
|
||||
await expect(handler.completePrompt("Test prompt")).rejects.toThrow()
|
||||
|
||||
|
|
@ -305,7 +447,16 @@ describe("GeminiHandler", () => {
|
|||
|
||||
it("should still throw the error after capturing telemetry", async () => {
|
||||
const mockError = new Error("Gemini API error")
|
||||
;(handler["client"].models.generateContentStream as any).mockRejectedValue(mockError)
|
||||
// eslint-disable-next-line require-yield
|
||||
const mockFullStream = (async function* () {
|
||||
throw mockError
|
||||
})()
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream,
|
||||
usage: Promise.resolve({}),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, mockMessages)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,578 +0,0 @@
|
|||
// npx vitest run src/api/providers/__tests__/groq.spec.ts
|
||||
|
||||
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
|
||||
const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
|
||||
mockStreamText: vi.fn(),
|
||||
mockGenerateText: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
...actual,
|
||||
streamText: mockStreamText,
|
||||
generateText: mockGenerateText,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@ai-sdk/groq", () => ({
|
||||
createGroq: vi.fn(() => {
|
||||
// Return a function that returns a mock language model
|
||||
return vi.fn(() => ({
|
||||
modelId: "moonshotai/kimi-k2-instruct-0905",
|
||||
provider: "groq",
|
||||
}))
|
||||
}),
|
||||
}))
|
||||
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { groqDefaultModelId, groqModels, type GroqModelId } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
import { GroqHandler } from "../groq"
|
||||
|
||||
describe("GroqHandler", () => {
|
||||
let handler: GroqHandler
|
||||
let mockOptions: ApiHandlerOptions
|
||||
|
||||
beforeEach(() => {
|
||||
mockOptions = {
|
||||
groqApiKey: "test-groq-api-key",
|
||||
apiModelId: "moonshotai/kimi-k2-instruct-0905",
|
||||
}
|
||||
handler = new GroqHandler(mockOptions)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should initialize with provided options", () => {
|
||||
expect(handler).toBeInstanceOf(GroqHandler)
|
||||
expect(handler.getModel().id).toBe(mockOptions.apiModelId)
|
||||
})
|
||||
|
||||
it("should use default model ID if not provided", () => {
|
||||
const handlerWithoutModel = new GroqHandler({
|
||||
...mockOptions,
|
||||
apiModelId: undefined,
|
||||
})
|
||||
expect(handlerWithoutModel.getModel().id).toBe(groqDefaultModelId)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return default model when no model is specified", () => {
|
||||
const handlerWithoutModel = new GroqHandler({
|
||||
groqApiKey: "test-groq-api-key",
|
||||
})
|
||||
const model = handlerWithoutModel.getModel()
|
||||
expect(model.id).toBe(groqDefaultModelId)
|
||||
expect(model.info).toEqual(groqModels[groqDefaultModelId])
|
||||
})
|
||||
|
||||
it("should return specified model when valid model is provided", () => {
|
||||
const testModelId: GroqModelId = "llama-3.3-70b-versatile"
|
||||
const handlerWithModel = new GroqHandler({
|
||||
apiModelId: testModelId,
|
||||
groqApiKey: "test-groq-api-key",
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.id).toBe(testModelId)
|
||||
expect(model.info).toEqual(groqModels[testModelId])
|
||||
})
|
||||
|
||||
it("should return model info for llama-3.1-8b-instant", () => {
|
||||
const handlerWithLlama = new GroqHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "llama-3.1-8b-instant",
|
||||
})
|
||||
const model = handlerWithLlama.getModel()
|
||||
expect(model.id).toBe("llama-3.1-8b-instant")
|
||||
expect(model.info).toBeDefined()
|
||||
expect(model.info.maxTokens).toBe(8192)
|
||||
expect(model.info.contextWindow).toBe(131072)
|
||||
expect(model.info.supportsImages).toBe(false)
|
||||
expect(model.info.supportsPromptCache).toBe(false)
|
||||
})
|
||||
|
||||
it("should return model info for kimi-k2 which supports prompt cache", () => {
|
||||
const handlerWithKimi = new GroqHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "moonshotai/kimi-k2-instruct-0905",
|
||||
})
|
||||
const model = handlerWithKimi.getModel()
|
||||
expect(model.id).toBe("moonshotai/kimi-k2-instruct-0905")
|
||||
expect(model.info).toBeDefined()
|
||||
expect(model.info.maxTokens).toBe(16384)
|
||||
expect(model.info.contextWindow).toBe(262144)
|
||||
expect(model.info.supportsPromptCache).toBe(true)
|
||||
})
|
||||
|
||||
it("should return provided model ID with default model info if model does not exist", () => {
|
||||
const handlerWithInvalidModel = new GroqHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "invalid-model",
|
||||
})
|
||||
const model = handlerWithInvalidModel.getModel()
|
||||
expect(model.id).toBe("invalid-model")
|
||||
expect(model.info).toBeDefined()
|
||||
// Should use default model info
|
||||
expect(model.info).toBe(groqModels[groqDefaultModelId])
|
||||
})
|
||||
|
||||
it("should include model parameters from getModelParams", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model).toHaveProperty("temperature")
|
||||
expect(model).toHaveProperty("maxTokens")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "Hello!",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
it("should handle streaming responses", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response from Groq" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks).toHaveLength(1)
|
||||
expect(textChunks[0].text).toBe("Test response from Groq")
|
||||
})
|
||||
|
||||
it("should include usage information", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0].inputTokens).toBe(10)
|
||||
expect(usageChunks[0].outputTokens).toBe(20)
|
||||
})
|
||||
|
||||
it("should handle cached tokens in usage data from providerMetadata", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
})
|
||||
|
||||
// Groq provides cache metrics via providerMetadata for supported models
|
||||
const mockProviderMetadata = Promise.resolve({
|
||||
groq: {
|
||||
promptCacheHitTokens: 30,
|
||||
promptCacheMissTokens: 70,
|
||||
},
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0].inputTokens).toBe(100)
|
||||
expect(usageChunks[0].outputTokens).toBe(50)
|
||||
expect(usageChunks[0].cacheReadTokens).toBe(30)
|
||||
expect(usageChunks[0].cacheWriteTokens).toBe(70)
|
||||
})
|
||||
|
||||
it("should handle usage with details.cachedInputTokens when providerMetadata is not available", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
details: {
|
||||
cachedInputTokens: 25,
|
||||
},
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0].cacheReadTokens).toBe(25)
|
||||
expect(usageChunks[0].cacheWriteTokens).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should pass correct temperature (0.5 default) to streamText", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const handlerWithDefaultTemp = new GroqHandler({
|
||||
groqApiKey: "test-key",
|
||||
apiModelId: "llama-3.1-8b-instant",
|
||||
})
|
||||
|
||||
const stream = handlerWithDefaultTemp.createMessage(systemPrompt, messages)
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 0.5,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("completePrompt", () => {
|
||||
it("should complete a prompt using generateText", async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test completion from Groq",
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
|
||||
expect(result).toBe("Test completion from Groq")
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "Test prompt",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use default temperature in completePrompt", async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test completion",
|
||||
})
|
||||
|
||||
await handler.completePrompt("Test prompt")
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 0.5,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("processUsageMetrics", () => {
|
||||
it("should correctly process usage metrics including cache information from providerMetadata", () => {
|
||||
class TestGroqHandler extends GroqHandler {
|
||||
public testProcessUsageMetrics(usage: any, providerMetadata?: any) {
|
||||
return this.processUsageMetrics(usage, providerMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestGroqHandler(mockOptions)
|
||||
|
||||
const usage = {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
}
|
||||
|
||||
const providerMetadata = {
|
||||
groq: {
|
||||
promptCacheHitTokens: 20,
|
||||
promptCacheMissTokens: 80,
|
||||
},
|
||||
}
|
||||
|
||||
const result = testHandler.testProcessUsageMetrics(usage, providerMetadata)
|
||||
|
||||
expect(result.type).toBe("usage")
|
||||
expect(result.inputTokens).toBe(100)
|
||||
expect(result.outputTokens).toBe(50)
|
||||
expect(result.cacheWriteTokens).toBe(80)
|
||||
expect(result.cacheReadTokens).toBe(20)
|
||||
})
|
||||
|
||||
it("should handle missing cache metrics gracefully", () => {
|
||||
class TestGroqHandler extends GroqHandler {
|
||||
public testProcessUsageMetrics(usage: any, providerMetadata?: any) {
|
||||
return this.processUsageMetrics(usage, providerMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestGroqHandler(mockOptions)
|
||||
|
||||
const usage = {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
}
|
||||
|
||||
const result = testHandler.testProcessUsageMetrics(usage)
|
||||
|
||||
expect(result.type).toBe("usage")
|
||||
expect(result.inputTokens).toBe(100)
|
||||
expect(result.outputTokens).toBe(50)
|
||||
expect(result.cacheWriteTokens).toBeUndefined()
|
||||
expect(result.cacheReadTokens).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should include reasoning tokens when provided", () => {
|
||||
class TestGroqHandler extends GroqHandler {
|
||||
public testProcessUsageMetrics(usage: any, providerMetadata?: any) {
|
||||
return this.processUsageMetrics(usage, providerMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestGroqHandler(mockOptions)
|
||||
|
||||
const usage = {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
details: {
|
||||
reasoningTokens: 30,
|
||||
},
|
||||
}
|
||||
|
||||
const result = testHandler.testProcessUsageMetrics(usage)
|
||||
|
||||
expect(result.reasoningTokens).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool handling", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text" as const, text: "Hello!" }],
|
||||
},
|
||||
]
|
||||
|
||||
it("should handle tool calls in streaming", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield {
|
||||
type: "tool-input-start",
|
||||
id: "tool-call-1",
|
||||
toolName: "read_file",
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-delta",
|
||||
id: "tool-call-1",
|
||||
delta: '{"path":"test.ts"}',
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-end",
|
||||
id: "tool-call-1",
|
||||
}
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages, {
|
||||
taskId: "test-task",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
description: "Read a file",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string" } },
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start")
|
||||
const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta")
|
||||
const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end")
|
||||
|
||||
expect(toolCallStartChunks.length).toBe(1)
|
||||
expect(toolCallStartChunks[0].id).toBe("tool-call-1")
|
||||
expect(toolCallStartChunks[0].name).toBe("read_file")
|
||||
|
||||
expect(toolCallDeltaChunks.length).toBe(1)
|
||||
expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}')
|
||||
|
||||
expect(toolCallEndChunks.length).toBe(1)
|
||||
expect(toolCallEndChunks[0].id).toBe("tool-call-1")
|
||||
})
|
||||
|
||||
it("should ignore tool-call events to prevent duplicate tools in UI", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield {
|
||||
type: "tool-call",
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "read_file",
|
||||
input: { path: "test.ts" },
|
||||
}
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages, {
|
||||
taskId: "test-task",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
description: "Read a file",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string" } },
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// tool-call events are ignored, so no tool_call chunks should be emitted
|
||||
const toolCallChunks = chunks.filter((c) => c.type === "tool_call")
|
||||
expect(toolCallChunks.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getMaxOutputTokens", () => {
|
||||
it("should return maxTokens from model info", () => {
|
||||
class TestGroqHandler extends GroqHandler {
|
||||
public testGetMaxOutputTokens() {
|
||||
return this.getMaxOutputTokens()
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestGroqHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "llama-3.1-8b-instant",
|
||||
})
|
||||
const result = testHandler.testGetMaxOutputTokens()
|
||||
|
||||
// llama-3.1-8b-instant has maxTokens of 8192
|
||||
expect(result).toBe(8192)
|
||||
})
|
||||
|
||||
it("should use modelMaxTokens when provided", () => {
|
||||
class TestGroqHandler extends GroqHandler {
|
||||
public testGetMaxOutputTokens() {
|
||||
return this.getMaxOutputTokens()
|
||||
}
|
||||
}
|
||||
|
||||
const customMaxTokens = 5000
|
||||
const testHandler = new TestGroqHandler({
|
||||
...mockOptions,
|
||||
modelMaxTokens: customMaxTokens,
|
||||
})
|
||||
|
||||
const result = testHandler.testGetMaxOutputTokens()
|
||||
expect(result).toBe(customMaxTokens)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,303 +0,0 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { IOIntelligenceHandler } from "../io-intelligence"
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
const mockCreate = vi.fn()
|
||||
|
||||
// Mock OpenAI
|
||||
vi.mock("openai", () => ({
|
||||
default: class MockOpenAI {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
chat = {
|
||||
completions: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
}
|
||||
constructor(options: any) {
|
||||
this.baseURL = options.baseURL
|
||||
this.apiKey = options.apiKey
|
||||
this.chat.completions.create = mockCreate
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the fetcher functions
|
||||
vi.mock("../fetchers/io-intelligence", () => ({
|
||||
getIOIntelligenceModels: vi.fn(),
|
||||
getCachedIOIntelligenceModels: vi.fn(() => ({
|
||||
"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 430000,
|
||||
description: "Llama 4 Maverick 17B model",
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"deepseek-ai/DeepSeek-R1-0528": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 128000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
description: "DeepSeek R1 reasoning model",
|
||||
},
|
||||
"Intel/Qwen3-Coder-480B-A35B-Instruct-int4-mixed-ar": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 106000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
description: "Qwen3 Coder 480B specialized for coding",
|
||||
},
|
||||
"openai/gpt-oss-120b": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
description: "OpenAI GPT-OSS 120B model",
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock constants
|
||||
vi.mock("../constants", () => ({
|
||||
DEFAULT_HEADERS: { "User-Agent": "roo-cline" },
|
||||
}))
|
||||
|
||||
// Mock transform functions
|
||||
vi.mock("../../transform/openai-format", () => ({
|
||||
convertToOpenAiMessages: vi.fn((messages) => messages),
|
||||
}))
|
||||
|
||||
describe("IOIntelligenceHandler", () => {
|
||||
let handler: IOIntelligenceHandler
|
||||
let mockOptions: ApiHandlerOptions
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockOptions = {
|
||||
ioIntelligenceApiKey: "test-api-key",
|
||||
apiModelId: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
|
||||
modelTemperature: 0.7,
|
||||
includeMaxTokens: false,
|
||||
modelMaxTokens: undefined,
|
||||
} as ApiHandlerOptions
|
||||
|
||||
mockCreate.mockImplementation(async () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "Test response" },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
handler = new IOIntelligenceHandler(mockOptions)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should create OpenAI client with correct configuration", () => {
|
||||
const ioIntelligenceApiKey = "test-io-intelligence-api-key"
|
||||
const handler = new IOIntelligenceHandler({ ioIntelligenceApiKey })
|
||||
// Verify that the handler was created successfully
|
||||
expect(handler).toBeInstanceOf(IOIntelligenceHandler)
|
||||
expect(handler["client"]).toBeDefined()
|
||||
// Verify the client has the expected properties
|
||||
expect(handler["client"].baseURL).toBe("https://api.intelligence.io.solutions/api/v1")
|
||||
expect(handler["client"].apiKey).toBe(ioIntelligenceApiKey)
|
||||
})
|
||||
|
||||
it("should initialize with correct configuration", () => {
|
||||
expect(handler).toBeInstanceOf(IOIntelligenceHandler)
|
||||
expect(handler["client"]).toBeDefined()
|
||||
expect(handler["options"]).toEqual({
|
||||
...mockOptions,
|
||||
apiKey: mockOptions.ioIntelligenceApiKey,
|
||||
})
|
||||
})
|
||||
|
||||
it("should throw error when API key is missing", () => {
|
||||
const optionsWithoutKey = { ...mockOptions }
|
||||
delete optionsWithoutKey.ioIntelligenceApiKey
|
||||
|
||||
expect(() => new IOIntelligenceHandler(optionsWithoutKey)).toThrow("IO Intelligence API key is required")
|
||||
})
|
||||
|
||||
it("should handle streaming response correctly", async () => {
|
||||
const mockStream = [
|
||||
{
|
||||
choices: [{ delta: { content: "Hello" } }],
|
||||
usage: null,
|
||||
},
|
||||
{
|
||||
choices: [{ delta: { content: " world" } }],
|
||||
usage: null,
|
||||
},
|
||||
{
|
||||
choices: [{ delta: {} }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5 },
|
||||
},
|
||||
]
|
||||
|
||||
mockCreate.mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
for (const chunk of mockStream) {
|
||||
yield chunk
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const stream = handler.createMessage("System prompt", messages)
|
||||
const results = []
|
||||
|
||||
for await (const chunk of stream) {
|
||||
results.push(chunk)
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(3)
|
||||
expect(results[0]).toEqual({ type: "text", text: "Hello" })
|
||||
expect(results[1]).toEqual({ type: "text", text: " world" })
|
||||
expect(results[2]).toMatchObject({
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it("completePrompt method should return text from IO Intelligence API", async () => {
|
||||
const expectedResponse = "This is a test response from IO Intelligence"
|
||||
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] })
|
||||
const result = await handler.completePrompt("test prompt")
|
||||
expect(result).toBe(expectedResponse)
|
||||
})
|
||||
|
||||
it("should handle errors in completePrompt", async () => {
|
||||
const errorMessage = "IO Intelligence API error"
|
||||
mockCreate.mockRejectedValueOnce(new Error(errorMessage))
|
||||
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
|
||||
`IO Intelligence completion error: ${errorMessage}`,
|
||||
)
|
||||
})
|
||||
|
||||
it("createMessage should yield text content from stream", async () => {
|
||||
const testContent = "This is test content from IO Intelligence stream"
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: { content: testContent } }] },
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({ type: "text", text: testContent })
|
||||
})
|
||||
|
||||
it("createMessage should yield usage data from stream", async () => {
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } },
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 })
|
||||
})
|
||||
|
||||
it("should return model info from cache when available", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8")
|
||||
expect(model.info).toEqual({
|
||||
maxTokens: 8192,
|
||||
contextWindow: 430000,
|
||||
description: "Llama 4 Maverick 17B model",
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should return fallback model info when not in cache", () => {
|
||||
const handlerWithUnknownModel = new IOIntelligenceHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
|
||||
})
|
||||
const model = handlerWithUnknownModel.getModel()
|
||||
expect(model.id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8")
|
||||
expect(model.info).toEqual({
|
||||
maxTokens: 8192,
|
||||
contextWindow: 430000,
|
||||
description: "Llama 4 Maverick 17B model",
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should use default model when no model is specified", () => {
|
||||
const handlerWithoutModel = new IOIntelligenceHandler({
|
||||
...mockOptions,
|
||||
apiModelId: undefined,
|
||||
})
|
||||
const model = handlerWithoutModel.getModel()
|
||||
expect(model.id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8")
|
||||
})
|
||||
|
||||
it("should handle empty response from completePrompt", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: null } }],
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
it("should handle missing choices in completePrompt response", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [],
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
|
|
@ -1,62 +1,69 @@
|
|||
// npx vitest run api/providers/__tests__/lm-studio-timeout.spec.ts
|
||||
|
||||
const { mockCreateOpenAICompatible } = vi.hoisted(() => ({
|
||||
mockCreateOpenAICompatible: vi.fn(() => {
|
||||
return vi.fn(() => ({
|
||||
modelId: "llama2",
|
||||
provider: "lmstudio",
|
||||
}))
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@ai-sdk/openai-compatible", () => ({
|
||||
createOpenAICompatible: mockCreateOpenAICompatible,
|
||||
}))
|
||||
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
...actual,
|
||||
streamText: vi.fn(),
|
||||
generateText: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import { LmStudioHandler } from "../lm-studio"
|
||||
import { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
// Mock the timeout config utility
|
||||
vitest.mock("../utils/timeout-config", () => ({
|
||||
getApiRequestTimeout: vitest.fn(),
|
||||
}))
|
||||
|
||||
import { getApiRequestTimeout } from "../utils/timeout-config"
|
||||
|
||||
// Mock OpenAI
|
||||
const mockOpenAIConstructor = vitest.fn()
|
||||
vitest.mock("openai", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: vitest.fn().mockImplementation((config) => {
|
||||
mockOpenAIConstructor(config)
|
||||
return {
|
||||
chat: {
|
||||
completions: {
|
||||
create: vitest.fn(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe("LmStudioHandler timeout configuration", () => {
|
||||
describe("LmStudioHandler configuration", () => {
|
||||
beforeEach(() => {
|
||||
vitest.clearAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should use default timeout of 600 seconds when no configuration is set", () => {
|
||||
;(getApiRequestTimeout as any).mockReturnValue(600000)
|
||||
|
||||
it("should configure the provider with default base URL", () => {
|
||||
const options: ApiHandlerOptions = {
|
||||
apiModelId: "llama2",
|
||||
lmStudioModelId: "llama2",
|
||||
lmStudioBaseUrl: "http://localhost:1234",
|
||||
}
|
||||
|
||||
new LmStudioHandler(options)
|
||||
|
||||
expect(getApiRequestTimeout).toHaveBeenCalled()
|
||||
expect(mockOpenAIConstructor).toHaveBeenCalledWith(
|
||||
expect(mockCreateOpenAICompatible).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "lmstudio",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
apiKey: "noop",
|
||||
timeout: 600000, // 600 seconds in milliseconds
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use custom timeout when configuration is set", () => {
|
||||
;(getApiRequestTimeout as any).mockReturnValue(1200000) // 20 minutes
|
||||
it("should configure the provider with custom base URL", () => {
|
||||
const options: ApiHandlerOptions = {
|
||||
apiModelId: "llama2",
|
||||
lmStudioModelId: "llama2",
|
||||
lmStudioBaseUrl: "http://localhost:5678",
|
||||
}
|
||||
|
||||
new LmStudioHandler(options)
|
||||
|
||||
expect(mockCreateOpenAICompatible).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "http://localhost:5678/v1",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use 'noop' as the API key", () => {
|
||||
const options: ApiHandlerOptions = {
|
||||
apiModelId: "llama2",
|
||||
lmStudioModelId: "llama2",
|
||||
|
|
@ -65,26 +72,9 @@ describe("LmStudioHandler timeout configuration", () => {
|
|||
|
||||
new LmStudioHandler(options)
|
||||
|
||||
expect(mockOpenAIConstructor).toHaveBeenCalledWith(
|
||||
expect(mockCreateOpenAICompatible).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeout: 1200000, // 1200 seconds in milliseconds
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle zero timeout (no timeout)", () => {
|
||||
;(getApiRequestTimeout as any).mockReturnValue(0)
|
||||
|
||||
const options: ApiHandlerOptions = {
|
||||
apiModelId: "llama2",
|
||||
lmStudioModelId: "llama2",
|
||||
}
|
||||
|
||||
new LmStudioHandler(options)
|
||||
|
||||
expect(mockOpenAIConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeout: 0, // No timeout
|
||||
apiKey: "noop",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,22 +1,28 @@
|
|||
// npx vitest run api/providers/__tests__/lmstudio-native-tools.spec.ts
|
||||
|
||||
// Mock OpenAI client - must come before other imports
|
||||
const mockCreate = vi.fn()
|
||||
vi.mock("openai", () => {
|
||||
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
|
||||
const { mockStreamText } = vi.hoisted(() => ({
|
||||
mockStreamText: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
__esModule: true,
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
chat: {
|
||||
completions: {
|
||||
create: mockCreate,
|
||||
},
|
||||
},
|
||||
})),
|
||||
...actual,
|
||||
streamText: mockStreamText,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@ai-sdk/openai-compatible", () => ({
|
||||
createOpenAICompatible: vi.fn(() => {
|
||||
return vi.fn(() => ({
|
||||
modelId: "local-model",
|
||||
provider: "lmstudio",
|
||||
}))
|
||||
}),
|
||||
}))
|
||||
|
||||
import { LmStudioHandler } from "../lm-studio"
|
||||
import { NativeToolCallParser } from "../../../core/assistant-message/NativeToolCallParser"
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
describe("LmStudioHandler Native Tools", () => {
|
||||
|
|
@ -49,128 +55,76 @@ describe("LmStudioHandler Native Tools", () => {
|
|||
lmStudioBaseUrl: "http://localhost:1234",
|
||||
}
|
||||
handler = new LmStudioHandler(mockOptions)
|
||||
|
||||
// Clear NativeToolCallParser state before each test
|
||||
NativeToolCallParser.clearRawChunkState()
|
||||
})
|
||||
|
||||
describe("Native Tool Calling Support", () => {
|
||||
it("should include tools in request when model supports native tools and tools are provided", async () => {
|
||||
mockCreate.mockImplementationOnce(() => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [{ delta: { content: "Test response" } }],
|
||||
}
|
||||
},
|
||||
}))
|
||||
it("should include tools in request when tools are provided", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
tools: testTools,
|
||||
})
|
||||
await stream.next()
|
||||
for await (const _chunk of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "function",
|
||||
function: expect.objectContaining({
|
||||
name: "test_tool",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
// parallel_tool_calls should be true by default when not explicitly set
|
||||
const callArgs = mockCreate.mock.calls[0][0]
|
||||
expect(callArgs).toHaveProperty("parallel_tool_calls", true)
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
expect(callArgs.tools).toBeDefined()
|
||||
})
|
||||
|
||||
it("should include tool_choice when provided", async () => {
|
||||
mockCreate.mockImplementationOnce(() => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [{ delta: { content: "Test response" } }],
|
||||
}
|
||||
},
|
||||
}))
|
||||
it("should include toolChoice when provided", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
tools: testTools,
|
||||
tool_choice: "auto",
|
||||
})
|
||||
await stream.next()
|
||||
for await (const _chunk of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tool_choice: "auto",
|
||||
}),
|
||||
)
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
expect(callArgs.toolChoice).toBe("auto")
|
||||
})
|
||||
|
||||
it("should always include tools and tool_choice in request (tools are always present after PR #10841)", async () => {
|
||||
mockCreate.mockImplementationOnce(() => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [{ delta: { content: "Test response" } }],
|
||||
}
|
||||
},
|
||||
}))
|
||||
it("should yield tool_call_start, tool_call_delta, and tool_call_end chunks from AI SDK stream", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield {
|
||||
type: "tool-input-start",
|
||||
id: "call_lmstudio_123",
|
||||
toolName: "test_tool",
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-delta",
|
||||
id: "call_lmstudio_123",
|
||||
delta: '{"arg1":"value"}',
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-end",
|
||||
id: "call_lmstudio_123",
|
||||
}
|
||||
}
|
||||
|
||||
const stream = handler.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
|
||||
})
|
||||
await stream.next()
|
||||
|
||||
const callArgs = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0]
|
||||
// Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
|
||||
expect(callArgs).toHaveProperty("tools")
|
||||
expect(callArgs).toHaveProperty("tool_choice")
|
||||
// parallel_tool_calls should be true by default when not explicitly set
|
||||
expect(callArgs).toHaveProperty("parallel_tool_calls", true)
|
||||
})
|
||||
|
||||
it("should yield tool_call_partial chunks during streaming", async () => {
|
||||
mockCreate.mockImplementationOnce(() => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_lmstudio_123",
|
||||
function: {
|
||||
name: "test_tool",
|
||||
arguments: '{"arg1":',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
function: {
|
||||
arguments: '"value"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const stream = handler.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
|
|
@ -182,168 +136,56 @@ describe("LmStudioHandler Native Tools", () => {
|
|||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks).toContainEqual({
|
||||
type: "tool_call_partial",
|
||||
index: 0,
|
||||
const startChunks = chunks.filter((chunk) => chunk.type === "tool_call_start")
|
||||
expect(startChunks).toHaveLength(1)
|
||||
expect(startChunks[0]).toEqual({
|
||||
type: "tool_call_start",
|
||||
id: "call_lmstudio_123",
|
||||
name: "test_tool",
|
||||
arguments: '{"arg1":',
|
||||
})
|
||||
|
||||
expect(chunks).toContainEqual({
|
||||
type: "tool_call_partial",
|
||||
index: 0,
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
arguments: '"value"}',
|
||||
})
|
||||
})
|
||||
|
||||
it("should set parallel_tool_calls based on metadata", async () => {
|
||||
mockCreate.mockImplementationOnce(() => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [{ delta: { content: "Test response" } }],
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const stream = handler.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
tools: testTools,
|
||||
parallelToolCalls: true,
|
||||
})
|
||||
await stream.next()
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parallel_tool_calls: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should yield tool_call_end events when finish_reason is tool_calls", async () => {
|
||||
mockCreate.mockImplementationOnce(() => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_lmstudio_test",
|
||||
function: {
|
||||
name: "test_tool",
|
||||
arguments: '{"arg1":"value"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const stream = handler.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
tools: testTools,
|
||||
const deltaChunks = chunks.filter((chunk) => chunk.type === "tool_call_delta")
|
||||
expect(deltaChunks).toHaveLength(1)
|
||||
expect(deltaChunks[0]).toEqual({
|
||||
type: "tool_call_delta",
|
||||
id: "call_lmstudio_123",
|
||||
delta: '{"arg1":"value"}',
|
||||
})
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
// Simulate what Task.ts does: when we receive tool_call_partial,
|
||||
// process it through NativeToolCallParser to populate rawChunkTracker
|
||||
if (chunk.type === "tool_call_partial") {
|
||||
NativeToolCallParser.processRawChunk({
|
||||
index: chunk.index,
|
||||
id: chunk.id,
|
||||
name: chunk.name,
|
||||
arguments: chunk.arguments,
|
||||
})
|
||||
}
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Should have tool_call_partial and tool_call_end
|
||||
const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
|
||||
const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end")
|
||||
|
||||
expect(partialChunks).toHaveLength(1)
|
||||
expect(endChunks).toHaveLength(1)
|
||||
expect(endChunks[0].id).toBe("call_lmstudio_test")
|
||||
})
|
||||
|
||||
it("should work with parallel tool calls disabled (sends false)", async () => {
|
||||
mockCreate.mockImplementationOnce(() => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [{ delta: { content: "Response" } }],
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const stream = handler.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
tools: testTools,
|
||||
parallelToolCalls: false,
|
||||
expect(endChunks[0]).toEqual({
|
||||
type: "tool_call_end",
|
||||
id: "call_lmstudio_123",
|
||||
})
|
||||
await stream.next()
|
||||
|
||||
// When parallelToolCalls is false, the parameter should be sent as false
|
||||
const callArgs = mockCreate.mock.calls[0][0]
|
||||
expect(callArgs).toHaveProperty("parallel_tool_calls", false)
|
||||
})
|
||||
|
||||
it("should handle reasoning content alongside tool calls", async () => {
|
||||
mockCreate.mockImplementationOnce(() => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
content: "<think>Thinking about this...</think>",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_after_think",
|
||||
function: {
|
||||
name: "test_tool",
|
||||
arguments: '{"arg1":"result"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
}))
|
||||
async function* mockFullStream() {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: "Thinking about this...",
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-start",
|
||||
id: "call_after_think",
|
||||
toolName: "test_tool",
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-delta",
|
||||
id: "call_after_think",
|
||||
delta: '{"arg1":"result"}',
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-end",
|
||||
id: "call_after_think",
|
||||
}
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
|
|
@ -352,25 +194,60 @@ describe("LmStudioHandler Native Tools", () => {
|
|||
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.type === "tool_call_partial") {
|
||||
NativeToolCallParser.processRawChunk({
|
||||
index: chunk.index,
|
||||
id: chunk.id,
|
||||
name: chunk.name,
|
||||
arguments: chunk.arguments,
|
||||
})
|
||||
}
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Should have reasoning, tool_call_partial, and tool_call_end
|
||||
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
|
||||
const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
|
||||
const startChunks = chunks.filter((chunk) => chunk.type === "tool_call_start")
|
||||
const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end")
|
||||
|
||||
expect(reasoningChunks).toHaveLength(1)
|
||||
expect(reasoningChunks[0].text).toBe("Thinking about this...")
|
||||
expect(partialChunks).toHaveLength(1)
|
||||
expect(startChunks).toHaveLength(1)
|
||||
expect(endChunks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should handle text and tool calls in the same response", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Here's the result: " }
|
||||
yield {
|
||||
type: "tool-input-start",
|
||||
id: "call_mixed",
|
||||
toolName: "test_tool",
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-delta",
|
||||
id: "call_mixed",
|
||||
delta: '{"arg1":"mixed"}',
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-end",
|
||||
id: "call_mixed",
|
||||
}
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
tools: testTools,
|
||||
})
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
const startChunks = chunks.filter((chunk) => chunk.type === "tool_call_start")
|
||||
const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end")
|
||||
|
||||
expect(textChunks).toHaveLength(1)
|
||||
expect(textChunks[0].text).toBe("Here's the result: ")
|
||||
expect(startChunks).toHaveLength(1)
|
||||
expect(endChunks).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,63 +1,29 @@
|
|||
// Mock OpenAI client - must come before other imports
|
||||
const mockCreate = vi.fn()
|
||||
vi.mock("openai", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
chat: {
|
||||
completions: {
|
||||
create: mockCreate.mockImplementation(async (options) => {
|
||||
if (!options.stream) {
|
||||
return {
|
||||
id: "test-completion",
|
||||
choices: [
|
||||
{
|
||||
message: { role: "assistant", content: "Test response" },
|
||||
finish_reason: "stop",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
},
|
||||
}
|
||||
}
|
||||
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
|
||||
const { mockStreamText, mockGenerateText, mockWrapLanguageModel } = vi.hoisted(() => ({
|
||||
mockStreamText: vi.fn(),
|
||||
mockGenerateText: vi.fn(),
|
||||
mockWrapLanguageModel: vi.fn((opts: any) => opts.model),
|
||||
}))
|
||||
|
||||
return {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "Test response" },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}),
|
||||
},
|
||||
},
|
||||
})),
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
...actual,
|
||||
streamText: mockStreamText,
|
||||
generateText: mockGenerateText,
|
||||
wrapLanguageModel: mockWrapLanguageModel,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@ai-sdk/openai-compatible", () => ({
|
||||
createOpenAICompatible: vi.fn(() => {
|
||||
return vi.fn(() => ({
|
||||
modelId: "local-model",
|
||||
provider: "lmstudio",
|
||||
}))
|
||||
}),
|
||||
}))
|
||||
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { LmStudioHandler } from "../lm-studio"
|
||||
|
|
@ -74,7 +40,7 @@ describe("LmStudioHandler", () => {
|
|||
lmStudioBaseUrl: "http://localhost:1234",
|
||||
}
|
||||
handler = new LmStudioHandler(mockOptions)
|
||||
mockCreate.mockClear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
|
|
@ -102,6 +68,20 @@ describe("LmStudioHandler", () => {
|
|||
]
|
||||
|
||||
it("should handle streaming responses", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
|
|
@ -114,8 +94,43 @@ describe("LmStudioHandler", () => {
|
|||
expect(textChunks[0].text).toBe("Test response")
|
||||
})
|
||||
|
||||
it("should include usage information", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0].inputTokens).toBe(10)
|
||||
expect(usageChunks[0].outputTokens).toBe(5)
|
||||
})
|
||||
|
||||
it("should handle API errors", async () => {
|
||||
mockCreate.mockRejectedValueOnce(new Error("API Error"))
|
||||
async function* mockFullStream(): AsyncGenerator<{ type: string; text: string }> {
|
||||
yield { type: "text-delta", text: "" }
|
||||
throw new Error("API Error")
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
|
||||
|
|
@ -123,36 +138,37 @@ describe("LmStudioHandler", () => {
|
|||
for await (const _chunk of stream) {
|
||||
// Should not reach here
|
||||
}
|
||||
}).rejects.toThrow("Please check the LM Studio developer logs to debug what went wrong")
|
||||
}).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("completePrompt", () => {
|
||||
it("should complete prompt successfully", async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test response",
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
expect(result).toBe("Test response")
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
model: mockOptions.lmStudioModelId,
|
||||
messages: [{ role: "user", content: "Test prompt" }],
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle API errors", async () => {
|
||||
mockCreate.mockRejectedValueOnce(new Error("API Error"))
|
||||
await expect(handler.completePrompt("Test prompt")).rejects.toThrow(
|
||||
"Please check the LM Studio developer logs to debug what went wrong",
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "Test prompt",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle empty response", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: "" } }],
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "",
|
||||
})
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
it("should handle API errors with handleAiSdkError", async () => {
|
||||
mockGenerateText.mockRejectedValueOnce(new Error("Connection refused"))
|
||||
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("LM Studio")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
|
|
@ -164,4 +180,131 @@ describe("LmStudioHandler", () => {
|
|||
expect(modelInfo.info.contextWindow).toBe(128_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("speculative decoding", () => {
|
||||
it("should include draft_model in providerOptions when speculative decoding is enabled", async () => {
|
||||
const speculativeHandler = new LmStudioHandler({
|
||||
...mockOptions,
|
||||
lmStudioSpeculativeDecodingEnabled: true,
|
||||
lmStudioDraftModelId: "draft-model-id",
|
||||
})
|
||||
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 5, outputTokens: 3 }),
|
||||
})
|
||||
|
||||
const stream = speculativeHandler.createMessage("test prompt", [])
|
||||
for await (const _chunk of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockStreamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerOptions: {
|
||||
lmstudio: { draft_model: "draft-model-id" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should not include draft_model when speculative decoding is disabled", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 5, outputTokens: 3 }),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("test prompt", [])
|
||||
for await (const _chunk of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
expect(callArgs.providerOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should include draft_model in completePrompt when speculative decoding is enabled", async () => {
|
||||
const speculativeHandler = new LmStudioHandler({
|
||||
...mockOptions,
|
||||
lmStudioSpeculativeDecodingEnabled: true,
|
||||
lmStudioDraftModelId: "draft-model-id",
|
||||
})
|
||||
|
||||
mockGenerateText.mockResolvedValue({ text: "Test" })
|
||||
|
||||
await speculativeHandler.completePrompt("Test prompt")
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerOptions: {
|
||||
lmstudio: { draft_model: "draft-model-id" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("reasoning middleware", () => {
|
||||
it("should wrap the language model with extractReasoningMiddleware for <think> tags", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 5, outputTokens: 3 }),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("test prompt", [])
|
||||
for await (const _chunk of stream) {
|
||||
// consume stream to trigger getLanguageModel()
|
||||
}
|
||||
|
||||
expect(mockWrapLanguageModel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
middleware: expect.any(Object),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle reasoning-delta chunks from middleware-processed stream", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "reasoning-delta", text: "Let me think about this..." }
|
||||
yield { type: "text-delta", text: "The answer is 42." }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 8 }),
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("test prompt", [])
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
|
||||
const textChunks = chunks.filter((c) => c.type === "text")
|
||||
|
||||
expect(reasoningChunks).toHaveLength(1)
|
||||
expect(reasoningChunks[0].text).toBe("Let me think about this...")
|
||||
expect(textChunks).toHaveLength(1)
|
||||
expect(textChunks[0].text).toBe("The answer is 42.")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isAiSdkProvider", () => {
|
||||
it("should return true", () => {
|
||||
expect(handler.isAiSdkProvider()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,59 +1,36 @@
|
|||
// Mock TelemetryService - must come before other imports
|
||||
const mockCaptureException = vi.hoisted(() => vi.fn())
|
||||
vi.mock("@roo-code/telemetry", () => ({
|
||||
TelemetryService: {
|
||||
instance: {
|
||||
captureException: mockCaptureException,
|
||||
},
|
||||
},
|
||||
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
|
||||
const { mockStreamText, mockGenerateText, mockCreateMistral } = vi.hoisted(() => ({
|
||||
mockStreamText: vi.fn(),
|
||||
mockGenerateText: vi.fn(),
|
||||
mockCreateMistral: vi.fn(() => {
|
||||
// Return a function that returns a mock language model
|
||||
return vi.fn(() => ({
|
||||
modelId: "codestral-latest",
|
||||
provider: "mistral",
|
||||
}))
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Mistral client - must come before other imports
|
||||
const mockCreate = vi.fn()
|
||||
const mockComplete = vi.fn()
|
||||
vi.mock("@mistralai/mistralai", () => {
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
Mistral: vi.fn().mockImplementation(() => ({
|
||||
chat: {
|
||||
stream: mockCreate.mockImplementation(async (_options) => {
|
||||
const stream = {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
data: {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "Test response" },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
return stream
|
||||
}),
|
||||
complete: mockComplete.mockImplementation(async (_options) => {
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: "Test response",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}),
|
||||
},
|
||||
})),
|
||||
...actual,
|
||||
streamText: mockStreamText,
|
||||
generateText: mockGenerateText,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@ai-sdk/mistral", () => ({
|
||||
createMistral: mockCreateMistral,
|
||||
}))
|
||||
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
import type OpenAI from "openai"
|
||||
import { MistralHandler } from "../mistral"
|
||||
|
||||
import { mistralDefaultModelId, mistralModels, type MistralModelId } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
import type { ApiHandlerCreateMessageMetadata } from "../../index"
|
||||
import type { ApiStreamTextChunk, ApiStreamReasoningChunk, ApiStreamToolCallPartialChunk } from "../../transform/stream"
|
||||
|
||||
import { MistralHandler } from "../mistral"
|
||||
|
||||
describe("MistralHandler", () => {
|
||||
let handler: MistralHandler
|
||||
|
|
@ -61,15 +38,11 @@ describe("MistralHandler", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
mockOptions = {
|
||||
apiModelId: "codestral-latest", // Update to match the actual model ID
|
||||
mistralApiKey: "test-api-key",
|
||||
includeMaxTokens: true,
|
||||
modelTemperature: 0,
|
||||
apiModelId: "codestral-latest" as MistralModelId,
|
||||
}
|
||||
handler = new MistralHandler(mockOptions)
|
||||
mockCreate.mockClear()
|
||||
mockComplete.mockClear()
|
||||
mockCaptureException.mockClear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
|
|
@ -78,32 +51,53 @@ describe("MistralHandler", () => {
|
|||
expect(handler.getModel().id).toBe(mockOptions.apiModelId)
|
||||
})
|
||||
|
||||
it("should throw error if API key is missing", () => {
|
||||
expect(() => {
|
||||
new MistralHandler({
|
||||
...mockOptions,
|
||||
mistralApiKey: undefined,
|
||||
})
|
||||
}).toThrow("Mistral API key is required")
|
||||
})
|
||||
|
||||
it("should use custom base URL if provided", () => {
|
||||
const customBaseUrl = "https://custom.mistral.ai/v1"
|
||||
const handlerWithCustomUrl = new MistralHandler({
|
||||
it("should use default model ID if not provided", () => {
|
||||
const handlerWithoutModel = new MistralHandler({
|
||||
...mockOptions,
|
||||
mistralCodestralUrl: customBaseUrl,
|
||||
apiModelId: undefined,
|
||||
})
|
||||
expect(handlerWithCustomUrl).toBeInstanceOf(MistralHandler)
|
||||
expect(handlerWithoutModel.getModel().id).toBe(mistralDefaultModelId)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return correct model info", () => {
|
||||
it("should return model info for valid model ID", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe(mockOptions.apiModelId)
|
||||
expect(model.info).toBeDefined()
|
||||
expect(model.info.maxTokens).toBe(8192)
|
||||
expect(model.info.contextWindow).toBe(256_000)
|
||||
expect(model.info.supportsImages).toBe(false)
|
||||
expect(model.info.supportsPromptCache).toBe(false)
|
||||
})
|
||||
|
||||
it("should return provided model ID with default model info if model does not exist", () => {
|
||||
const handlerWithInvalidModel = new MistralHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "invalid-model",
|
||||
})
|
||||
const model = handlerWithInvalidModel.getModel()
|
||||
expect(model.id).toBe("invalid-model") // Returns provided ID
|
||||
expect(model.info).toBeDefined()
|
||||
// Should have the same base properties as default model
|
||||
expect(model.info.contextWindow).toBe(mistralModels[mistralDefaultModelId].contextWindow)
|
||||
})
|
||||
|
||||
it("should return default model if no model ID is provided", () => {
|
||||
const handlerWithoutModel = new MistralHandler({
|
||||
...mockOptions,
|
||||
apiModelId: undefined,
|
||||
})
|
||||
const model = handlerWithoutModel.getModel()
|
||||
expect(model.id).toBe(mistralDefaultModelId)
|
||||
expect(model.info).toBeDefined()
|
||||
})
|
||||
|
||||
it("should include model parameters from getModelParams", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model).toHaveProperty("temperature")
|
||||
expect(model).toHaveProperty("maxTokens")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
|
|
@ -111,389 +105,446 @@ describe("MistralHandler", () => {
|
|||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello!" }],
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "Hello!",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
it("should create message successfully", async () => {
|
||||
const iterator = handler.createMessage(systemPrompt, messages)
|
||||
const result = await iterator.next()
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: mockOptions.apiModelId,
|
||||
messages: expect.any(Array),
|
||||
maxTokens: expect.any(Number),
|
||||
temperature: 0,
|
||||
// Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
|
||||
tools: expect.any(Array),
|
||||
toolChoice: "any",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.value).toBeDefined()
|
||||
expect(result.done).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle streaming response correctly", async () => {
|
||||
const iterator = handler.createMessage(systemPrompt, messages)
|
||||
const results: ApiStreamTextChunk[] = []
|
||||
|
||||
for await (const chunk of iterator) {
|
||||
if ("text" in chunk) {
|
||||
results.push(chunk as ApiStreamTextChunk)
|
||||
}
|
||||
it("should handle streaming responses", async () => {
|
||||
// Mock the fullStream async generator
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(results[0].text).toBe("Test response")
|
||||
})
|
||||
|
||||
it("should handle errors gracefully", async () => {
|
||||
mockCreate.mockRejectedValueOnce(new Error("API Error"))
|
||||
await expect(handler.createMessage(systemPrompt, messages).next()).rejects.toThrow("API Error")
|
||||
})
|
||||
|
||||
it("should handle thinking content as reasoning chunks", async () => {
|
||||
// Mock stream with thinking content matching new SDK structure
|
||||
mockCreate.mockImplementationOnce(async (_options) => {
|
||||
const stream = {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
data: {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: [{ type: "text", text: "Let me think about this..." }],
|
||||
},
|
||||
{ type: "text", text: "Here's the answer" },
|
||||
],
|
||||
},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
return stream
|
||||
// Mock usage promise
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
const iterator = handler.createMessage(systemPrompt, messages)
|
||||
const results: (ApiStreamTextChunk | ApiStreamReasoningChunk)[] = []
|
||||
|
||||
for await (const chunk of iterator) {
|
||||
if ("text" in chunk) {
|
||||
results.push(chunk as ApiStreamTextChunk | ApiStreamReasoningChunk)
|
||||
}
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0]).toEqual({ type: "reasoning", text: "Let me think about this..." })
|
||||
expect(results[1]).toEqual({ type: "text", text: "Here's the answer" })
|
||||
})
|
||||
|
||||
it("should handle mixed content arrays correctly", async () => {
|
||||
// Mock stream with mixed content matching new SDK structure
|
||||
mockCreate.mockImplementationOnce(async (_options) => {
|
||||
const stream = {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
data: {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
content: [
|
||||
{ type: "text", text: "First text" },
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: [{ type: "text", text: "Some reasoning" }],
|
||||
},
|
||||
{ type: "text", text: "Second text" },
|
||||
],
|
||||
},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
return stream
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
const iterator = handler.createMessage(systemPrompt, messages)
|
||||
const results: (ApiStreamTextChunk | ApiStreamReasoningChunk)[] = []
|
||||
|
||||
for await (const chunk of iterator) {
|
||||
if ("text" in chunk) {
|
||||
results.push(chunk as ApiStreamTextChunk | ApiStreamReasoningChunk)
|
||||
}
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(3)
|
||||
expect(results[0]).toEqual({ type: "text", text: "First text" })
|
||||
expect(results[1]).toEqual({ type: "reasoning", text: "Some reasoning" })
|
||||
expect(results[2]).toEqual({ type: "text", text: "Second text" })
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks).toHaveLength(1)
|
||||
expect(textChunks[0].text).toBe("Test response")
|
||||
})
|
||||
})
|
||||
|
||||
describe("native tool calling", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "What's the weather?" }],
|
||||
},
|
||||
]
|
||||
it("should include usage information", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockTools: OpenAI.Chat.ChatCompletionTool[] = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Get the current weather",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
location: { type: "string" },
|
||||
},
|
||||
required: ["location"],
|
||||
},
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0].inputTokens).toBe(10)
|
||||
expect(usageChunks[0].outputTokens).toBe(5)
|
||||
})
|
||||
|
||||
it("should handle reasoning content in streaming responses", async () => {
|
||||
// Mock the fullStream async generator with reasoning content
|
||||
async function* mockFullStream() {
|
||||
yield { type: "reasoning", text: "Let me think about this..." }
|
||||
yield { type: "reasoning", text: " I'll analyze step by step." }
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
details: {
|
||||
reasoningTokens: 15,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
it("should include tools in request by default (native is default)", async () => {
|
||||
const metadata: ApiHandlerCreateMessageMetadata = {
|
||||
taskId: "test-task",
|
||||
tools: mockTools,
|
||||
}
|
||||
|
||||
const iterator = handler.createMessage(systemPrompt, messages, metadata)
|
||||
await iterator.next()
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "function",
|
||||
function: expect.objectContaining({
|
||||
name: "get_weather",
|
||||
description: "Get the current weather",
|
||||
parameters: expect.any(Object),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
toolChoice: "any",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should always include tools in request (tools are always present after PR #10841)", async () => {
|
||||
const metadata: ApiHandlerCreateMessageMetadata = {
|
||||
taskId: "test-task",
|
||||
}
|
||||
|
||||
const iterator = handler.createMessage(systemPrompt, messages, metadata)
|
||||
await iterator.next()
|
||||
|
||||
// Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.any(Array),
|
||||
toolChoice: "any",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle tool calls in streaming response", async () => {
|
||||
// Mock stream with tool calls
|
||||
mockCreate.mockImplementationOnce(async (_options) => {
|
||||
const stream = {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
data: {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
toolCalls: [
|
||||
{
|
||||
id: "call_123",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: '{"location":"New York"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
return stream
|
||||
})
|
||||
|
||||
const metadata: ApiHandlerCreateMessageMetadata = {
|
||||
taskId: "test-task",
|
||||
tools: mockTools,
|
||||
}
|
||||
|
||||
const iterator = handler.createMessage(systemPrompt, messages, metadata)
|
||||
const results: ApiStreamToolCallPartialChunk[] = []
|
||||
|
||||
for await (const chunk of iterator) {
|
||||
if (chunk.type === "tool_call_partial") {
|
||||
results.push(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toEqual({
|
||||
type: "tool_call_partial",
|
||||
index: 0,
|
||||
id: "call_123",
|
||||
name: "get_weather",
|
||||
arguments: '{"location":"New York"}',
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle multiple tool calls in a single response", async () => {
|
||||
// Mock stream with multiple tool calls
|
||||
mockCreate.mockImplementationOnce(async (_options) => {
|
||||
const stream = {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
data: {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
toolCalls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: '{"location":"NYC"}',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "call_2",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: '{"location":"LA"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
return stream
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
const metadata: ApiHandlerCreateMessageMetadata = {
|
||||
taskId: "test-task",
|
||||
tools: mockTools,
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const iterator = handler.createMessage(systemPrompt, messages, metadata)
|
||||
const results: ApiStreamToolCallPartialChunk[] = []
|
||||
// Should have reasoning chunks
|
||||
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
|
||||
expect(reasoningChunks.length).toBe(2)
|
||||
expect(reasoningChunks[0].text).toBe("Let me think about this...")
|
||||
expect(reasoningChunks[1].text).toBe(" I'll analyze step by step.")
|
||||
|
||||
for await (const chunk of iterator) {
|
||||
if (chunk.type === "tool_call_partial") {
|
||||
results.push(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0]).toEqual({
|
||||
type: "tool_call_partial",
|
||||
index: 0,
|
||||
id: "call_1",
|
||||
name: "get_weather",
|
||||
arguments: '{"location":"NYC"}',
|
||||
})
|
||||
expect(results[1]).toEqual({
|
||||
type: "tool_call_partial",
|
||||
index: 1,
|
||||
id: "call_2",
|
||||
name: "get_weather",
|
||||
arguments: '{"location":"LA"}',
|
||||
})
|
||||
})
|
||||
|
||||
it("should always set toolChoice to 'any' when tools are provided", async () => {
|
||||
// Even if tool_choice is provided in metadata, we override it to "any"
|
||||
const metadata: ApiHandlerCreateMessageMetadata = {
|
||||
taskId: "test-task",
|
||||
tools: mockTools,
|
||||
tool_choice: "auto", // This should be ignored
|
||||
}
|
||||
|
||||
const iterator = handler.createMessage(systemPrompt, messages, metadata)
|
||||
await iterator.next()
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolChoice: "any",
|
||||
}),
|
||||
)
|
||||
// Should also have text chunks
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks.length).toBe(1)
|
||||
expect(textChunks[0].text).toBe("Test response")
|
||||
})
|
||||
})
|
||||
|
||||
describe("completePrompt", () => {
|
||||
it("should complete prompt successfully", async () => {
|
||||
const prompt = "Test prompt"
|
||||
const result = await handler.completePrompt(prompt)
|
||||
|
||||
expect(mockComplete).toHaveBeenCalledWith({
|
||||
model: mockOptions.apiModelId,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
temperature: 0,
|
||||
it("should complete a prompt using generateText", async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test completion",
|
||||
})
|
||||
|
||||
expect(result).toBe("Test response")
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
|
||||
expect(result).toBe("Test completion")
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "Test prompt",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("processUsageMetrics", () => {
|
||||
it("should correctly process usage metrics", () => {
|
||||
// We need to access the protected method, so we'll create a test subclass
|
||||
class TestMistralHandler extends MistralHandler {
|
||||
public testProcessUsageMetrics(usage: any) {
|
||||
return this.processUsageMetrics(usage)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestMistralHandler(mockOptions)
|
||||
|
||||
const usage = {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
details: {
|
||||
cachedInputTokens: 20,
|
||||
reasoningTokens: 30,
|
||||
},
|
||||
}
|
||||
|
||||
const result = testHandler.testProcessUsageMetrics(usage)
|
||||
|
||||
expect(result.type).toBe("usage")
|
||||
expect(result.inputTokens).toBe(100)
|
||||
expect(result.outputTokens).toBe(50)
|
||||
expect(result.cacheReadTokens).toBe(20)
|
||||
expect(result.reasoningTokens).toBe(30)
|
||||
})
|
||||
|
||||
it("should filter out thinking content in completePrompt", async () => {
|
||||
mockComplete.mockImplementationOnce(async (_options) => {
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: [
|
||||
{ type: "thinking", text: "Let me think..." },
|
||||
{ type: "text", text: "Answer part 1" },
|
||||
{ type: "text", text: "Answer part 2" },
|
||||
],
|
||||
it("should handle missing cache metrics gracefully", () => {
|
||||
class TestMistralHandler extends MistralHandler {
|
||||
public testProcessUsageMetrics(usage: any) {
|
||||
return this.processUsageMetrics(usage)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestMistralHandler(mockOptions)
|
||||
|
||||
const usage = {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
}
|
||||
|
||||
const result = testHandler.testProcessUsageMetrics(usage)
|
||||
|
||||
expect(result.type).toBe("usage")
|
||||
expect(result.inputTokens).toBe(100)
|
||||
expect(result.outputTokens).toBe(50)
|
||||
expect(result.cacheReadTokens).toBeUndefined()
|
||||
expect(result.reasoningTokens).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getMaxOutputTokens", () => {
|
||||
it("should return maxTokens from model info", () => {
|
||||
class TestMistralHandler extends MistralHandler {
|
||||
public testGetMaxOutputTokens() {
|
||||
return this.getMaxOutputTokens()
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestMistralHandler(mockOptions)
|
||||
const result = testHandler.testGetMaxOutputTokens()
|
||||
|
||||
// codestral-latest maxTokens is 8192
|
||||
expect(result).toBe(8192)
|
||||
})
|
||||
|
||||
it("should use modelMaxTokens when provided", () => {
|
||||
class TestMistralHandler extends MistralHandler {
|
||||
public testGetMaxOutputTokens() {
|
||||
return this.getMaxOutputTokens()
|
||||
}
|
||||
}
|
||||
|
||||
const customMaxTokens = 5000
|
||||
const testHandler = new TestMistralHandler({
|
||||
...mockOptions,
|
||||
modelMaxTokens: customMaxTokens,
|
||||
})
|
||||
|
||||
const result = testHandler.testGetMaxOutputTokens()
|
||||
expect(result).toBe(customMaxTokens)
|
||||
})
|
||||
|
||||
it("should fall back to modelInfo.maxTokens when modelMaxTokens is not provided", () => {
|
||||
class TestMistralHandler extends MistralHandler {
|
||||
public testGetMaxOutputTokens() {
|
||||
return this.getMaxOutputTokens()
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestMistralHandler(mockOptions)
|
||||
const result = testHandler.testGetMaxOutputTokens()
|
||||
|
||||
// codestral-latest has maxTokens of 8192
|
||||
expect(result).toBe(8192)
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool handling", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text" as const, text: "Hello!" }],
|
||||
},
|
||||
]
|
||||
|
||||
it("should handle tool calls in streaming", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield {
|
||||
type: "tool-input-start",
|
||||
id: "tool-call-1",
|
||||
toolName: "read_file",
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-delta",
|
||||
id: "tool-call-1",
|
||||
delta: '{"path":"test.ts"}',
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-end",
|
||||
id: "tool-call-1",
|
||||
}
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages, {
|
||||
taskId: "test-task",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
description: "Read a file",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string" } },
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const prompt = "Test prompt"
|
||||
const result = await handler.completePrompt(prompt)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(result).toBe("Answer part 1Answer part 2")
|
||||
const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start")
|
||||
const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta")
|
||||
const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end")
|
||||
|
||||
expect(toolCallStartChunks.length).toBe(1)
|
||||
expect(toolCallStartChunks[0].id).toBe("tool-call-1")
|
||||
expect(toolCallStartChunks[0].name).toBe("read_file")
|
||||
|
||||
expect(toolCallDeltaChunks.length).toBe(1)
|
||||
expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}')
|
||||
|
||||
expect(toolCallEndChunks.length).toBe(1)
|
||||
expect(toolCallEndChunks[0].id).toBe("tool-call-1")
|
||||
})
|
||||
|
||||
it("should handle errors in completePrompt", async () => {
|
||||
mockComplete.mockRejectedValueOnce(new Error("API Error"))
|
||||
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Mistral completion error: API Error")
|
||||
it("should ignore tool-call events to prevent duplicate tools in UI", async () => {
|
||||
// tool-call events are intentionally ignored because tool-input-start/delta/end
|
||||
// already provide complete tool call information. Emitting tool-call would cause
|
||||
// duplicate tools in the UI for AI SDK providers.
|
||||
async function* mockFullStream() {
|
||||
yield {
|
||||
type: "tool-call",
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "read_file",
|
||||
input: { path: "test.ts" },
|
||||
}
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages, {
|
||||
taskId: "test-task",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
description: "Read a file",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string" } },
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// tool-call events are ignored, so no tool_call chunks should be emitted
|
||||
const toolCallChunks = chunks.filter((c) => c.type === "tool_call")
|
||||
expect(toolCallChunks.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("mapToolChoice", () => {
|
||||
it("should handle string tool choices", () => {
|
||||
class TestMistralHandler extends MistralHandler {
|
||||
public testMapToolChoice(toolChoice: any) {
|
||||
return this.mapToolChoice(toolChoice)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestMistralHandler(mockOptions)
|
||||
|
||||
expect(testHandler.testMapToolChoice("auto")).toBe("auto")
|
||||
expect(testHandler.testMapToolChoice("none")).toBe("none")
|
||||
expect(testHandler.testMapToolChoice("required")).toBe("required")
|
||||
expect(testHandler.testMapToolChoice("any")).toBe("required")
|
||||
expect(testHandler.testMapToolChoice("unknown")).toBe("auto")
|
||||
})
|
||||
|
||||
it("should handle object tool choice with function name", () => {
|
||||
class TestMistralHandler extends MistralHandler {
|
||||
public testMapToolChoice(toolChoice: any) {
|
||||
return this.mapToolChoice(toolChoice)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestMistralHandler(mockOptions)
|
||||
|
||||
const result = testHandler.testMapToolChoice({
|
||||
type: "function",
|
||||
function: { name: "my_tool" },
|
||||
})
|
||||
|
||||
expect(result).toEqual({ type: "tool", toolName: "my_tool" })
|
||||
})
|
||||
|
||||
it("should return undefined for null or undefined", () => {
|
||||
class TestMistralHandler extends MistralHandler {
|
||||
public testMapToolChoice(toolChoice: any) {
|
||||
return this.mapToolChoice(toolChoice)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestMistralHandler(mockOptions)
|
||||
|
||||
expect(testHandler.testMapToolChoice(null)).toBeUndefined()
|
||||
expect(testHandler.testMapToolChoice(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Codestral URL handling", () => {
|
||||
beforeEach(() => {
|
||||
mockCreateMistral.mockClear()
|
||||
})
|
||||
|
||||
it("should use default Codestral URL for codestral models", () => {
|
||||
new MistralHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "codestral-latest",
|
||||
})
|
||||
|
||||
expect(mockCreateMistral).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://codestral.mistral.ai/v1",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use custom Codestral URL when provided", () => {
|
||||
new MistralHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "codestral-latest",
|
||||
mistralCodestralUrl: "https://custom.codestral.url/v1",
|
||||
})
|
||||
|
||||
expect(mockCreateMistral).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://custom.codestral.url/v1",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use default Mistral URL for non-codestral models", () => {
|
||||
new MistralHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "mistral-large-latest",
|
||||
})
|
||||
|
||||
expect(mockCreateMistral).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://api.mistral.ai/v1",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue