Adding MCP Servers supports to Arcade Evals (#689)
# MCP Server Tool Evaluation Support
## Overview
Add support for evaluating tools from remote MCP servers without
requiring Python callables. Enables direct evaluation of any
MCP-compatible tool server.
## What's New
### Core Features
- **`MCPToolRegistry`**: Evaluate tools from a single MCP server
- **`CompositeMCPRegistry`**: Evaluate tools from multiple MCP servers
simultaneously
- **Automatic loaders**: `load_from_stdio()` and `load_from_http()` to
fetch tools from running servers
- **Automatic namespacing**: Tools prefixed with server name (e.g.,
`server_tool_name`)
- **Smart name resolution**: Use short names if unique, full names if
ambiguous
- **OpenAI strict mode**: Automatic schema conversion prevents parameter
hallucinations
### Usage
**Automatic Loading:**
```python
from arcade_evals import load_from_stdio, MCPToolRegistry
# Load tools automatically from MCP server
tools = load_from_stdio(["npx", "-y", "@modelcontextprotocol/server-github"])
registry = MCPToolRegistry(tools)
```
**Single MCP Server:**
```python
from arcade_evals import MCPToolRegistry, ExpectedToolCall
registry = MCPToolRegistry(mcp_tools)
suite = EvalSuite(catalog=registry)
suite.add_case(
expected_tool_calls=[
ExpectedToolCall(tool_name="tool_name", args={...})
]
)
```
**Multiple MCP Servers:**
```python
from arcade_evals import CompositeMCPRegistry, load_from_stdio
# Load from multiple servers
github_tools = load_from_stdio(["npx", "-y", "@modelcontextprotocol/server-github"])
slack_tools = load_from_stdio(["npx", "-y", "@modelcontextprotocol/server-slack"])
composite = CompositeMCPRegistry(
tool_lists={
"github": github_tools,
"slack": slack_tools,
}
)
suite = EvalSuite(catalog=composite)
suite.add_case(
expected_tool_calls=[
ExpectedToolCall(tool_name="github_list_issues", args={...})
]
)
```
## Implementation
### Files Changed
- **`libs/arcade-evals/arcade_evals/registry.py`** (NEW): Registry
abstractions and implementations
- **`libs/arcade-evals/arcade_evals/loaders.py`** (NEW): Automatic tool
loading from MCP servers
- **`libs/arcade-evals/arcade_evals/eval.py`** (MODIFIED): Enhanced
`ExpectedToolCall` and evaluation logic
- **`libs/arcade-evals/arcade_evals/__init__.py`** (MODIFIED): Exported
new registries and loaders
### Key Technical Details
- Added `BaseToolRegistry` interface for abstraction
- `MCPToolRegistry` handles single server tools
- `CompositeMCPRegistry` manages multiple servers with collision
detection
- `load_from_stdio()` and `load_from_http()` for automatic tool
discovery
- Fixed name normalization bug: MCP tools use underscores (not dots)
- Optimized tool copying: 2.5x faster via shallow copy
## Testing
- ✅ 41 tests passing (25 new tests added)
- ✅ `test_eval_mcp_registry.py`: MCPToolRegistry functionality
- ✅ `test_eval_composite_mcp.py`: CompositeMCPRegistry with multiple
servers
- ✅ Verified backward compatibility with Python tools
## Backward Compatibility
✅ **100% backward compatible** - No breaking changes
## Breaking Changes
**None**
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> Adds end-to-end eval UX: examples, a robust CLI runner, and rich
outputs.
>
> - **New examples**: `eval_arcade_gateway.py`,
`eval_stdio_mcp_server.py`, `eval_http_mcp_server.py`,
`eval_comprehensive_comparison.py` with timeouts, error handling, and
track-based comparisons; detailed `README.md`
> - **CLI runner**: `arcade_cli/evals_runner.py` to execute
evals/capture in parallel with progress, error isolation, failed-only
filtering, context inclusion, and multi-provider/model support
> - **Output formatters**: `arcade_cli/formatters/` (txt, md, html,
json) for evals and capture; comparative and multi-model HTML with tabs
and context rendering
> - **Display refactor**: `display.py` now supports writing multiple
formats, failed-only disclaimers, include-context, and improved console
summaries
>
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
ff8acf9c34a6b61462a019a1ee9df081006517d0. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
---------
Co-authored-by: Francisco Liberal <francisco@arcade.dev>
Co-authored-by: Mateo Torres <torresmateo@gmail.com>
This commit is contained in:
parent
25309c4e15
commit
98fad93d21
79 changed files with 26894 additions and 494 deletions
392
examples/evals/README.md
Normal file
392
examples/evals/README.md
Normal file
|
|
@ -0,0 +1,392 @@
|
||||||
|
# Arcade Evals Examples
|
||||||
|
|
||||||
|
This directory contains user-friendly examples demonstrating how to evaluate tools from different sources using the Arcade evals framework.
|
||||||
|
|
||||||
|
## 📋 Table of Contents
|
||||||
|
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [Example Files](#example-files)
|
||||||
|
- [CLI Reference](#cli-reference)
|
||||||
|
- [Common Patterns](#common-patterns)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### What Makes These Examples Different
|
||||||
|
|
||||||
|
These examples are designed to be:
|
||||||
|
- **Production-ready**: Include proper error handling and timeouts
|
||||||
|
- **Copy-paste friendly**: Clear configuration sections you can modify
|
||||||
|
- **Informative**: Print status messages during loading
|
||||||
|
- **Focused**: One concept per example, no unnecessary complexity
|
||||||
|
- **Pattern-based**: Follow consistent structure from real-world evals
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install with evals support
|
||||||
|
pip install 'arcade-mcp[evals]'
|
||||||
|
|
||||||
|
# Or using uv (recommended)
|
||||||
|
uv tool install 'arcade-mcp[evals]'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run an evaluation with OpenAI
|
||||||
|
arcade evals examples/evals/eval_arcade_gateway.py \
|
||||||
|
--api-key openai:YOUR_OPENAI_KEY
|
||||||
|
|
||||||
|
# Compare multiple models
|
||||||
|
arcade evals examples/evals/eval_stdio_mcp_server.py \
|
||||||
|
-p "openai:gpt-4o anthropic:claude-sonnet-4-5-20250929" \
|
||||||
|
-k openai:YOUR_OPENAI_KEY \
|
||||||
|
-k anthropic:YOUR_ANTHROPIC_KEY
|
||||||
|
|
||||||
|
# Output results to HTML
|
||||||
|
arcade evals examples/evals/eval_http_mcp_server.py \
|
||||||
|
--api-key openai:YOUR_KEY \
|
||||||
|
-o results.html -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 Example Files
|
||||||
|
|
||||||
|
### Example Structure
|
||||||
|
|
||||||
|
All examples follow a consistent pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 1. Configuration section - Update these values
|
||||||
|
ARCADE_API_KEY = os.environ.get("ARCADE_API_KEY", "YOUR_KEY_HERE")
|
||||||
|
|
||||||
|
# 2. Eval suite with async loading
|
||||||
|
@tool_eval()
|
||||||
|
async def eval_my_suite() -> EvalSuite:
|
||||||
|
suite = EvalSuite(name="...", system_message="...", rubric=...)
|
||||||
|
|
||||||
|
# 3. Load tools with timeout and error handling
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
suite.add_arcade_gateway(...),
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
print(" ✓ Source loaded")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Source failed: {e}")
|
||||||
|
return suite
|
||||||
|
|
||||||
|
# 4. Add test cases
|
||||||
|
suite.add_case(name="...", user_message="...", ...)
|
||||||
|
|
||||||
|
return suite
|
||||||
|
```
|
||||||
|
|
||||||
|
This pattern ensures:
|
||||||
|
- Clear configuration at the top
|
||||||
|
- Robust error handling
|
||||||
|
- Informative output during loading
|
||||||
|
- Graceful degradation if sources fail
|
||||||
|
|
||||||
|
### 1. `eval_arcade_gateway.py`
|
||||||
|
|
||||||
|
Evaluates tools from Arcade Gateway (cloud-hosted toolkits).
|
||||||
|
|
||||||
|
**What it demonstrates:**
|
||||||
|
|
||||||
|
- Async loading from Arcade Gateway with timeout handling
|
||||||
|
- Error handling for connection failures
|
||||||
|
- Math toolkit evaluations
|
||||||
|
- BinaryCritic for parameter validation
|
||||||
|
- Conversational context with additional_messages
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
|
||||||
|
Before running this example, you need to set up an MCP Gateway:
|
||||||
|
|
||||||
|
1. **Get your API key** - [API Keys Setup Guide](https://docs.arcade.dev/en/get-started/setup/api-keys)
|
||||||
|
2. **Create an MCP Gateway** at [Arcade Portal](https://portal.arcade.dev)
|
||||||
|
3. **Add toolkits** (e.g., Math, GitHub, Slack) to your gateway
|
||||||
|
4. **Get your credentials:**
|
||||||
|
- `ARCADE_API_KEY` - Your Arcade API key
|
||||||
|
- `ARCADE_USER_ID` - Your user ID (found in portal settings)
|
||||||
|
|
||||||
|
📚 **Full setup guide:** [MCP Gateways Documentation](https://docs.arcade.dev/en/guides/create-tools/mcp-gateways)
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
|
||||||
|
- Arcade API key (get one at [arcade.dev](https://arcade.dev))
|
||||||
|
- LLM API key (OpenAI or Anthropic)
|
||||||
|
|
||||||
|
**Run it:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set your Arcade API key
|
||||||
|
export ARCADE_API_KEY=your_arcade_key
|
||||||
|
|
||||||
|
arcade evals examples/evals/eval_arcade_gateway.py \
|
||||||
|
--api-key openai:YOUR_OPENAI_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. `eval_stdio_mcp_server.py`
|
||||||
|
|
||||||
|
Evaluates tools from local MCP servers running via stdio (subprocess).
|
||||||
|
|
||||||
|
**What it demonstrates:**
|
||||||
|
|
||||||
|
- Loading from local stdio MCP servers (subprocesses)
|
||||||
|
- Using `add_mcp_stdio_server()` method
|
||||||
|
- Setting environment variables (PYTHONUNBUFFERED)
|
||||||
|
- Simple echo tool evaluations
|
||||||
|
- Async loading with timeout and error handling
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
|
||||||
|
- Local MCP server code
|
||||||
|
- Server dependencies installed
|
||||||
|
- LLM API key
|
||||||
|
|
||||||
|
**Run it:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
arcade evals examples/evals/eval_stdio_mcp_server.py \
|
||||||
|
--api-key openai:YOUR_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `eval_http_mcp_server.py`
|
||||||
|
|
||||||
|
Evaluates tools from remote MCP servers via HTTP or SSE.
|
||||||
|
|
||||||
|
**What it demonstrates:**
|
||||||
|
|
||||||
|
- Connecting to HTTP MCP endpoints
|
||||||
|
- Using SSE (Server-Sent Events) transport
|
||||||
|
- Authentication with Bearer tokens
|
||||||
|
- Error handling with timeouts
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
|
||||||
|
- Running HTTP/SSE MCP server
|
||||||
|
- Network connectivity
|
||||||
|
- LLM API key
|
||||||
|
- (Optional) Authentication token
|
||||||
|
|
||||||
|
**Run it:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update the configuration in the file first, then run:
|
||||||
|
arcade evals examples/evals/eval_http_mcp_server.py \
|
||||||
|
--api-key openai:YOUR_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. `eval_comprehensive_comparison.py`
|
||||||
|
|
||||||
|
Compares tool performance across multiple sources simultaneously.
|
||||||
|
|
||||||
|
**What it demonstrates:**
|
||||||
|
|
||||||
|
- Comparative evaluation across different tool sources
|
||||||
|
- Loading from multiple sources (Gateway, stdio, dict)
|
||||||
|
- Track-based evaluation (comparing same task across sources)
|
||||||
|
- Conditional test cases based on loaded sources
|
||||||
|
- Using SimilarityCritic for fuzzy matching
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
|
||||||
|
- Arcade API key (for Gateway)
|
||||||
|
- LLM API key
|
||||||
|
- (Optional) Local simple MCP server
|
||||||
|
|
||||||
|
**Run it:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set environment variables
|
||||||
|
export ARCADE_API_KEY=your_key
|
||||||
|
export ARCADE_USER_ID=your_user_id
|
||||||
|
|
||||||
|
arcade evals examples/evals/eval_comprehensive_comparison.py \
|
||||||
|
-p "openai:gpt-4o anthropic:claude-sonnet-4-5-20250929" \
|
||||||
|
-k openai:YOUR_KEY \
|
||||||
|
-k anthropic:YOUR_KEY \
|
||||||
|
-o comparison.html -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 CLI Reference
|
||||||
|
|
||||||
|
### New v2.0.0 Flags
|
||||||
|
|
||||||
|
|
||||||
|
| Flag | Short | Description | Example |
|
||||||
|
| --------------------- | ------- | -------------------------------------------------- | ------------------------------------------------- |
|
||||||
|
| `--use-provider` | `-p` | Provider(s) and models (space-separated) | `-p "openai:gpt-4o anthropic:claude-sonnet"` |
|
||||||
|
| `--api-key` | `-k` | API key in`provider:key` format (repeatable) | `-k openai:sk-... -k anthropic:sk-ant-...` |
|
||||||
|
| `--output` | `-o` | Output file (auto-detects format from extension) | `-o results.html` or `-o results` (all formats) |
|
||||||
|
| `--only-failed` | `-f` | Show only failed evaluations | `--only-failed` |
|
||||||
|
| `--include-context` | | Include system messages and conversation history | `--include-context` |
|
||||||
|
| `--details` | `-d` | Show detailed output | `-d` |
|
||||||
|
| `--max-concurrent` | | Max concurrent evaluations | `--max-concurrent 5` |
|
||||||
|
| `--capture` | | Capture mode (record tool calls without scoring) | `--capture` |
|
||||||
|
|
||||||
|
### Provider & Model Selection
|
||||||
|
|
||||||
|
**Single provider with default model:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
arcade evals eval_file.py -p openai -k openai:YOUR_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
**Single provider with specific models:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
arcade evals eval_file.py -p "openai:gpt-4o,gpt-4o-mini" -k openai:YOUR_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multiple providers (space-separated):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
arcade evals eval_file.py \
|
||||||
|
-p "openai:gpt-4o anthropic:claude-sonnet-4-5-20250929" \
|
||||||
|
-k openai:YOUR_KEY \
|
||||||
|
-k anthropic:YOUR_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output Formats
|
||||||
|
|
||||||
|
**Auto-detect from extension:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
-o results.html # HTML output
|
||||||
|
-o results.json # JSON output
|
||||||
|
-o results.md # Markdown output
|
||||||
|
-o results.txt # Text output
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multiple formats:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
-o results.html -o results.json # Both HTML and JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
**All formats:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
-o results # Generates results.txt, results.md, results.html, results.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Common Patterns
|
||||||
|
|
||||||
|
### Pattern 1: Compare OpenAI Models
|
||||||
|
|
||||||
|
```bash
|
||||||
|
arcade evals examples/evals/eval_arcade_gateway.py \
|
||||||
|
-p "openai:gpt-4o,gpt-4o-mini,gpt-3.5-turbo" \
|
||||||
|
-k openai:YOUR_KEY \
|
||||||
|
-o comparison.html -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: OpenAI vs Anthropic
|
||||||
|
|
||||||
|
```bash
|
||||||
|
arcade evals examples/evals/eval_stdio_mcp_server.py \
|
||||||
|
-p "openai:gpt-4o anthropic:claude-sonnet-4-5-20250929" \
|
||||||
|
-k openai:YOUR_OPENAI_KEY \
|
||||||
|
-k anthropic:YOUR_ANTHROPIC_KEY \
|
||||||
|
-o battle.html -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Failed Tests Only
|
||||||
|
|
||||||
|
```bash
|
||||||
|
arcade evals examples/evals/eval_http_mcp_server.py \
|
||||||
|
--api-key openai:YOUR_KEY \
|
||||||
|
--only-failed -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: Comparative Evaluation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compare performance across multiple tool sources
|
||||||
|
arcade evals examples/evals/eval_comprehensive_comparison.py \
|
||||||
|
-p "openai:gpt-4o anthropic:claude-sonnet-4-5-20250929" \
|
||||||
|
-k openai:YOUR_KEY \
|
||||||
|
-k anthropic:YOUR_KEY \
|
||||||
|
-o comparison.html -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 5: Capture Mode (No Scoring)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Record tool calls without evaluation
|
||||||
|
arcade evals examples/evals/eval_arcade_gateway.py \
|
||||||
|
--capture \
|
||||||
|
--api-key openai:YOUR_KEY \
|
||||||
|
-o captured.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 6: Full Context Output
|
||||||
|
|
||||||
|
```bash
|
||||||
|
arcade evals examples/evals/eval_stdio_mcp_server.py \
|
||||||
|
--api-key openai:YOUR_KEY \
|
||||||
|
--include-context \
|
||||||
|
-o full_results.html -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Error: "No module named 'openai'"
|
||||||
|
|
||||||
|
**Solution:** Install evals dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install 'arcade-mcp[evals]'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error: "API key not found for provider 'openai'"
|
||||||
|
|
||||||
|
**Solution:** Provide API key via flag or environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Via flag
|
||||||
|
arcade evals eval_file.py --api-key openai:YOUR_KEY
|
||||||
|
|
||||||
|
# Via environment variable
|
||||||
|
export OPENAI_API_KEY=your_key
|
||||||
|
arcade evals eval_file.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error: "Connection refused" (HTTP server)
|
||||||
|
|
||||||
|
**Solution:** Ensure your HTTP MCP server is running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if server is running
|
||||||
|
curl http://localhost:8000/mcp
|
||||||
|
|
||||||
|
# Start your server first
|
||||||
|
python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error: "Module not found" (stdio server)
|
||||||
|
|
||||||
|
**Solution:** Install server dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd examples/mcp_servers/simple
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
### Evals run but all tests fail
|
||||||
|
|
||||||
|
**Possible causes:**
|
||||||
|
|
||||||
|
1. Wrong tool names - check your server's tool definitions
|
||||||
|
2. Incorrect argument names - verify expected vs actual
|
||||||
|
3. Server not responding - check server logs
|
||||||
|
4. API key issues - verify LLM provider keys
|
||||||
|
|
||||||
|
**Debug with verbose output:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
arcade evals eval_file.py --api-key openai:YOUR_KEY -d
|
||||||
|
```
|
||||||
135
examples/evals/eval_arcade_gateway.py
Normal file
135
examples/evals/eval_arcade_gateway.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
"""Arcade Gateway evaluation - Loading tools from cloud-hosted toolkits.
|
||||||
|
|
||||||
|
This example demonstrates loading and evaluating tools from Arcade Gateway,
|
||||||
|
which provides access to pre-built toolkits (Math, GitHub, Slack, Linear, etc.).
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
1. Get your API key: https://docs.arcade.dev/en/get-started/setup/api-keys
|
||||||
|
2. Create an MCP Gateway at https://portal.arcade.dev
|
||||||
|
3. Add toolkits to your gateway (e.g., Math, GitHub, Slack)
|
||||||
|
4. Get your ARCADE_API_KEY and ARCADE_USER_ID from the portal
|
||||||
|
|
||||||
|
Full setup guide: https://docs.arcade.dev/en/guides/create-tools/mcp-gateways
|
||||||
|
|
||||||
|
Run:
|
||||||
|
# Set environment variables
|
||||||
|
export ARCADE_API_KEY=your_arcade_key
|
||||||
|
export ARCADE_USER_ID=your_user_id
|
||||||
|
|
||||||
|
# Run the evaluation
|
||||||
|
arcade evals examples/evals/eval_arcade_gateway.py \\
|
||||||
|
-p openai:gpt-4o \\
|
||||||
|
-k openai:YOUR_KEY \\
|
||||||
|
-o results.html -d
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
from arcade_evals import (
|
||||||
|
BinaryCritic,
|
||||||
|
EvalRubric,
|
||||||
|
EvalSuite,
|
||||||
|
ExpectedMCPToolCall,
|
||||||
|
tool_eval,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
ARCADE_API_KEY = os.environ.get("ARCADE_API_KEY", "YOUR_ARCADE_API_KEY_HERE")
|
||||||
|
ARCADE_USER_ID = os.environ.get("ARCADE_USER_ID", "YOUR_USER_ID_HERE")
|
||||||
|
|
||||||
|
default_rubric = EvalRubric(
|
||||||
|
fail_threshold=0.7,
|
||||||
|
warn_threshold=0.9,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# EVAL SUITE
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@tool_eval()
|
||||||
|
async def eval_arcade_gateway() -> EvalSuite:
|
||||||
|
"""Evaluate Math toolkit from Arcade Gateway."""
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="Arcade Gateway - Math Toolkit",
|
||||||
|
system_message="You are a helpful math assistant. Use tools to perform calculations.",
|
||||||
|
rubric=default_rubric,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n Loading Arcade Gateway...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
suite.add_arcade_gateway(
|
||||||
|
gateway_slug="Math",
|
||||||
|
arcade_api_key=ARCADE_API_KEY,
|
||||||
|
arcade_user_id=ARCADE_USER_ID,
|
||||||
|
),
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
print(" ✓ Arcade Gateway (Math toolkit)")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print(" ✗ Arcade Gateway - timeout")
|
||||||
|
return suite
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Arcade Gateway - {type(e).__name__}: {e}")
|
||||||
|
return suite
|
||||||
|
|
||||||
|
# Test Case 1: Simple addition
|
||||||
|
suite.add_case(
|
||||||
|
name="Simple addition - 10 + 5",
|
||||||
|
user_message="What is 10 plus 5?",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedMCPToolCall(
|
||||||
|
tool_name="Math_Add",
|
||||||
|
args={"a": 10, "b": 5},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="a", weight=0.5),
|
||||||
|
BinaryCritic(critic_field="b", weight=0.5),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test Case 2: Larger numbers
|
||||||
|
suite.add_case(
|
||||||
|
name="Addition - 123 + 456",
|
||||||
|
user_message="Calculate 123 + 456",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedMCPToolCall(
|
||||||
|
tool_name="Math_Add",
|
||||||
|
args={"a": 123, "b": 456},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="a", weight=0.5),
|
||||||
|
BinaryCritic(critic_field="b", weight=0.5),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test Case 3: Conversational context
|
||||||
|
suite.add_case(
|
||||||
|
name="Addition with context",
|
||||||
|
user_message="Now add them together",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedMCPToolCall(
|
||||||
|
tool_name="Math_Add",
|
||||||
|
args={"a": 50, "b": 25},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="a", weight=0.5),
|
||||||
|
BinaryCritic(critic_field="b", weight=0.5),
|
||||||
|
],
|
||||||
|
additional_messages=[
|
||||||
|
{"role": "user", "content": "I have two numbers: 50 and 25"},
|
||||||
|
{"role": "assistant", "content": "Great! I'll remember those numbers."},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return suite
|
||||||
229
examples/evals/eval_comprehensive_comparison.py
Normal file
229
examples/evals/eval_comprehensive_comparison.py
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
"""Comprehensive comparison across multiple tool sources.
|
||||||
|
|
||||||
|
This example demonstrates comparative evaluations across different sources:
|
||||||
|
- Arcade Gateway (cloud toolkits)
|
||||||
|
- Local stdio MCP servers
|
||||||
|
- Dict-based tool definitions (baseline)
|
||||||
|
|
||||||
|
Run:
|
||||||
|
arcade evals examples/evals/eval_comprehensive_comparison.py \\
|
||||||
|
-p "openai:gpt-4o anthropic:claude-sonnet-4-5-20250929" \\
|
||||||
|
-k openai:YOUR_KEY -k anthropic:YOUR_KEY \\
|
||||||
|
-o comparison.html -d
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
from arcade_evals import (
|
||||||
|
BinaryCritic,
|
||||||
|
EvalRubric,
|
||||||
|
EvalSuite,
|
||||||
|
ExpectedMCPToolCall,
|
||||||
|
MCPToolDefinition,
|
||||||
|
SimilarityCritic,
|
||||||
|
tool_eval,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
ARCADE_API_KEY = os.environ.get("ARCADE_API_KEY", "YOUR_ARCADE_API_KEY_HERE")
|
||||||
|
ARCADE_USER_ID = os.environ.get("ARCADE_USER_ID", "YOUR_USER_ID_HERE")
|
||||||
|
|
||||||
|
EXAMPLES_DIR = os.path.dirname(os.path.dirname(__file__))
|
||||||
|
SIMPLE_SERVER_PATH = os.path.join(EXAMPLES_DIR, "mcp_servers", "simple")
|
||||||
|
|
||||||
|
SIMPLE_SERVER_COMMAND = [
|
||||||
|
"uv",
|
||||||
|
"run",
|
||||||
|
"--directory",
|
||||||
|
SIMPLE_SERVER_PATH,
|
||||||
|
"simple",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Baseline dict tool (for comparison)
|
||||||
|
DICT_SEARCH: MCPToolDefinition = {
|
||||||
|
"name": "search",
|
||||||
|
"description": "Search for information",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string", "description": "Search query"},
|
||||||
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
default_rubric = EvalRubric(
|
||||||
|
fail_threshold=0.6,
|
||||||
|
warn_threshold=0.8,
|
||||||
|
fail_on_tool_selection=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# EVAL SUITE
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@tool_eval()
|
||||||
|
async def eval_comprehensive_comparison() -> EvalSuite:
|
||||||
|
"""Compare tool performance across multiple sources."""
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="Multi-Source Comparative Evaluation",
|
||||||
|
system_message="You are a helpful assistant with various tools available.",
|
||||||
|
rubric=default_rubric,
|
||||||
|
)
|
||||||
|
|
||||||
|
loaded_tracks: list[str] = []
|
||||||
|
|
||||||
|
# Always add baseline dict tools
|
||||||
|
suite.add_tool_definitions([DICT_SEARCH], track="dict_baseline")
|
||||||
|
loaded_tracks.append("dict_baseline")
|
||||||
|
|
||||||
|
print("\n Loading tool sources...")
|
||||||
|
|
||||||
|
# Load from Arcade Gateway
|
||||||
|
try:
|
||||||
|
print(" → Loading Arcade Gateway (Math)...")
|
||||||
|
await asyncio.wait_for(
|
||||||
|
suite.add_arcade_gateway(
|
||||||
|
gateway_slug="Math",
|
||||||
|
arcade_api_key=ARCADE_API_KEY,
|
||||||
|
arcade_user_id=ARCADE_USER_ID,
|
||||||
|
track="arcade_gateway",
|
||||||
|
),
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
loaded_tracks.append("arcade_gateway")
|
||||||
|
print(" ✓ Arcade Gateway")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print(" ✗ Arcade Gateway - timeout")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Arcade Gateway - {type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
# Load from stdio MCP server
|
||||||
|
try:
|
||||||
|
print(" → Loading stdio MCP server (simple)...")
|
||||||
|
await asyncio.wait_for(
|
||||||
|
suite.add_mcp_stdio_server(
|
||||||
|
command=SIMPLE_SERVER_COMMAND,
|
||||||
|
env={"PYTHONUNBUFFERED": "1"},
|
||||||
|
track="stdio_simple",
|
||||||
|
),
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
loaded_tracks.append("stdio_simple")
|
||||||
|
print(" ✓ Stdio MCP server")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print(" ✗ Stdio MCP server - timeout")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Stdio MCP server - {type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
print(f"\n Loaded tracks: {loaded_tracks}\n")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# TEST CASE 1: Math operation (Arcade Gateway vs baseline)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
if "arcade_gateway" in loaded_tracks:
|
||||||
|
case1 = suite.add_comparative_case(
|
||||||
|
name="Math addition - Gateway vs Baseline",
|
||||||
|
user_message="What is 15 plus 27?",
|
||||||
|
)
|
||||||
|
case1.for_track(
|
||||||
|
"arcade_gateway",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedMCPToolCall(
|
||||||
|
tool_name="Math_Add",
|
||||||
|
args={"a": 15, "b": 27},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="a", weight=0.5),
|
||||||
|
BinaryCritic(critic_field="b", weight=0.5),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
case1.for_track(
|
||||||
|
"dict_baseline",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedMCPToolCall(
|
||||||
|
tool_name="search",
|
||||||
|
args={"query": "15 plus 27"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[SimilarityCritic(critic_field="query", weight=1.0, similarity_threshold=0.3)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# TEST CASE 2: Echo operation (stdio vs baseline)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
if "stdio_simple" in loaded_tracks:
|
||||||
|
case2 = suite.add_comparative_case(
|
||||||
|
name="Echo message - Stdio vs Baseline",
|
||||||
|
user_message="Echo 'Hello World'",
|
||||||
|
)
|
||||||
|
case2.for_track(
|
||||||
|
"stdio_simple",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedMCPToolCall(
|
||||||
|
tool_name="echo",
|
||||||
|
args={"message": "Hello World"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="message", weight=1.0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
case2.for_track(
|
||||||
|
"dict_baseline",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedMCPToolCall(
|
||||||
|
tool_name="search",
|
||||||
|
args={"query": "Hello World"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[SimilarityCritic(critic_field="query", weight=1.0, similarity_threshold=0.5)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# TEST CASE 3: Conversational context
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
if "arcade_gateway" in loaded_tracks:
|
||||||
|
case3 = suite.add_comparative_case(
|
||||||
|
name="Math with context",
|
||||||
|
user_message="Now add them together",
|
||||||
|
additional_messages=[
|
||||||
|
{"role": "user", "content": "I have two numbers: 50 and 25"},
|
||||||
|
{"role": "assistant", "content": "I'll remember those numbers."},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
case3.for_track(
|
||||||
|
"arcade_gateway",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedMCPToolCall(
|
||||||
|
tool_name="Math_Add",
|
||||||
|
args={"a": 50, "b": 25},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="a", weight=0.5),
|
||||||
|
BinaryCritic(critic_field="b", weight=0.5),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
case3.for_track(
|
||||||
|
"dict_baseline",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedMCPToolCall(
|
||||||
|
tool_name="search",
|
||||||
|
args={"query": "50 plus 25"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[SimilarityCritic(critic_field="query", weight=1.0, similarity_threshold=0.3)],
|
||||||
|
)
|
||||||
|
|
||||||
|
return suite
|
||||||
142
examples/evals/eval_http_mcp_server.py
Normal file
142
examples/evals/eval_http_mcp_server.py
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
"""Remote HTTP/SSE MCP server evaluation.
|
||||||
|
|
||||||
|
This example demonstrates loading and evaluating tools from remote MCP servers
|
||||||
|
accessible via HTTP or Server-Sent Events (SSE).
|
||||||
|
|
||||||
|
NOTE: This requires a running HTTP MCP server. Update the configuration below
|
||||||
|
with your server details.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
arcade evals examples/evals/eval_http_mcp_server.py \\
|
||||||
|
-p openai:gpt-4o \\
|
||||||
|
-k openai:YOUR_KEY \\
|
||||||
|
-o results.html -d
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
from arcade_evals import (
|
||||||
|
BinaryCritic,
|
||||||
|
EvalRubric,
|
||||||
|
EvalSuite,
|
||||||
|
ExpectedMCPToolCall,
|
||||||
|
tool_eval,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CONFIGURATION - Update these for your HTTP MCP server
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Example: GitHub Copilot MCP (requires GitHub token)
|
||||||
|
HTTP_MCP_URL = os.environ.get("MCP_SERVER_URL", "https://api.githubcopilot.com/mcp/")
|
||||||
|
HTTP_MCP_TOKEN = os.environ.get("GITHUB_PAT", "YOUR_GITHUB_TOKEN_HERE")
|
||||||
|
|
||||||
|
# Example: SSE-based MCP server
|
||||||
|
SSE_MCP_URL = os.environ.get("SSE_MCP_URL", "https://mcp.example.com/sse")
|
||||||
|
|
||||||
|
default_rubric = EvalRubric(
|
||||||
|
fail_threshold=0.7,
|
||||||
|
warn_threshold=0.9,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# EVAL SUITE - HTTP MCP Server
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@tool_eval()
|
||||||
|
async def eval_http_mcp_server() -> EvalSuite:
|
||||||
|
"""Evaluate tools from HTTP MCP server."""
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="HTTP MCP Server Evaluation",
|
||||||
|
system_message="You are a helpful assistant with access to remote tools.",
|
||||||
|
rubric=default_rubric,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n Loading HTTP MCP server...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
suite.add_mcp_server(
|
||||||
|
url=HTTP_MCP_URL,
|
||||||
|
headers={"Authorization": f"Bearer {HTTP_MCP_TOKEN}"},
|
||||||
|
use_sse=False, # Use HTTP streaming
|
||||||
|
),
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
print(" ✓ HTTP MCP server")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print(" ✗ HTTP MCP server - timeout")
|
||||||
|
return suite
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ HTTP MCP server - {type(e).__name__}: {e}")
|
||||||
|
return suite
|
||||||
|
|
||||||
|
# Add test cases based on your server's tools
|
||||||
|
# Example: If your server has an echo tool
|
||||||
|
suite.add_case(
|
||||||
|
name="HTTP server tool call",
|
||||||
|
user_message="Echo 'Hello from HTTP'",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedMCPToolCall(
|
||||||
|
tool_name="echo", # Adjust to match your server's tool names
|
||||||
|
args={"message": "Hello from HTTP"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="message", weight=1.0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return suite
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# EVAL SUITE - SSE MCP Server
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@tool_eval()
|
||||||
|
async def eval_sse_mcp_server() -> EvalSuite:
|
||||||
|
"""Evaluate tools from SSE MCP server."""
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="SSE MCP Server Evaluation",
|
||||||
|
system_message="You are a helpful assistant with access to SSE-connected tools.",
|
||||||
|
rubric=default_rubric,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n Loading SSE MCP server...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
suite.add_mcp_server(
|
||||||
|
url=SSE_MCP_URL,
|
||||||
|
use_sse=True, # Use SSE transport
|
||||||
|
headers={"Accept": "text/event-stream"},
|
||||||
|
),
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
print(" ✓ SSE MCP server")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print(" ✗ SSE MCP server - timeout")
|
||||||
|
return suite
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ SSE MCP server - {type(e).__name__}: {e}")
|
||||||
|
return suite
|
||||||
|
|
||||||
|
# Add test cases for your SSE server's tools
|
||||||
|
suite.add_case(
|
||||||
|
name="SSE server tool call",
|
||||||
|
user_message="Get status",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedMCPToolCall(
|
||||||
|
tool_name="get_status", # Adjust to match your server's tools
|
||||||
|
args={},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
return suite
|
||||||
124
examples/evals/eval_stdio_mcp_server.py
Normal file
124
examples/evals/eval_stdio_mcp_server.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""Local stdio MCP server evaluation.
|
||||||
|
|
||||||
|
This example demonstrates loading and evaluating tools from a local MCP server
|
||||||
|
running as a subprocess via stdio (standard input/output).
|
||||||
|
|
||||||
|
Run:
|
||||||
|
arcade evals examples/evals/eval_stdio_mcp_server.py \\
|
||||||
|
-p openai:gpt-4o \\
|
||||||
|
-k openai:YOUR_KEY \\
|
||||||
|
-o results.html -d
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
from arcade_evals import (
|
||||||
|
BinaryCritic,
|
||||||
|
EvalRubric,
|
||||||
|
EvalSuite,
|
||||||
|
ExpectedMCPToolCall,
|
||||||
|
tool_eval,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Path to the simple echo server
|
||||||
|
EXAMPLES_DIR = os.path.dirname(os.path.dirname(__file__))
|
||||||
|
SIMPLE_SERVER_PATH = os.path.join(EXAMPLES_DIR, "mcp_servers", "simple")
|
||||||
|
|
||||||
|
# Stdio server command
|
||||||
|
SIMPLE_SERVER_COMMAND = [
|
||||||
|
"uv",
|
||||||
|
"run",
|
||||||
|
"--directory",
|
||||||
|
SIMPLE_SERVER_PATH,
|
||||||
|
"simple",
|
||||||
|
]
|
||||||
|
|
||||||
|
default_rubric = EvalRubric(
|
||||||
|
fail_threshold=0.7,
|
||||||
|
warn_threshold=0.9,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# EVAL SUITE
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@tool_eval()
|
||||||
|
async def eval_stdio_simple_server() -> EvalSuite:
|
||||||
|
"""Evaluate simple echo server via stdio."""
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="Stdio MCP Server - Simple Echo",
|
||||||
|
system_message="You are a helpful assistant that can echo messages.",
|
||||||
|
rubric=default_rubric,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n Loading stdio MCP server (simple)...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
suite.add_mcp_stdio_server(
|
||||||
|
command=SIMPLE_SERVER_COMMAND,
|
||||||
|
env={"PYTHONUNBUFFERED": "1"},
|
||||||
|
),
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
print(" ✓ Simple MCP server (stdio)")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print(" ✗ Simple MCP server (stdio) - timeout")
|
||||||
|
return suite
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Simple MCP server (stdio) - {type(e).__name__}: {e}")
|
||||||
|
return suite
|
||||||
|
|
||||||
|
# Test Case 1: Simple echo
|
||||||
|
suite.add_case(
|
||||||
|
name="Echo - Hello",
|
||||||
|
user_message="Echo the word 'Hello'",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedMCPToolCall(
|
||||||
|
tool_name="echo",
|
||||||
|
args={"message": "Hello"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="message", weight=1.0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test Case 2: Echo with punctuation
|
||||||
|
suite.add_case(
|
||||||
|
name="Echo - Hello, World!",
|
||||||
|
user_message="Echo this: Hello, World!",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedMCPToolCall(
|
||||||
|
tool_name="echo",
|
||||||
|
args={"message": "Hello, World!"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="message", weight=1.0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test Case 3: Echo longer phrase
|
||||||
|
suite.add_case(
|
||||||
|
name="Echo - Longer phrase",
|
||||||
|
user_message="Please echo: The quick brown fox",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedMCPToolCall(
|
||||||
|
tool_name="echo",
|
||||||
|
args={"message": "The quick brown fox"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="message", weight=1.0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return suite
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from typing import TYPE_CHECKING, Any
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any, Optional
|
||||||
|
|
||||||
from arcade_core.schema import ToolDefinition
|
from arcade_core.schema import ToolDefinition
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
@ -323,14 +324,14 @@ def display_tool_messages(tool_messages: list[dict]) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def display_eval_results(results: list[list[dict[str, Any]]], show_details: bool = False) -> None:
|
def _display_results_to_console(
|
||||||
"""
|
output_console: Console,
|
||||||
Display evaluation results in a format inspired by pytest's output.
|
results: list[list[dict[str, Any]]],
|
||||||
|
show_details: bool = False,
|
||||||
Args:
|
failed_only: bool = False,
|
||||||
results: List of dictionaries containing evaluation results for each model.
|
original_counts: Optional[tuple[int, int, int, int]] = None,
|
||||||
show_details: Whether to show detailed results for each case.
|
) -> None:
|
||||||
"""
|
"""Display evaluation results to a Rich console."""
|
||||||
total_passed = 0
|
total_passed = 0
|
||||||
total_failed = 0
|
total_failed = 0
|
||||||
total_warned = 0
|
total_warned = 0
|
||||||
|
|
@ -343,9 +344,9 @@ def display_eval_results(results: list[list[dict[str, Any]]], show_details: bool
|
||||||
cases = model_results.get("cases", [])
|
cases = model_results.get("cases", [])
|
||||||
total_cases += len(cases)
|
total_cases += len(cases)
|
||||||
|
|
||||||
console.print(f"[bold]Model:[/bold] [bold magenta]{model}[/bold magenta]")
|
output_console.print(f"[bold]Model:[/bold] [bold magenta]{model}[/bold magenta]")
|
||||||
if show_details:
|
if show_details:
|
||||||
console.print(f"[bold magenta]{rubric}[/bold magenta]")
|
output_console.print(f"[bold magenta]{rubric}[/bold magenta]")
|
||||||
|
|
||||||
for case in cases:
|
for case in cases:
|
||||||
evaluation = case["evaluation"]
|
evaluation = case["evaluation"]
|
||||||
|
|
@ -365,24 +366,123 @@ def display_eval_results(results: list[list[dict[str, Any]]], show_details: bool
|
||||||
|
|
||||||
# Display one-line summary for each case with score as a percentage
|
# Display one-line summary for each case with score as a percentage
|
||||||
score_percentage = evaluation.score * 100
|
score_percentage = evaluation.score * 100
|
||||||
console.print(f"{status} {case['name']} -- Score: {score_percentage:.2f}%")
|
output_console.print(f"{status} {case['name']} -- Score: {score_percentage:.2f}%")
|
||||||
|
|
||||||
if show_details:
|
if show_details:
|
||||||
# Show detailed information for each case
|
# Show detailed information for each case
|
||||||
console.print(f"[bold]User Input:[/bold] {case['input']}\n")
|
output_console.print(f"[bold]User Input:[/bold] {case['input']}\n")
|
||||||
console.print("[bold]Details:[/bold]")
|
output_console.print("[bold]Details:[/bold]")
|
||||||
console.print(_format_evaluation(evaluation))
|
output_console.print(_format_evaluation(evaluation))
|
||||||
console.print("-" * 80)
|
output_console.print("-" * 80)
|
||||||
|
|
||||||
# Summary
|
output_console.print("")
|
||||||
summary = (
|
|
||||||
f"[bold]Summary -- [/bold]Total: {total_cases} -- [green]Passed: {total_passed}[/green]"
|
# Summary - use original counts if filtering, otherwise use current counts
|
||||||
)
|
if failed_only and original_counts:
|
||||||
if total_warned > 0:
|
# Unpack original counts
|
||||||
summary += f" -- [yellow]Warnings: {total_warned}[/yellow]"
|
orig_total, orig_passed, orig_failed, orig_warned = original_counts
|
||||||
if total_failed > 0:
|
|
||||||
summary += f" -- [red]Failed: {total_failed}[/red]"
|
# Show disclaimer before summary
|
||||||
console.print(summary + "\n")
|
output_console.print(
|
||||||
|
f"[bold yellow]Note: Showing only {total_cases} failed evaluation(s) (--only-failed)[/bold yellow]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build summary with original counts
|
||||||
|
summary = (
|
||||||
|
f"[bold]Summary -- [/bold]Total: {orig_total} -- [green]Passed: {orig_passed}[/green]"
|
||||||
|
)
|
||||||
|
if orig_warned > 0:
|
||||||
|
summary += f" -- [yellow]Warnings: {orig_warned}[/yellow]"
|
||||||
|
if orig_failed > 0:
|
||||||
|
summary += f" -- [red]Failed: {orig_failed}[/red]"
|
||||||
|
else:
|
||||||
|
# Normal summary with current counts
|
||||||
|
summary = (
|
||||||
|
f"[bold]Summary -- [/bold]Total: {total_cases} -- [green]Passed: {total_passed}[/green]"
|
||||||
|
)
|
||||||
|
if total_warned > 0:
|
||||||
|
summary += f" -- [yellow]Warnings: {total_warned}[/yellow]"
|
||||||
|
if total_failed > 0:
|
||||||
|
summary += f" -- [red]Failed: {total_failed}[/red]"
|
||||||
|
|
||||||
|
output_console.print(summary + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def display_eval_results(
|
||||||
|
results: list[list[dict[str, Any]]],
|
||||||
|
show_details: bool = False,
|
||||||
|
output_file: Optional[str] = None,
|
||||||
|
failed_only: bool = False,
|
||||||
|
original_counts: Optional[tuple[int, int, int, int]] = None,
|
||||||
|
output_formats: list[str] | None = None,
|
||||||
|
include_context: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Display evaluation results in a format inspired by pytest's output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
results: List of dictionaries containing evaluation results for each model.
|
||||||
|
show_details: Whether to show detailed results for each case.
|
||||||
|
output_file: Optional file path to write results to.
|
||||||
|
failed_only: Whether only failed cases are being displayed (adds disclaimer).
|
||||||
|
original_counts: Optional tuple of (total_cases, total_passed, total_failed, total_warned)
|
||||||
|
from before filtering. Used when failed_only is True.
|
||||||
|
output_formats: List of output formats for file output (e.g., ['txt', 'md', 'html']).
|
||||||
|
include_context: Whether to include system_message and additional_messages.
|
||||||
|
"""
|
||||||
|
# Always display to terminal with Rich formatting
|
||||||
|
try:
|
||||||
|
_display_results_to_console(console, results, show_details, failed_only, original_counts)
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]Error displaying results to console: {type(e).__name__}: {e}[/red]")
|
||||||
|
|
||||||
|
# Also write to file(s) if requested using the specified formatter(s)
|
||||||
|
if output_file and output_formats:
|
||||||
|
from arcade_cli.formatters import get_formatter
|
||||||
|
|
||||||
|
# Get base path without extension
|
||||||
|
base_path = Path(output_file)
|
||||||
|
base_name = base_path.stem
|
||||||
|
parent_dir = base_path.parent
|
||||||
|
|
||||||
|
try:
|
||||||
|
parent_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
except PermissionError:
|
||||||
|
console.print(f"[red]Error: Permission denied creating directory {parent_dir}[/red]")
|
||||||
|
return
|
||||||
|
except OSError as e:
|
||||||
|
console.print(f"[red]Error creating directory: {e}[/red]")
|
||||||
|
return
|
||||||
|
|
||||||
|
for fmt in output_formats:
|
||||||
|
# Define output_path early so it's available in exception handlers
|
||||||
|
output_path = parent_dir / f"{base_name}.{fmt}"
|
||||||
|
try:
|
||||||
|
formatter = get_formatter(fmt)
|
||||||
|
formatted_output = formatter.format(
|
||||||
|
results,
|
||||||
|
show_details=show_details,
|
||||||
|
failed_only=failed_only,
|
||||||
|
original_counts=original_counts,
|
||||||
|
include_context=include_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build output path with proper extension
|
||||||
|
output_path = parent_dir / f"{base_name}.{formatter.file_extension}"
|
||||||
|
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(formatted_output)
|
||||||
|
|
||||||
|
console.print(f"[green]✓ Results written to {output_path}[/green]")
|
||||||
|
|
||||||
|
except PermissionError:
|
||||||
|
console.print(f"[red]Error: Permission denied writing to {output_path}[/red]")
|
||||||
|
except OSError as e:
|
||||||
|
console.print(f"[red]Error writing file: {e}[/red]")
|
||||||
|
except Exception as e:
|
||||||
|
console.print(
|
||||||
|
f"[red]Error formatting results ({fmt}): {type(e).__name__}: {e}[/red]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _format_evaluation(evaluation: "EvaluationResult") -> str:
|
def _format_evaluation(evaluation: "EvaluationResult") -> str:
|
||||||
|
|
|
||||||
515
libs/arcade-cli/arcade_cli/evals_runner.py
Normal file
515
libs/arcade-cli/arcade_cli/evals_runner.py
Normal file
|
|
@ -0,0 +1,515 @@
|
||||||
|
"""
|
||||||
|
Evaluation and capture mode execution logic for the CLI.
|
||||||
|
|
||||||
|
This module contains the async execution functions for running evaluations
|
||||||
|
and capture mode operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from arcade_cli.display import display_eval_results
|
||||||
|
from arcade_cli.formatters import get_capture_formatter
|
||||||
|
from arcade_cli.utils import ModelSpec, filter_failed_evaluations
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from arcade_evals import CaptureResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# All supported output formats
|
||||||
|
ALL_FORMATS = ["txt", "md", "html", "json"]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_output_formats(format_str: str, console: Console | None = None) -> list[str]:
|
||||||
|
"""
|
||||||
|
Parse output format string into a list of formats.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Single format: "md" -> ["md"]
|
||||||
|
- Comma-separated: "md,html" -> ["md", "html"]
|
||||||
|
- "all" keyword: "all" -> ["txt", "md", "html", "json"]
|
||||||
|
|
||||||
|
Args:
|
||||||
|
format_str: The format string from CLI.
|
||||||
|
console: Optional Rich console for error messages (unused now - raises instead).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of valid format strings.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If any invalid formats are provided.
|
||||||
|
"""
|
||||||
|
if format_str.lower() == "all":
|
||||||
|
return ALL_FORMATS.copy()
|
||||||
|
|
||||||
|
formats = [f.strip().lower() for f in format_str.split(",")]
|
||||||
|
valid_formats = [f for f in formats if f in ALL_FORMATS]
|
||||||
|
invalid_formats = [f for f in formats if f and f not in ALL_FORMATS]
|
||||||
|
|
||||||
|
# Fail fast on invalid formats (parse-time validation)
|
||||||
|
if invalid_formats:
|
||||||
|
valid_list = ", ".join(ALL_FORMATS)
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid format(s): {', '.join(invalid_formats)}. Valid formats: {valid_list}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return valid_formats
|
||||||
|
|
||||||
|
|
||||||
|
# --- Result Types for Error Handling ---
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EvalTaskResult:
|
||||||
|
"""Result of running a single evaluation task."""
|
||||||
|
|
||||||
|
suite_name: str
|
||||||
|
model: str
|
||||||
|
provider: str
|
||||||
|
success: bool
|
||||||
|
result: Any | None = None # EvalResult on success
|
||||||
|
error: str | None = None
|
||||||
|
error_type: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
"""Get display name in format 'provider/model'."""
|
||||||
|
return f"{self.provider}/{self.model}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_success(
|
||||||
|
cls, suite_name: str, model: str, provider: str, result: Any
|
||||||
|
) -> EvalTaskResult:
|
||||||
|
"""Create a successful result."""
|
||||||
|
return cls(
|
||||||
|
suite_name=suite_name, model=model, provider=provider, success=True, result=result
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_error(
|
||||||
|
cls, suite_name: str, model: str, provider: str, error: Exception
|
||||||
|
) -> EvalTaskResult:
|
||||||
|
"""Create a failed result from an exception."""
|
||||||
|
return cls(
|
||||||
|
suite_name=suite_name,
|
||||||
|
model=model,
|
||||||
|
provider=provider,
|
||||||
|
success=False,
|
||||||
|
error=str(error),
|
||||||
|
error_type=type(error).__name__,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CaptureTaskResult:
|
||||||
|
"""Result of running a single capture task."""
|
||||||
|
|
||||||
|
suite_name: str
|
||||||
|
model: str
|
||||||
|
provider: str
|
||||||
|
success: bool
|
||||||
|
result: list[CaptureResult] | None = None # List of CaptureResult on success
|
||||||
|
error: str | None = None
|
||||||
|
error_type: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
"""Get display name in format 'provider/model'."""
|
||||||
|
return f"{self.provider}/{self.model}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_success(
|
||||||
|
cls, suite_name: str, model: str, provider: str, result: list[CaptureResult]
|
||||||
|
) -> CaptureTaskResult:
|
||||||
|
"""Create a successful result."""
|
||||||
|
return cls(
|
||||||
|
suite_name=suite_name, model=model, provider=provider, success=True, result=result
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_error(
|
||||||
|
cls, suite_name: str, model: str, provider: str, error: Exception
|
||||||
|
) -> CaptureTaskResult:
|
||||||
|
"""Create a failed result from an exception."""
|
||||||
|
return cls(
|
||||||
|
suite_name=suite_name,
|
||||||
|
model=model,
|
||||||
|
provider=provider,
|
||||||
|
success=False,
|
||||||
|
error=str(error),
|
||||||
|
error_type=type(error).__name__,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Task Wrappers with Error Handling ---
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_eval_task(
|
||||||
|
suite_func: Callable[..., Any],
|
||||||
|
model_spec: ModelSpec,
|
||||||
|
max_concurrent: int,
|
||||||
|
include_context: bool = False,
|
||||||
|
) -> EvalTaskResult:
|
||||||
|
"""
|
||||||
|
Run a single evaluation task with error handling.
|
||||||
|
|
||||||
|
Returns EvalTaskResult with success/failure info instead of raising.
|
||||||
|
"""
|
||||||
|
suite_name = suite_func.__name__
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await suite_func(
|
||||||
|
provider_api_key=model_spec.api_key,
|
||||||
|
model=model_spec.model,
|
||||||
|
max_concurrency=max_concurrent,
|
||||||
|
provider=model_spec.provider.value,
|
||||||
|
include_context=include_context,
|
||||||
|
)
|
||||||
|
return EvalTaskResult.from_success(
|
||||||
|
suite_name, model_spec.model, model_spec.provider.value, result
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Evaluation task failed: suite=%s, model=%s, provider=%s, error=%s: %s",
|
||||||
|
suite_name,
|
||||||
|
model_spec.model,
|
||||||
|
model_spec.provider.value,
|
||||||
|
type(e).__name__,
|
||||||
|
str(e),
|
||||||
|
exc_info=True, # Include full traceback for debugging
|
||||||
|
)
|
||||||
|
return EvalTaskResult.from_error(suite_name, model_spec.model, model_spec.provider.value, e)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_capture_task(
|
||||||
|
suite_func: Callable[..., Any],
|
||||||
|
model_spec: ModelSpec,
|
||||||
|
max_concurrent: int,
|
||||||
|
include_context: bool,
|
||||||
|
) -> CaptureTaskResult:
|
||||||
|
"""
|
||||||
|
Run a single capture task with error handling.
|
||||||
|
|
||||||
|
Returns CaptureTaskResult with success/failure info instead of raising.
|
||||||
|
"""
|
||||||
|
suite_name = suite_func.__name__
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await suite_func(
|
||||||
|
provider_api_key=model_spec.api_key,
|
||||||
|
model=model_spec.model,
|
||||||
|
max_concurrency=max_concurrent,
|
||||||
|
provider=model_spec.provider.value,
|
||||||
|
capture_mode=True,
|
||||||
|
include_context=include_context,
|
||||||
|
)
|
||||||
|
return CaptureTaskResult.from_success(
|
||||||
|
suite_name, model_spec.model, model_spec.provider.value, result
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Capture task failed: suite=%s, model=%s, provider=%s, error=%s: %s",
|
||||||
|
suite_name,
|
||||||
|
model_spec.model,
|
||||||
|
model_spec.provider.value,
|
||||||
|
type(e).__name__,
|
||||||
|
str(e),
|
||||||
|
exc_info=True, # Include full traceback for debugging
|
||||||
|
)
|
||||||
|
return CaptureTaskResult.from_error(
|
||||||
|
suite_name, model_spec.model, model_spec.provider.value, e
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Main Runner Functions ---
|
||||||
|
|
||||||
|
|
||||||
|
async def run_evaluations(
|
||||||
|
eval_suites: list[Callable[..., Any]],
|
||||||
|
model_specs: list[ModelSpec],
|
||||||
|
max_concurrent: int,
|
||||||
|
show_details: bool,
|
||||||
|
output_file: str | None,
|
||||||
|
output_format: str,
|
||||||
|
failed_only: bool,
|
||||||
|
console: Console,
|
||||||
|
include_context: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Run evaluation suites and display results.
|
||||||
|
|
||||||
|
Individual task failures are caught and reported without crashing the entire batch.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
eval_suites: List of decorated evaluation suite functions.
|
||||||
|
model_specs: List of ModelSpec objects containing provider, model, and API key.
|
||||||
|
max_concurrent: Maximum concurrent evaluations.
|
||||||
|
show_details: Whether to show detailed results.
|
||||||
|
output_file: Optional file path to write results.
|
||||||
|
output_format: Format for file output ('txt', 'md').
|
||||||
|
failed_only: Whether to show only failed evaluations.
|
||||||
|
console: Rich console for output.
|
||||||
|
include_context: Whether to include system_message and additional_messages.
|
||||||
|
"""
|
||||||
|
tasks = []
|
||||||
|
|
||||||
|
for suite_func in eval_suites:
|
||||||
|
console.print(
|
||||||
|
Text.assemble(
|
||||||
|
("Running evaluations in ", "bold"),
|
||||||
|
(suite_func.__name__, "bold blue"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for model_spec in model_specs:
|
||||||
|
task = asyncio.create_task(
|
||||||
|
_run_eval_task(
|
||||||
|
suite_func=suite_func,
|
||||||
|
model_spec=model_spec,
|
||||||
|
max_concurrent=max_concurrent,
|
||||||
|
include_context=include_context,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tasks.append(task)
|
||||||
|
|
||||||
|
# Track progress with Rich progress bar (compatible with Rich console)
|
||||||
|
# Note: task_results is collected synchronously as each async task completes.
|
||||||
|
# The append() is atomic in CPython due to the GIL, and we await each future
|
||||||
|
# sequentially within the for-loop, so this is safe.
|
||||||
|
task_results: list[EvalTaskResult] = []
|
||||||
|
with Progress(
|
||||||
|
SpinnerColumn(),
|
||||||
|
TextColumn("[progress.description]{task.description}"),
|
||||||
|
BarColumn(),
|
||||||
|
TaskProgressColumn(),
|
||||||
|
console=console,
|
||||||
|
transient=False,
|
||||||
|
) as progress:
|
||||||
|
task_id = progress.add_task("[cyan]Running evaluations...", total=len(tasks))
|
||||||
|
for f in asyncio.as_completed(tasks):
|
||||||
|
result = await f
|
||||||
|
task_results.append(result)
|
||||||
|
# Update progress with completed task info
|
||||||
|
progress.update(
|
||||||
|
task_id,
|
||||||
|
advance=1,
|
||||||
|
description=f"[cyan]Completed: {result.suite_name} ({result.display_name})",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Separate successes and failures
|
||||||
|
successful = [r for r in task_results if r.success]
|
||||||
|
failed = [r for r in task_results if not r.success]
|
||||||
|
|
||||||
|
# Report failures
|
||||||
|
if failed:
|
||||||
|
console.print(f"\n[bold yellow]⚠️ {len(failed)} evaluation(s) failed:[/bold yellow]")
|
||||||
|
for fail in failed:
|
||||||
|
console.print(
|
||||||
|
f" • {fail.suite_name} ({fail.display_name}): [red]{fail.error_type}[/red] - {fail.error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process successful results
|
||||||
|
# Normalize results structure: ensure each result is a list (for consistent formatting)
|
||||||
|
# - Regular evals return a single dict -> wrap in list
|
||||||
|
# - Comparative evals return a list of dicts -> keep as is
|
||||||
|
all_evaluations: list[list[dict[str, Any]]] = []
|
||||||
|
for r in successful:
|
||||||
|
if r.result is None:
|
||||||
|
continue
|
||||||
|
if isinstance(r.result, list):
|
||||||
|
# Comparative eval: already a list of results (one per track)
|
||||||
|
all_evaluations.append(r.result)
|
||||||
|
else:
|
||||||
|
# Regular eval: single dict, wrap in list for consistent structure
|
||||||
|
all_evaluations.append([r.result])
|
||||||
|
|
||||||
|
if not all_evaluations:
|
||||||
|
console.print("\n[bold red]❌ No evaluations completed successfully.[/bold red]")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Filter to show only failed evaluations if requested
|
||||||
|
original_counts = None
|
||||||
|
if failed_only:
|
||||||
|
all_evaluations, original_counts = filter_failed_evaluations(all_evaluations)
|
||||||
|
|
||||||
|
# Parse output_format as a list (handles comma-separated and "all")
|
||||||
|
output_formats = parse_output_formats(output_format, console)
|
||||||
|
|
||||||
|
display_eval_results(
|
||||||
|
all_evaluations,
|
||||||
|
show_details=show_details,
|
||||||
|
output_file=output_file,
|
||||||
|
failed_only=failed_only,
|
||||||
|
original_counts=original_counts,
|
||||||
|
output_formats=output_formats,
|
||||||
|
include_context=include_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Summary when there were failures
|
||||||
|
if failed:
|
||||||
|
console.print(f"\n[bold]Summary:[/bold] {len(successful)} succeeded, {len(failed)} failed")
|
||||||
|
|
||||||
|
|
||||||
|
async def run_capture(
|
||||||
|
eval_suites: list[Callable[..., Any]],
|
||||||
|
model_specs: list[ModelSpec],
|
||||||
|
max_concurrent: int,
|
||||||
|
include_context: bool,
|
||||||
|
output_file: str | None,
|
||||||
|
output_format: str,
|
||||||
|
console: Console,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Run evaluation suites in capture mode and output results.
|
||||||
|
|
||||||
|
Capture mode records tool calls without scoring them.
|
||||||
|
Individual task failures are caught and reported without crashing the entire batch.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
eval_suites: List of decorated evaluation suite functions.
|
||||||
|
model_specs: List of ModelSpec objects containing provider, model, and API key.
|
||||||
|
max_concurrent: Maximum concurrent operations.
|
||||||
|
include_context: Whether to include system_message and additional_messages.
|
||||||
|
output_file: Optional file path to write results.
|
||||||
|
output_format: Output format ('json', 'txt', 'md', 'html').
|
||||||
|
console: Rich console for output.
|
||||||
|
"""
|
||||||
|
tasks = []
|
||||||
|
|
||||||
|
for suite_func in eval_suites:
|
||||||
|
console.print(
|
||||||
|
Text.assemble(
|
||||||
|
("Capturing tool calls from ", "bold"),
|
||||||
|
(suite_func.__name__, "bold cyan"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for model_spec in model_specs:
|
||||||
|
task = asyncio.create_task(
|
||||||
|
_run_capture_task(
|
||||||
|
suite_func=suite_func,
|
||||||
|
model_spec=model_spec,
|
||||||
|
max_concurrent=max_concurrent,
|
||||||
|
include_context=include_context,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tasks.append(task)
|
||||||
|
|
||||||
|
# Track progress with Rich progress bar (compatible with Rich console)
|
||||||
|
# Note: task_results is collected synchronously as each async task completes.
|
||||||
|
# The append() is atomic in CPython due to the GIL, and we await each future
|
||||||
|
# sequentially within the for-loop, so this is safe.
|
||||||
|
task_results: list[CaptureTaskResult] = []
|
||||||
|
with Progress(
|
||||||
|
SpinnerColumn(),
|
||||||
|
TextColumn("[progress.description]{task.description}"),
|
||||||
|
BarColumn(),
|
||||||
|
TaskProgressColumn(),
|
||||||
|
console=console,
|
||||||
|
transient=False,
|
||||||
|
) as progress:
|
||||||
|
task_id = progress.add_task("[cyan]Capturing tool calls...", total=len(tasks))
|
||||||
|
for f in asyncio.as_completed(tasks):
|
||||||
|
result = await f
|
||||||
|
task_results.append(result)
|
||||||
|
# Update progress with completed task info
|
||||||
|
progress.update(
|
||||||
|
task_id,
|
||||||
|
advance=1,
|
||||||
|
description=f"[cyan]Completed: {result.suite_name} ({result.display_name})",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Separate successes and failures
|
||||||
|
successful = [r for r in task_results if r.success]
|
||||||
|
failed = [r for r in task_results if not r.success]
|
||||||
|
|
||||||
|
# Report failures
|
||||||
|
if failed:
|
||||||
|
console.print(f"\n[bold yellow]⚠️ {len(failed)} capture(s) failed:[/bold yellow]")
|
||||||
|
for fail in failed:
|
||||||
|
console.print(
|
||||||
|
f" • {fail.suite_name} ({fail.display_name}): [red]{fail.error_type}[/red] - {fail.error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Collect successful captures
|
||||||
|
all_captures: list[CaptureResult] = []
|
||||||
|
for r in successful:
|
||||||
|
if r.result is not None:
|
||||||
|
all_captures.extend(r.result)
|
||||||
|
|
||||||
|
if not all_captures:
|
||||||
|
console.print("\n[bold red]❌ No captures completed successfully.[/bold red]")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Parse output formats (handles comma-separated and "all")
|
||||||
|
output_formats = parse_output_formats(output_format, console)
|
||||||
|
|
||||||
|
# Output to file(s) or console
|
||||||
|
if output_file:
|
||||||
|
# Get base path without extension
|
||||||
|
base_path = Path(output_file)
|
||||||
|
base_name = base_path.stem
|
||||||
|
parent_dir = base_path.parent
|
||||||
|
|
||||||
|
try:
|
||||||
|
parent_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
except PermissionError:
|
||||||
|
console.print(
|
||||||
|
f"\n[red]❌ Error: Permission denied creating directory {parent_dir}[/red]"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except OSError as e:
|
||||||
|
console.print(f"\n[red]❌ Error creating directory: {e}[/red]")
|
||||||
|
return
|
||||||
|
|
||||||
|
for fmt in output_formats:
|
||||||
|
# Define file_path early so it's available in exception handlers
|
||||||
|
file_path = parent_dir / f"{base_name}.{fmt}"
|
||||||
|
try:
|
||||||
|
formatter = get_capture_formatter(fmt)
|
||||||
|
formatted_output = formatter.format(all_captures, include_context=include_context)
|
||||||
|
|
||||||
|
# Build output path with proper extension
|
||||||
|
file_path = parent_dir / f"{base_name}.{formatter.file_extension}"
|
||||||
|
|
||||||
|
with open(file_path, "w", encoding="utf-8") as outfile:
|
||||||
|
outfile.write(formatted_output)
|
||||||
|
console.print(
|
||||||
|
f"\n[green]✓ Capture results written to[/green] [bold]{file_path}[/bold]"
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
console.print(f"\n[red]❌ {e}[/red]")
|
||||||
|
except PermissionError:
|
||||||
|
console.print(f"\n[red]❌ Error: Permission denied writing to {file_path}[/red]")
|
||||||
|
except OSError as e:
|
||||||
|
console.print(f"\n[red]❌ Error writing file: {e}[/red]")
|
||||||
|
else:
|
||||||
|
# Console output: always use JSON for best copy-paste experience
|
||||||
|
console.print("\n[bold]Capture Results:[/bold]")
|
||||||
|
json_formatter = get_capture_formatter("json")
|
||||||
|
console.print(json_formatter.format(all_captures, include_context=include_context))
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
total_cases = sum(len(cap.captured_cases) for cap in all_captures)
|
||||||
|
total_calls = sum(
|
||||||
|
sum(len(case.tool_calls) for case in cap.captured_cases) for cap in all_captures
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
f"\n[bold green]Captured {total_calls} tool calls across {total_cases} cases[/bold green]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Summary when there were failures
|
||||||
|
if failed:
|
||||||
|
console.print(f"\n[bold]Summary:[/bold] {len(successful)} succeeded, {len(failed)} failed")
|
||||||
102
libs/arcade-cli/arcade_cli/formatters/__init__.py
Normal file
102
libs/arcade-cli/arcade_cli/formatters/__init__.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""Formatters for evaluation and capture results output."""
|
||||||
|
|
||||||
|
from difflib import get_close_matches
|
||||||
|
|
||||||
|
from arcade_cli.formatters.base import CaptureFormatter, EvalResultFormatter
|
||||||
|
from arcade_cli.formatters.html import CaptureHtmlFormatter, HtmlFormatter
|
||||||
|
from arcade_cli.formatters.json import CaptureJsonFormatter, JsonFormatter
|
||||||
|
from arcade_cli.formatters.markdown import CaptureMarkdownFormatter, MarkdownFormatter
|
||||||
|
from arcade_cli.formatters.text import CaptureTextFormatter, TextFormatter
|
||||||
|
|
||||||
|
# Registry of available formatters for evaluations
|
||||||
|
FORMATTERS: dict[str, type[EvalResultFormatter]] = {
|
||||||
|
"txt": TextFormatter,
|
||||||
|
"md": MarkdownFormatter,
|
||||||
|
"html": HtmlFormatter,
|
||||||
|
"json": JsonFormatter,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Registry of available formatters for capture mode
|
||||||
|
CAPTURE_FORMATTERS: dict[str, type[CaptureFormatter]] = {
|
||||||
|
"json": CaptureJsonFormatter,
|
||||||
|
"txt": CaptureTextFormatter,
|
||||||
|
"md": CaptureMarkdownFormatter,
|
||||||
|
"html": CaptureHtmlFormatter,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_formatter(format_name: str) -> EvalResultFormatter:
|
||||||
|
"""
|
||||||
|
Get a formatter instance by name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
format_name: The format name (e.g., 'txt', 'md').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An instance of the appropriate formatter.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the format is not supported. Suggests similar format names if available.
|
||||||
|
"""
|
||||||
|
formatter_class = FORMATTERS.get(format_name.lower())
|
||||||
|
if formatter_class is None:
|
||||||
|
supported = list(FORMATTERS.keys())
|
||||||
|
|
||||||
|
# Try to find a close match for better error messages
|
||||||
|
close_matches = get_close_matches(format_name.lower(), supported, n=1, cutoff=0.6)
|
||||||
|
|
||||||
|
error_msg = f"Unsupported format '{format_name}'."
|
||||||
|
if close_matches:
|
||||||
|
error_msg += f" Did you mean '{close_matches[0]}'?"
|
||||||
|
error_msg += f" Supported formats: {', '.join(supported)}"
|
||||||
|
|
||||||
|
raise ValueError(error_msg)
|
||||||
|
return formatter_class()
|
||||||
|
|
||||||
|
|
||||||
|
def get_capture_formatter(format_name: str) -> CaptureFormatter:
|
||||||
|
"""
|
||||||
|
Get a capture formatter instance by name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
format_name: The format name (e.g., 'json', 'txt', 'md', 'html').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An instance of the appropriate formatter.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the format is not supported. Suggests similar format names if available.
|
||||||
|
"""
|
||||||
|
formatter_class = CAPTURE_FORMATTERS.get(format_name.lower())
|
||||||
|
if formatter_class is None:
|
||||||
|
supported = list(CAPTURE_FORMATTERS.keys())
|
||||||
|
|
||||||
|
close_matches = get_close_matches(format_name.lower(), supported, n=1, cutoff=0.6)
|
||||||
|
|
||||||
|
error_msg = f"Unsupported capture format '{format_name}'."
|
||||||
|
if close_matches:
|
||||||
|
error_msg += f" Did you mean '{close_matches[0]}'?"
|
||||||
|
error_msg += f" Supported formats: {', '.join(supported)}"
|
||||||
|
|
||||||
|
raise ValueError(error_msg)
|
||||||
|
return formatter_class()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Eval formatters
|
||||||
|
"FORMATTERS",
|
||||||
|
"EvalResultFormatter",
|
||||||
|
"HtmlFormatter",
|
||||||
|
"JsonFormatter",
|
||||||
|
"MarkdownFormatter",
|
||||||
|
"TextFormatter",
|
||||||
|
"get_formatter",
|
||||||
|
# Capture formatters
|
||||||
|
"CAPTURE_FORMATTERS",
|
||||||
|
"CaptureFormatter",
|
||||||
|
"CaptureHtmlFormatter",
|
||||||
|
"CaptureJsonFormatter",
|
||||||
|
"CaptureMarkdownFormatter",
|
||||||
|
"CaptureTextFormatter",
|
||||||
|
"get_capture_formatter",
|
||||||
|
]
|
||||||
791
libs/arcade-cli/arcade_cli/formatters/base.py
Normal file
791
libs/arcade-cli/arcade_cli/formatters/base.py
Normal file
|
|
@ -0,0 +1,791 @@
|
||||||
|
"""Base formatter for evaluation and capture results."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from arcade_evals import CaptureResult
|
||||||
|
|
||||||
|
# Type alias for capture results
|
||||||
|
CaptureResults = list["CaptureResult"]
|
||||||
|
|
||||||
|
# --- Type Aliases ---
|
||||||
|
# The results structure: list of suites, each containing list of model results
|
||||||
|
EvalResults = list[list[dict[str, Any]]]
|
||||||
|
|
||||||
|
# Model -> Suite -> Cases mapping
|
||||||
|
ModelSuiteGroups = dict[str, dict[str, list[dict[str, Any]]]]
|
||||||
|
|
||||||
|
# Statistics tuple: (total, passed, failed, warned)
|
||||||
|
EvalStats = tuple[int, int, int, int]
|
||||||
|
|
||||||
|
# Comparative grouping: model -> base_suite -> case_name -> {input, tracks: {track: case_result}}
|
||||||
|
ComparativeCaseData = dict[str, Any] # {input, tracks: {track_name: case_result}}
|
||||||
|
ComparativeSuiteData = dict[str, ComparativeCaseData] # case_name -> ComparativeCaseData
|
||||||
|
ComparativeGroups = dict[str, dict[str, ComparativeSuiteData]] # model -> suite -> cases
|
||||||
|
|
||||||
|
# --- Constants ---
|
||||||
|
# Maximum field value length before truncation (for display)
|
||||||
|
MAX_FIELD_DISPLAY_LENGTH = 60
|
||||||
|
TRUNCATION_SUFFIX = "..."
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_field_value(value: str, max_length: int = MAX_FIELD_DISPLAY_LENGTH) -> str:
|
||||||
|
"""
|
||||||
|
Truncate long field values for display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: The string value to potentially truncate.
|
||||||
|
max_length: Maximum allowed length (default: 60).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The original value if within limits, or truncated with "..." suffix.
|
||||||
|
"""
|
||||||
|
if len(value) > max_length:
|
||||||
|
return value[: max_length - len(TRUNCATION_SUFFIX)] + TRUNCATION_SUFFIX
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def group_results_by_model(
|
||||||
|
results: EvalResults,
|
||||||
|
) -> tuple[ModelSuiteGroups, int, int, int, int]:
|
||||||
|
"""
|
||||||
|
Group evaluation results by model and suite, collecting statistics.
|
||||||
|
|
||||||
|
This is the shared logic used by all formatters and display functions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
results: Nested list of evaluation results by suite and model.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of:
|
||||||
|
- model_groups: Dict mapping model -> suite -> list of cases
|
||||||
|
- total_passed: Count of passed evaluations
|
||||||
|
- total_failed: Count of failed evaluations
|
||||||
|
- total_warned: Count of warned evaluations
|
||||||
|
- total_cases: Total count of all cases
|
||||||
|
"""
|
||||||
|
total_passed = 0
|
||||||
|
total_failed = 0
|
||||||
|
total_warned = 0
|
||||||
|
total_cases = 0
|
||||||
|
model_groups: ModelSuiteGroups = {}
|
||||||
|
|
||||||
|
for eval_suite in results:
|
||||||
|
for model_results in eval_suite:
|
||||||
|
model = model_results.get("model", "Unknown Model")
|
||||||
|
|
||||||
|
# suite_name is always set by EvalSuite.evaluate()
|
||||||
|
suite_name = model_results.get("suite_name") or "Unnamed Suite"
|
||||||
|
|
||||||
|
cases = model_results.get("cases", [])
|
||||||
|
total_cases += len(cases)
|
||||||
|
|
||||||
|
if model not in model_groups:
|
||||||
|
model_groups[model] = {}
|
||||||
|
|
||||||
|
if suite_name not in model_groups[model]:
|
||||||
|
model_groups[model][suite_name] = []
|
||||||
|
|
||||||
|
for case in cases:
|
||||||
|
evaluation = case["evaluation"]
|
||||||
|
if evaluation.passed:
|
||||||
|
total_passed += 1
|
||||||
|
elif evaluation.warning:
|
||||||
|
total_warned += 1
|
||||||
|
else:
|
||||||
|
total_failed += 1
|
||||||
|
|
||||||
|
model_groups[model][suite_name].append(case)
|
||||||
|
|
||||||
|
return model_groups, total_passed, total_failed, total_warned, total_cases
|
||||||
|
|
||||||
|
|
||||||
|
def is_comparative_result(results: EvalResults) -> bool:
|
||||||
|
"""
|
||||||
|
Check if results contain comparative evaluations.
|
||||||
|
|
||||||
|
Comparative results have a 'track_name' field that indicates they came
|
||||||
|
from a multi-track comparative evaluation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
results: Nested list of evaluation results.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if any result has a 'track_name' field.
|
||||||
|
"""
|
||||||
|
for eval_suite in results:
|
||||||
|
for model_results in eval_suite:
|
||||||
|
if model_results.get("track_name"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_base_suite_name(suite_name: str, track_name: str) -> str:
|
||||||
|
"""
|
||||||
|
Extract the base suite name by removing the track suffix.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
"My Suite [track_a]" with track "track_a" -> "My Suite"
|
||||||
|
"Suite Name [some_track]" with track "some_track" -> "Suite Name"
|
||||||
|
"""
|
||||||
|
suffix = f" [{track_name}]"
|
||||||
|
if suite_name.endswith(suffix):
|
||||||
|
return suite_name[: -len(suffix)]
|
||||||
|
return suite_name
|
||||||
|
|
||||||
|
|
||||||
|
def group_comparative_by_case(
|
||||||
|
results: EvalResults,
|
||||||
|
) -> tuple[ComparativeGroups, int, int, int, int, dict[str, list[str]]]:
|
||||||
|
"""
|
||||||
|
Group comparative results by model, suite, and case name.
|
||||||
|
|
||||||
|
This allows showing the same case across different tracks side-by-side.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
results: Nested list of evaluation results (must be comparative).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of:
|
||||||
|
- comparative_groups: {model: {base_suite: {case_name: {input, tracks: {track: result}}}}}
|
||||||
|
- total_passed: Count of passed evaluations
|
||||||
|
- total_failed: Count of failed evaluations
|
||||||
|
- total_warned: Count of warned evaluations
|
||||||
|
- total_cases: Total count of all cases (unique case_name * tracks)
|
||||||
|
- suite_track_order: Dict mapping base_suite -> list of track names for that suite
|
||||||
|
"""
|
||||||
|
total_passed = 0
|
||||||
|
total_failed = 0
|
||||||
|
total_warned = 0
|
||||||
|
total_cases = 0
|
||||||
|
|
||||||
|
# Track order per suite (different suites can have different tracks)
|
||||||
|
suite_track_order: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
# Structure: model -> base_suite -> case_name -> {input, tracks: {track: case_result}}
|
||||||
|
comparative_groups: ComparativeGroups = {}
|
||||||
|
|
||||||
|
for eval_suite in results:
|
||||||
|
for model_results in eval_suite:
|
||||||
|
model = model_results.get("model", "Unknown Model")
|
||||||
|
suite_name = model_results.get("suite_name") or "Unnamed Suite"
|
||||||
|
track_name = model_results.get("track_name", "default")
|
||||||
|
|
||||||
|
# Extract base suite name (without track suffix)
|
||||||
|
base_suite = _extract_base_suite_name(suite_name, track_name)
|
||||||
|
|
||||||
|
# Track the order of tracks per suite
|
||||||
|
if base_suite not in suite_track_order:
|
||||||
|
suite_track_order[base_suite] = []
|
||||||
|
if track_name not in suite_track_order[base_suite]:
|
||||||
|
suite_track_order[base_suite].append(track_name)
|
||||||
|
|
||||||
|
cases = model_results.get("cases", [])
|
||||||
|
total_cases += len(cases)
|
||||||
|
|
||||||
|
if model not in comparative_groups:
|
||||||
|
comparative_groups[model] = {}
|
||||||
|
|
||||||
|
if base_suite not in comparative_groups[model]:
|
||||||
|
comparative_groups[model][base_suite] = {}
|
||||||
|
|
||||||
|
for case in cases:
|
||||||
|
case_name = case["name"]
|
||||||
|
evaluation = case["evaluation"]
|
||||||
|
|
||||||
|
# Count stats
|
||||||
|
if evaluation.passed:
|
||||||
|
total_passed += 1
|
||||||
|
elif evaluation.warning:
|
||||||
|
total_warned += 1
|
||||||
|
else:
|
||||||
|
total_failed += 1
|
||||||
|
|
||||||
|
# Initialize case entry if needed
|
||||||
|
if case_name not in comparative_groups[model][base_suite]:
|
||||||
|
comparative_groups[model][base_suite][case_name] = {
|
||||||
|
"input": case.get("input", ""),
|
||||||
|
"system_message": case.get("system_message"),
|
||||||
|
"additional_messages": case.get("additional_messages"),
|
||||||
|
"tracks": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Store this track's result for this case
|
||||||
|
comparative_groups[model][base_suite][case_name]["tracks"][track_name] = {
|
||||||
|
"evaluation": evaluation,
|
||||||
|
"name": case_name,
|
||||||
|
"input": case.get("input", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
comparative_groups,
|
||||||
|
total_passed,
|
||||||
|
total_failed,
|
||||||
|
total_warned,
|
||||||
|
total_cases,
|
||||||
|
suite_track_order,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_track_differences(
|
||||||
|
case_data: ComparativeCaseData,
|
||||||
|
track_order: list[str],
|
||||||
|
) -> dict[str, list[str]]:
|
||||||
|
"""
|
||||||
|
Compute which fields differ between tracks for a given case.
|
||||||
|
|
||||||
|
Compares each track against the first track (baseline).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_data: The case data with tracks.
|
||||||
|
track_order: List of track names in order.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping track_name -> list of field names that differ from baseline.
|
||||||
|
"""
|
||||||
|
differences: dict[str, list[str]] = {}
|
||||||
|
tracks = case_data.get("tracks", {})
|
||||||
|
|
||||||
|
if len(tracks) < 2 or not track_order:
|
||||||
|
return differences
|
||||||
|
|
||||||
|
# First track is baseline
|
||||||
|
baseline_track = track_order[0]
|
||||||
|
if baseline_track not in tracks:
|
||||||
|
return differences
|
||||||
|
|
||||||
|
baseline_result = tracks[baseline_track]
|
||||||
|
baseline_eval = baseline_result.get("evaluation")
|
||||||
|
if not baseline_eval or not hasattr(baseline_eval, "results"):
|
||||||
|
return differences
|
||||||
|
|
||||||
|
# Build baseline field values
|
||||||
|
baseline_fields: dict[str, Any] = {}
|
||||||
|
for critic_result in baseline_eval.results:
|
||||||
|
field = critic_result.get("field", "")
|
||||||
|
baseline_fields[field] = {
|
||||||
|
"actual": critic_result.get("actual"),
|
||||||
|
"match": critic_result.get("match"),
|
||||||
|
"score": critic_result.get("score"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compare other tracks to baseline
|
||||||
|
for track_name in track_order[1:]:
|
||||||
|
if track_name not in tracks:
|
||||||
|
continue
|
||||||
|
|
||||||
|
track_result = tracks[track_name]
|
||||||
|
track_eval = track_result.get("evaluation")
|
||||||
|
if not track_eval or not hasattr(track_eval, "results"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
diff_fields: list[str] = []
|
||||||
|
|
||||||
|
for critic_result in track_eval.results:
|
||||||
|
field = critic_result.get("field", "")
|
||||||
|
actual = critic_result.get("actual")
|
||||||
|
match = critic_result.get("match")
|
||||||
|
|
||||||
|
# Check if this field exists in baseline and differs
|
||||||
|
if field in baseline_fields:
|
||||||
|
baseline_data = baseline_fields[field]
|
||||||
|
# Different if actual value differs or match status differs
|
||||||
|
if actual != baseline_data["actual"] or match != baseline_data["match"]:
|
||||||
|
diff_fields.append(field)
|
||||||
|
else:
|
||||||
|
# Field exists in this track but not baseline
|
||||||
|
diff_fields.append(field)
|
||||||
|
|
||||||
|
differences[track_name] = diff_fields
|
||||||
|
|
||||||
|
return differences
|
||||||
|
|
||||||
|
|
||||||
|
# Type for case-first comparative grouping
|
||||||
|
# Structure: suite -> case_name -> model -> {input, tracks: {track: result}}
|
||||||
|
CaseFirstComparativeGroups = dict[str, dict[str, dict[str, dict[str, Any]]]]
|
||||||
|
|
||||||
|
|
||||||
|
def is_multi_model_comparative(results: EvalResults) -> bool:
|
||||||
|
"""
|
||||||
|
Check if comparative results contain multiple models.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
results: Nested list of evaluation results.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if this is a comparative result with more than one unique model.
|
||||||
|
"""
|
||||||
|
if not is_comparative_result(results):
|
||||||
|
return False
|
||||||
|
|
||||||
|
models: set[str] = set()
|
||||||
|
for eval_suite in results:
|
||||||
|
for model_results in eval_suite:
|
||||||
|
model = model_results.get("model", "Unknown")
|
||||||
|
models.add(model)
|
||||||
|
if len(models) > 1:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def group_comparative_by_case_first(
|
||||||
|
results: EvalResults,
|
||||||
|
) -> tuple[CaseFirstComparativeGroups, list[str], dict[str, list[str]], int, int, int, int]:
|
||||||
|
"""
|
||||||
|
Group comparative results by suite -> case -> model for case-first comparison.
|
||||||
|
|
||||||
|
When multiple models run the same comparative evaluation, this groups results
|
||||||
|
so the same case from different models appears together.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
results: Nested list of comparative evaluation results.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of:
|
||||||
|
- case_groups: {suite: {case_name: {model: {input, tracks: {track: result}}}}}
|
||||||
|
- model_order: List of model names in order of appearance
|
||||||
|
- suite_track_order: Dict mapping suite -> list of track names
|
||||||
|
- total_passed, total_failed, total_warned, total_cases
|
||||||
|
"""
|
||||||
|
total_passed = 0
|
||||||
|
total_failed = 0
|
||||||
|
total_warned = 0
|
||||||
|
total_cases = 0
|
||||||
|
|
||||||
|
model_order: list[str] = []
|
||||||
|
suite_track_order: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
# Structure: base_suite -> case_name -> model -> {input, tracks: {track: result}}
|
||||||
|
case_groups: CaseFirstComparativeGroups = {}
|
||||||
|
|
||||||
|
for eval_suite in results:
|
||||||
|
for model_results in eval_suite:
|
||||||
|
model = model_results.get("model", "Unknown Model")
|
||||||
|
suite_name = model_results.get("suite_name") or "Unnamed Suite"
|
||||||
|
track_name = model_results.get("track_name", "default")
|
||||||
|
|
||||||
|
# Track model order
|
||||||
|
if model not in model_order:
|
||||||
|
model_order.append(model)
|
||||||
|
|
||||||
|
# Extract base suite name (without track suffix)
|
||||||
|
base_suite = _extract_base_suite_name(suite_name, track_name)
|
||||||
|
|
||||||
|
# Track the order of tracks per suite
|
||||||
|
if base_suite not in suite_track_order:
|
||||||
|
suite_track_order[base_suite] = []
|
||||||
|
if track_name not in suite_track_order[base_suite]:
|
||||||
|
suite_track_order[base_suite].append(track_name)
|
||||||
|
|
||||||
|
cases = model_results.get("cases", [])
|
||||||
|
total_cases += len(cases)
|
||||||
|
|
||||||
|
# Initialize suite
|
||||||
|
if base_suite not in case_groups:
|
||||||
|
case_groups[base_suite] = {}
|
||||||
|
|
||||||
|
for case in cases:
|
||||||
|
case_name = case["name"]
|
||||||
|
evaluation = case["evaluation"]
|
||||||
|
|
||||||
|
# Count stats
|
||||||
|
if evaluation.passed:
|
||||||
|
total_passed += 1
|
||||||
|
elif evaluation.warning:
|
||||||
|
total_warned += 1
|
||||||
|
else:
|
||||||
|
total_failed += 1
|
||||||
|
|
||||||
|
# Initialize case
|
||||||
|
if case_name not in case_groups[base_suite]:
|
||||||
|
case_groups[base_suite][case_name] = {}
|
||||||
|
|
||||||
|
# Initialize model entry for this case
|
||||||
|
if model not in case_groups[base_suite][case_name]:
|
||||||
|
case_groups[base_suite][case_name][model] = {
|
||||||
|
"input": case.get("input", ""),
|
||||||
|
"system_message": case.get("system_message"),
|
||||||
|
"additional_messages": case.get("additional_messages"),
|
||||||
|
"tracks": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Store this track's result
|
||||||
|
case_groups[base_suite][case_name][model]["tracks"][track_name] = {
|
||||||
|
"evaluation": evaluation,
|
||||||
|
"name": case_name,
|
||||||
|
"input": case.get("input", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
case_groups,
|
||||||
|
model_order,
|
||||||
|
suite_track_order,
|
||||||
|
total_passed,
|
||||||
|
total_failed,
|
||||||
|
total_warned,
|
||||||
|
total_cases,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MULTI-MODEL HELPERS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def is_multi_model_eval(results: EvalResults) -> bool:
|
||||||
|
"""
|
||||||
|
Check if evaluation results contain multiple models.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
results: Nested list of evaluation results.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if more than one unique model is present.
|
||||||
|
"""
|
||||||
|
models: set[str] = set()
|
||||||
|
for eval_suite in results:
|
||||||
|
for model_results in eval_suite:
|
||||||
|
model = model_results.get("model", "Unknown")
|
||||||
|
models.add(model)
|
||||||
|
if len(models) > 1:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_multi_model_capture(captures: CaptureResults) -> bool:
|
||||||
|
"""
|
||||||
|
Check if capture results contain multiple models.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
captures: List of CaptureResult objects.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if more than one unique model is present.
|
||||||
|
"""
|
||||||
|
models = {c.model for c in captures}
|
||||||
|
return len(models) > 1
|
||||||
|
|
||||||
|
|
||||||
|
# Type for multi-model comparison: suite -> case -> model -> case_result
|
||||||
|
MultiModelComparisonData = dict[str, dict[str, dict[str, dict[str, Any]]]]
|
||||||
|
|
||||||
|
# Type for per-model stats: model -> {passed, failed, warned, total, pass_rate}
|
||||||
|
PerModelStats = dict[str, dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
def group_eval_for_comparison(
|
||||||
|
results: EvalResults,
|
||||||
|
) -> tuple[MultiModelComparisonData, list[str], PerModelStats]:
|
||||||
|
"""
|
||||||
|
Reorganize evaluation results for cross-model comparison.
|
||||||
|
|
||||||
|
Groups results by suite -> case -> model, enabling side-by-side tables.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
results: Nested list of evaluation results.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of:
|
||||||
|
- comparison_data: {suite: {case_name: {model: case_result}}}
|
||||||
|
- model_order: List of model names in order of appearance
|
||||||
|
- per_model_stats: {model: {passed, failed, warned, total, pass_rate}}
|
||||||
|
"""
|
||||||
|
comparison_data: MultiModelComparisonData = {}
|
||||||
|
model_order: list[str] = []
|
||||||
|
per_model_stats: PerModelStats = {}
|
||||||
|
|
||||||
|
for eval_suite in results:
|
||||||
|
for model_results in eval_suite:
|
||||||
|
model = model_results.get("model", "Unknown Model")
|
||||||
|
suite_name = model_results.get("suite_name") or "Unnamed Suite"
|
||||||
|
cases = model_results.get("cases", [])
|
||||||
|
|
||||||
|
# Track model order
|
||||||
|
if model not in model_order:
|
||||||
|
model_order.append(model)
|
||||||
|
|
||||||
|
# Initialize per-model stats
|
||||||
|
if model not in per_model_stats:
|
||||||
|
per_model_stats[model] = {
|
||||||
|
"passed": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"warned": 0,
|
||||||
|
"total": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize suite in comparison data
|
||||||
|
if suite_name not in comparison_data:
|
||||||
|
comparison_data[suite_name] = {}
|
||||||
|
|
||||||
|
for case in cases:
|
||||||
|
case_name = case["name"]
|
||||||
|
evaluation = case["evaluation"]
|
||||||
|
|
||||||
|
# Update per-model stats
|
||||||
|
per_model_stats[model]["total"] += 1
|
||||||
|
if evaluation.passed:
|
||||||
|
per_model_stats[model]["passed"] += 1
|
||||||
|
elif evaluation.warning:
|
||||||
|
per_model_stats[model]["warned"] += 1
|
||||||
|
else:
|
||||||
|
per_model_stats[model]["failed"] += 1
|
||||||
|
|
||||||
|
# Initialize case in suite
|
||||||
|
if case_name not in comparison_data[suite_name]:
|
||||||
|
comparison_data[suite_name][case_name] = {}
|
||||||
|
|
||||||
|
# Store this model's result for this case
|
||||||
|
comparison_data[suite_name][case_name][model] = {
|
||||||
|
"evaluation": evaluation,
|
||||||
|
"input": case.get("input", ""),
|
||||||
|
"name": case_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate pass rates
|
||||||
|
for _model, stats in per_model_stats.items():
|
||||||
|
if stats["total"] > 0:
|
||||||
|
stats["pass_rate"] = (stats["passed"] / stats["total"]) * 100
|
||||||
|
else:
|
||||||
|
stats["pass_rate"] = 0.0
|
||||||
|
|
||||||
|
return comparison_data, model_order, per_model_stats
|
||||||
|
|
||||||
|
|
||||||
|
def find_best_model(
|
||||||
|
case_models: dict[str, dict[str, Any]],
|
||||||
|
) -> tuple[str | None, float]:
|
||||||
|
"""
|
||||||
|
Find the model with the highest score for a case.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_models: Dict mapping model -> case_result with evaluation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (best_model_name, best_score). Returns (None, 0.0) if no models
|
||||||
|
or if all evaluations are missing.
|
||||||
|
Returns ("Tie", score) if multiple models share the highest score.
|
||||||
|
"""
|
||||||
|
if not case_models:
|
||||||
|
return None, 0.0
|
||||||
|
|
||||||
|
best_model: str | None = None
|
||||||
|
best_score = -1.0
|
||||||
|
tie = False
|
||||||
|
found_valid_evaluation = False
|
||||||
|
|
||||||
|
for model, case_result in case_models.items():
|
||||||
|
evaluation = case_result.get("evaluation")
|
||||||
|
if not evaluation:
|
||||||
|
continue
|
||||||
|
|
||||||
|
found_valid_evaluation = True
|
||||||
|
score = evaluation.score
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_model = model
|
||||||
|
tie = False
|
||||||
|
elif score == best_score:
|
||||||
|
tie = True
|
||||||
|
|
||||||
|
# Return 0.0 if no valid evaluations found (not -1.0)
|
||||||
|
if not found_valid_evaluation:
|
||||||
|
return None, 0.0
|
||||||
|
|
||||||
|
if tie:
|
||||||
|
return "Tie", best_score
|
||||||
|
|
||||||
|
return best_model, best_score
|
||||||
|
|
||||||
|
|
||||||
|
# Type for grouped captures: suite -> case_name -> {user_message, models: {model: [tool_calls]}}
|
||||||
|
GroupedCaptures = dict[str, dict[str, dict[str, Any]]]
|
||||||
|
|
||||||
|
|
||||||
|
def group_captures_by_case(
|
||||||
|
captures: CaptureResults,
|
||||||
|
) -> tuple[GroupedCaptures, list[str]]:
|
||||||
|
"""
|
||||||
|
Group capture results by suite and case for multi-model comparison.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
captures: List of CaptureResult objects.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of:
|
||||||
|
- grouped: {suite: {case_key: {user_message, system_message, track_name, models: {model: captured_case}}}}
|
||||||
|
- model_order: List of model names in order of appearance
|
||||||
|
|
||||||
|
Note: For comparative captures with tracks, case_key includes the track name
|
||||||
|
to keep them separate (e.g., "weather_case [track_a]").
|
||||||
|
"""
|
||||||
|
grouped: GroupedCaptures = {}
|
||||||
|
model_order: list[str] = []
|
||||||
|
|
||||||
|
for capture in captures:
|
||||||
|
suite_name = capture.suite_name
|
||||||
|
model = capture.model
|
||||||
|
|
||||||
|
# Track model order
|
||||||
|
if model not in model_order:
|
||||||
|
model_order.append(model)
|
||||||
|
|
||||||
|
# Initialize suite
|
||||||
|
if suite_name not in grouped:
|
||||||
|
grouped[suite_name] = {}
|
||||||
|
|
||||||
|
for case in capture.captured_cases:
|
||||||
|
# Include track_name in the key for comparative captures
|
||||||
|
track_name = getattr(case, "track_name", None)
|
||||||
|
case_key = f"{case.case_name} [{track_name}]" if track_name else case.case_name
|
||||||
|
|
||||||
|
# Initialize case
|
||||||
|
if case_key not in grouped[suite_name]:
|
||||||
|
grouped[suite_name][case_key] = {
|
||||||
|
"user_message": case.user_message,
|
||||||
|
"system_message": case.system_message,
|
||||||
|
"additional_messages": case.additional_messages,
|
||||||
|
"track_name": track_name,
|
||||||
|
"models": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Store this model's captured case
|
||||||
|
grouped[suite_name][case_key]["models"][model] = case
|
||||||
|
|
||||||
|
return grouped, model_order
|
||||||
|
|
||||||
|
|
||||||
|
def group_captures_by_case_then_track(
|
||||||
|
captures: CaptureResults,
|
||||||
|
) -> tuple[dict[str, dict[str, dict[str, Any]]], list[str], list[str | None]]:
|
||||||
|
"""
|
||||||
|
Group capture results by suite, case, then track for tab-based display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
captures: List of CaptureResult objects.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of:
|
||||||
|
- grouped: {suite: {base_case_name: {tracks: {track: {models: {model: case}}}, user_message, ...}}}
|
||||||
|
- model_order: List of model names in order
|
||||||
|
- track_order: List of track names in order (None for non-comparative)
|
||||||
|
"""
|
||||||
|
grouped: dict[str, dict[str, dict[str, Any]]] = {}
|
||||||
|
model_order: list[str] = []
|
||||||
|
track_order: list[str | None] = []
|
||||||
|
|
||||||
|
for capture in captures:
|
||||||
|
suite_name = capture.suite_name
|
||||||
|
model = capture.model
|
||||||
|
|
||||||
|
if model not in model_order:
|
||||||
|
model_order.append(model)
|
||||||
|
|
||||||
|
if suite_name not in grouped:
|
||||||
|
grouped[suite_name] = {}
|
||||||
|
|
||||||
|
for case in capture.captured_cases:
|
||||||
|
track_name = getattr(case, "track_name", None)
|
||||||
|
base_case_name = case.case_name
|
||||||
|
|
||||||
|
# Track order
|
||||||
|
if track_name and track_name not in track_order:
|
||||||
|
track_order.append(track_name)
|
||||||
|
|
||||||
|
# Initialize case
|
||||||
|
if base_case_name not in grouped[suite_name]:
|
||||||
|
grouped[suite_name][base_case_name] = {
|
||||||
|
"user_message": case.user_message,
|
||||||
|
"system_message": case.system_message,
|
||||||
|
"additional_messages": case.additional_messages,
|
||||||
|
"tracks": {}, # {track_name: {models: {model: case}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize track
|
||||||
|
track_key = track_name or "_default"
|
||||||
|
if track_key not in grouped[suite_name][base_case_name]["tracks"]:
|
||||||
|
grouped[suite_name][base_case_name]["tracks"][track_key] = {
|
||||||
|
"models": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Store case under track and model
|
||||||
|
grouped[suite_name][base_case_name]["tracks"][track_key]["models"][model] = case
|
||||||
|
|
||||||
|
# If no tracks, add None to track_order for consistent handling
|
||||||
|
if not track_order:
|
||||||
|
track_order = [None]
|
||||||
|
|
||||||
|
return grouped, model_order, track_order
|
||||||
|
|
||||||
|
|
||||||
|
class EvalResultFormatter(ABC):
|
||||||
|
"""
|
||||||
|
Abstract base class for evaluation result formatters.
|
||||||
|
|
||||||
|
Implement this class to add new output formats (txt, md, json, html, etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def file_extension(self) -> str:
|
||||||
|
"""Return the default file extension for this format (e.g., 'txt', 'md')."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def format(
|
||||||
|
self,
|
||||||
|
results: EvalResults,
|
||||||
|
show_details: bool = False,
|
||||||
|
failed_only: bool = False,
|
||||||
|
original_counts: EvalStats | None = None,
|
||||||
|
include_context: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Format evaluation results into a string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
results: Nested list of evaluation results by suite and model.
|
||||||
|
show_details: Whether to show detailed results for each case.
|
||||||
|
failed_only: Whether only failed cases are being displayed.
|
||||||
|
original_counts: Optional (total, passed, failed, warned) from before filtering.
|
||||||
|
include_context: Whether to include system_message and additional_messages.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string representation of the results.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureFormatter(ABC):
|
||||||
|
"""
|
||||||
|
Abstract base class for capture result formatters.
|
||||||
|
|
||||||
|
Implement this class to add new output formats for capture mode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def file_extension(self) -> str:
|
||||||
|
"""Return the default file extension for this format."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def format(
|
||||||
|
self,
|
||||||
|
captures: CaptureResults,
|
||||||
|
include_context: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Format capture results into a string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
captures: List of CaptureResult objects.
|
||||||
|
include_context: Whether to include system_message and additional_messages.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string representation of the capture results.
|
||||||
|
"""
|
||||||
|
...
|
||||||
2878
libs/arcade-cli/arcade_cli/formatters/html.py
Normal file
2878
libs/arcade-cli/arcade_cli/formatters/html.py
Normal file
File diff suppressed because it is too large
Load diff
690
libs/arcade-cli/arcade_cli/formatters/json.py
Normal file
690
libs/arcade-cli/arcade_cli/formatters/json.py
Normal file
|
|
@ -0,0 +1,690 @@
|
||||||
|
"""JSON formatter for evaluation and capture results."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from arcade_cli.formatters.base import (
|
||||||
|
CaptureFormatter,
|
||||||
|
CaptureResults,
|
||||||
|
EvalResultFormatter,
|
||||||
|
EvalResults,
|
||||||
|
EvalStats,
|
||||||
|
find_best_model,
|
||||||
|
group_comparative_by_case,
|
||||||
|
group_comparative_by_case_first,
|
||||||
|
group_eval_for_comparison,
|
||||||
|
group_results_by_model,
|
||||||
|
is_comparative_result,
|
||||||
|
is_multi_model_capture,
|
||||||
|
is_multi_model_comparative,
|
||||||
|
is_multi_model_eval,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JsonFormatter(EvalResultFormatter):
|
||||||
|
"""
|
||||||
|
JSON formatter for evaluation results.
|
||||||
|
|
||||||
|
Produces a structured JSON document containing all evaluation data,
|
||||||
|
suitable for programmatic processing, dashboards, or further analysis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_extension(self) -> str:
|
||||||
|
return "json"
|
||||||
|
|
||||||
|
def format(
|
||||||
|
self,
|
||||||
|
results: EvalResults,
|
||||||
|
show_details: bool = False,
|
||||||
|
failed_only: bool = False,
|
||||||
|
original_counts: EvalStats | None = None,
|
||||||
|
include_context: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Format evaluation results as JSON."""
|
||||||
|
# Check if this is a comparative evaluation
|
||||||
|
if is_comparative_result(results):
|
||||||
|
output = self._format_comparative(
|
||||||
|
results, show_details, failed_only, original_counts, include_context
|
||||||
|
)
|
||||||
|
elif is_multi_model_eval(results):
|
||||||
|
output = self._format_multi_model(
|
||||||
|
results, show_details, failed_only, original_counts, include_context
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
output = self._format_regular(
|
||||||
|
results, show_details, failed_only, original_counts, include_context
|
||||||
|
)
|
||||||
|
|
||||||
|
return json.dumps(output, indent=2, default=str)
|
||||||
|
|
||||||
|
def _format_regular(
|
||||||
|
self,
|
||||||
|
results: EvalResults,
|
||||||
|
show_details: bool = False,
|
||||||
|
failed_only: bool = False,
|
||||||
|
original_counts: EvalStats | None = None,
|
||||||
|
include_context: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Format regular (non-comparative) evaluation results."""
|
||||||
|
model_groups, total_passed, total_failed, total_warned, total_cases = (
|
||||||
|
group_results_by_model(results)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate pass rate
|
||||||
|
if total_cases > 0:
|
||||||
|
if failed_only and original_counts and original_counts[0] > 0:
|
||||||
|
pass_rate = (original_counts[1] / original_counts[0]) * 100
|
||||||
|
else:
|
||||||
|
pass_rate = (total_passed / total_cases) * 100
|
||||||
|
else:
|
||||||
|
pass_rate = 0
|
||||||
|
|
||||||
|
output: dict[str, Any] = {
|
||||||
|
"type": "evaluation",
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"summary": {
|
||||||
|
"total_cases": total_cases,
|
||||||
|
"passed": total_passed,
|
||||||
|
"failed": total_failed,
|
||||||
|
"warned": total_warned,
|
||||||
|
"pass_rate": round(pass_rate, 2),
|
||||||
|
},
|
||||||
|
"models": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed_only and original_counts:
|
||||||
|
output["summary"]["original_counts"] = {
|
||||||
|
"total": original_counts[0],
|
||||||
|
"passed": original_counts[1],
|
||||||
|
"failed": original_counts[2],
|
||||||
|
"warned": original_counts[3],
|
||||||
|
}
|
||||||
|
output["summary"]["filtered"] = True
|
||||||
|
|
||||||
|
# Build model results
|
||||||
|
for model, suites in model_groups.items():
|
||||||
|
output["models"][model] = {"suites": {}}
|
||||||
|
|
||||||
|
for suite_name, cases in suites.items():
|
||||||
|
suite_data: dict[str, Any] = {
|
||||||
|
"case_count": len(cases),
|
||||||
|
"cases": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for case in cases:
|
||||||
|
case_data = self._serialize_case(case, show_details, include_context)
|
||||||
|
suite_data["cases"].append(case_data)
|
||||||
|
|
||||||
|
output["models"][model]["suites"][suite_name] = suite_data
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
def _format_comparative(
|
||||||
|
self,
|
||||||
|
results: EvalResults,
|
||||||
|
show_details: bool = False,
|
||||||
|
failed_only: bool = False,
|
||||||
|
original_counts: EvalStats | None = None,
|
||||||
|
include_context: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Format comparative evaluation results."""
|
||||||
|
# Check if this is multi-model comparative - use case-first grouping
|
||||||
|
if is_multi_model_comparative(results):
|
||||||
|
return self._format_comparative_case_first(
|
||||||
|
results, show_details, failed_only, original_counts, include_context
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._format_comparative_single_model(
|
||||||
|
results, show_details, failed_only, original_counts, include_context
|
||||||
|
)
|
||||||
|
|
||||||
|
def _format_comparative_single_model(
|
||||||
|
self,
|
||||||
|
results: EvalResults,
|
||||||
|
show_details: bool = False,
|
||||||
|
failed_only: bool = False,
|
||||||
|
original_counts: EvalStats | None = None,
|
||||||
|
include_context: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Format single-model comparative evaluation results."""
|
||||||
|
(
|
||||||
|
comparative_groups,
|
||||||
|
total_passed,
|
||||||
|
total_failed,
|
||||||
|
total_warned,
|
||||||
|
total_cases,
|
||||||
|
suite_track_order,
|
||||||
|
) = group_comparative_by_case(results)
|
||||||
|
|
||||||
|
# Collect all unique tracks
|
||||||
|
all_tracks: list[str] = []
|
||||||
|
for tracks in suite_track_order.values():
|
||||||
|
for t in tracks:
|
||||||
|
if t not in all_tracks:
|
||||||
|
all_tracks.append(t)
|
||||||
|
|
||||||
|
# Calculate pass rate
|
||||||
|
if total_cases > 0:
|
||||||
|
if failed_only and original_counts and original_counts[0] > 0:
|
||||||
|
pass_rate = (original_counts[1] / original_counts[0]) * 100
|
||||||
|
else:
|
||||||
|
pass_rate = (total_passed / total_cases) * 100
|
||||||
|
else:
|
||||||
|
pass_rate = 0
|
||||||
|
|
||||||
|
output: dict[str, Any] = {
|
||||||
|
"type": "comparative_evaluation",
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"tracks": all_tracks,
|
||||||
|
"summary": {
|
||||||
|
"total_cases": total_cases,
|
||||||
|
"passed": total_passed,
|
||||||
|
"failed": total_failed,
|
||||||
|
"warned": total_warned,
|
||||||
|
"pass_rate": round(pass_rate, 2),
|
||||||
|
},
|
||||||
|
"models": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed_only and original_counts:
|
||||||
|
output["summary"]["original_counts"] = {
|
||||||
|
"total": original_counts[0],
|
||||||
|
"passed": original_counts[1],
|
||||||
|
"failed": original_counts[2],
|
||||||
|
"warned": original_counts[3],
|
||||||
|
}
|
||||||
|
output["summary"]["filtered"] = True
|
||||||
|
|
||||||
|
# Build model results
|
||||||
|
for model, suites in comparative_groups.items():
|
||||||
|
output["models"][model] = {"suites": {}}
|
||||||
|
|
||||||
|
for suite_name, cases in suites.items():
|
||||||
|
track_order = suite_track_order.get(suite_name, [])
|
||||||
|
|
||||||
|
suite_data: dict[str, Any] = {
|
||||||
|
"tracks": track_order,
|
||||||
|
"case_count": len(cases),
|
||||||
|
"cases": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for case_name, case_data in cases.items():
|
||||||
|
tracks_data = case_data.get("tracks", {})
|
||||||
|
|
||||||
|
case_output: dict[str, Any] = {
|
||||||
|
"input": case_data.get("input", ""),
|
||||||
|
"tracks": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add context if requested
|
||||||
|
if include_context:
|
||||||
|
system_msg = case_data.get("system_message")
|
||||||
|
addl_msgs = case_data.get("additional_messages")
|
||||||
|
if system_msg:
|
||||||
|
case_output["system_message"] = system_msg
|
||||||
|
if addl_msgs:
|
||||||
|
case_output["additional_messages"] = addl_msgs
|
||||||
|
|
||||||
|
for track_name in track_order:
|
||||||
|
if track_name not in tracks_data:
|
||||||
|
case_output["tracks"][track_name] = {"status": "missing"}
|
||||||
|
continue
|
||||||
|
|
||||||
|
track_result = tracks_data[track_name]
|
||||||
|
evaluation = track_result.get("evaluation")
|
||||||
|
|
||||||
|
if not evaluation:
|
||||||
|
case_output["tracks"][track_name] = {"status": "no_evaluation"}
|
||||||
|
continue
|
||||||
|
|
||||||
|
track_data: dict[str, Any] = {
|
||||||
|
"status": self._get_status(evaluation),
|
||||||
|
"score": round(evaluation.score * 100, 2),
|
||||||
|
"passed": evaluation.passed,
|
||||||
|
"warning": evaluation.warning,
|
||||||
|
}
|
||||||
|
|
||||||
|
if evaluation.failure_reason:
|
||||||
|
track_data["failure_reason"] = evaluation.failure_reason
|
||||||
|
|
||||||
|
if show_details and evaluation.results:
|
||||||
|
track_data["details"] = self._serialize_critic_results(
|
||||||
|
evaluation.results
|
||||||
|
)
|
||||||
|
|
||||||
|
case_output["tracks"][track_name] = track_data
|
||||||
|
|
||||||
|
suite_data["cases"][case_name] = case_output
|
||||||
|
|
||||||
|
output["models"][model]["suites"][suite_name] = suite_data
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
def _format_comparative_case_first(
|
||||||
|
self,
|
||||||
|
results: EvalResults,
|
||||||
|
show_details: bool = False,
|
||||||
|
failed_only: bool = False,
|
||||||
|
original_counts: EvalStats | None = None,
|
||||||
|
include_context: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Format multi-model comparative evaluation grouped by case first."""
|
||||||
|
# Get case-first grouping
|
||||||
|
(
|
||||||
|
case_groups,
|
||||||
|
model_order,
|
||||||
|
suite_track_order,
|
||||||
|
total_passed,
|
||||||
|
total_failed,
|
||||||
|
total_warned,
|
||||||
|
total_cases,
|
||||||
|
) = group_comparative_by_case_first(results)
|
||||||
|
|
||||||
|
# Collect all unique tracks
|
||||||
|
all_tracks: list[str] = []
|
||||||
|
for tracks in suite_track_order.values():
|
||||||
|
for t in tracks:
|
||||||
|
if t not in all_tracks:
|
||||||
|
all_tracks.append(t)
|
||||||
|
|
||||||
|
# Calculate pass rate
|
||||||
|
if total_cases > 0:
|
||||||
|
if failed_only and original_counts and original_counts[0] > 0:
|
||||||
|
pass_rate = (original_counts[1] / original_counts[0]) * 100
|
||||||
|
else:
|
||||||
|
pass_rate = (total_passed / total_cases) * 100
|
||||||
|
else:
|
||||||
|
pass_rate = 0
|
||||||
|
|
||||||
|
output: dict[str, Any] = {
|
||||||
|
"type": "multi_model_comparative_evaluation",
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"models": model_order,
|
||||||
|
"tracks": all_tracks,
|
||||||
|
"summary": {
|
||||||
|
"total_cases": total_cases,
|
||||||
|
"passed": total_passed,
|
||||||
|
"failed": total_failed,
|
||||||
|
"warned": total_warned,
|
||||||
|
"pass_rate": round(pass_rate, 2),
|
||||||
|
},
|
||||||
|
"grouped_by_case": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed_only and original_counts:
|
||||||
|
output["summary"]["original_counts"] = {
|
||||||
|
"total": original_counts[0],
|
||||||
|
"passed": original_counts[1],
|
||||||
|
"failed": original_counts[2],
|
||||||
|
"warned": original_counts[3],
|
||||||
|
}
|
||||||
|
output["summary"]["filtered"] = True
|
||||||
|
|
||||||
|
# Build case-first structure
|
||||||
|
for suite_name, cases in case_groups.items():
|
||||||
|
track_order = suite_track_order.get(suite_name, [])
|
||||||
|
output["grouped_by_case"][suite_name] = {"tracks": track_order, "cases": {}}
|
||||||
|
|
||||||
|
for case_name, model_data in cases.items():
|
||||||
|
first_model_data = next(iter(model_data.values()), {})
|
||||||
|
case_output: dict[str, Any] = {
|
||||||
|
"input": first_model_data.get("input", ""),
|
||||||
|
"models": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add context if requested
|
||||||
|
if include_context:
|
||||||
|
system_msg = first_model_data.get("system_message")
|
||||||
|
addl_msgs = first_model_data.get("additional_messages")
|
||||||
|
if system_msg:
|
||||||
|
case_output["system_message"] = system_msg
|
||||||
|
if addl_msgs:
|
||||||
|
case_output["additional_messages"] = addl_msgs
|
||||||
|
|
||||||
|
for model in model_order:
|
||||||
|
if model not in model_data:
|
||||||
|
case_output["models"][model] = {"status": "missing"}
|
||||||
|
continue
|
||||||
|
|
||||||
|
model_case_data = model_data[model]
|
||||||
|
tracks_data = model_case_data.get("tracks", {})
|
||||||
|
|
||||||
|
model_output: dict[str, Any] = {"tracks": {}}
|
||||||
|
|
||||||
|
for track_name in track_order:
|
||||||
|
if track_name not in tracks_data:
|
||||||
|
model_output["tracks"][track_name] = {"status": "missing"}
|
||||||
|
continue
|
||||||
|
|
||||||
|
track_result = tracks_data[track_name]
|
||||||
|
evaluation = track_result.get("evaluation")
|
||||||
|
|
||||||
|
if not evaluation:
|
||||||
|
model_output["tracks"][track_name] = {"status": "no_evaluation"}
|
||||||
|
continue
|
||||||
|
|
||||||
|
track_data: dict[str, Any] = {
|
||||||
|
"status": self._get_status(evaluation),
|
||||||
|
"score": round(evaluation.score * 100, 2),
|
||||||
|
"passed": evaluation.passed,
|
||||||
|
"warning": evaluation.warning,
|
||||||
|
}
|
||||||
|
|
||||||
|
if evaluation.failure_reason:
|
||||||
|
track_data["failure_reason"] = evaluation.failure_reason
|
||||||
|
|
||||||
|
if show_details and evaluation.results:
|
||||||
|
track_data["details"] = self._serialize_critic_results(
|
||||||
|
evaluation.results
|
||||||
|
)
|
||||||
|
|
||||||
|
model_output["tracks"][track_name] = track_data
|
||||||
|
|
||||||
|
case_output["models"][model] = model_output
|
||||||
|
|
||||||
|
output["grouped_by_case"][suite_name]["cases"][case_name] = case_output
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
def _format_multi_model(
|
||||||
|
self,
|
||||||
|
results: EvalResults,
|
||||||
|
show_details: bool = False,
|
||||||
|
failed_only: bool = False,
|
||||||
|
original_counts: EvalStats | None = None,
|
||||||
|
include_context: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Format multi-model evaluation results with comparison structure."""
|
||||||
|
comparison_data, model_order, per_model_stats = group_eval_for_comparison(results)
|
||||||
|
|
||||||
|
# Calculate totals
|
||||||
|
total_passed = sum(s["passed"] for s in per_model_stats.values())
|
||||||
|
total_failed = sum(s["failed"] for s in per_model_stats.values())
|
||||||
|
total_warned = sum(s["warned"] for s in per_model_stats.values())
|
||||||
|
total_cases = sum(s["total"] for s in per_model_stats.values())
|
||||||
|
|
||||||
|
# Calculate pass rate
|
||||||
|
if total_cases > 0:
|
||||||
|
if failed_only and original_counts and original_counts[0] > 0:
|
||||||
|
pass_rate = (original_counts[1] / original_counts[0]) * 100
|
||||||
|
else:
|
||||||
|
pass_rate = (total_passed / total_cases) * 100
|
||||||
|
else:
|
||||||
|
pass_rate = 0
|
||||||
|
|
||||||
|
output: dict[str, Any] = {
|
||||||
|
"type": "multi_model_evaluation",
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"models": model_order,
|
||||||
|
"summary": {
|
||||||
|
"total_evaluations": total_cases,
|
||||||
|
"unique_cases": sum(len(cases) for cases in comparison_data.values()),
|
||||||
|
"passed": total_passed,
|
||||||
|
"failed": total_failed,
|
||||||
|
"warned": total_warned,
|
||||||
|
"pass_rate": round(pass_rate, 2),
|
||||||
|
},
|
||||||
|
"per_model_stats": {},
|
||||||
|
"comparison": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed_only and original_counts:
|
||||||
|
output["summary"]["original_counts"] = {
|
||||||
|
"total": original_counts[0],
|
||||||
|
"passed": original_counts[1],
|
||||||
|
"failed": original_counts[2],
|
||||||
|
"warned": original_counts[3],
|
||||||
|
}
|
||||||
|
output["summary"]["filtered"] = True
|
||||||
|
|
||||||
|
# Per-model statistics
|
||||||
|
best_model = None
|
||||||
|
best_rate = -1.0
|
||||||
|
for model in model_order:
|
||||||
|
stats = per_model_stats[model]
|
||||||
|
output["per_model_stats"][model] = {
|
||||||
|
"total": stats["total"],
|
||||||
|
"passed": stats["passed"],
|
||||||
|
"failed": stats["failed"],
|
||||||
|
"warned": stats["warned"],
|
||||||
|
"pass_rate": round(stats["pass_rate"], 2),
|
||||||
|
}
|
||||||
|
if stats["pass_rate"] > best_rate:
|
||||||
|
best_rate = stats["pass_rate"]
|
||||||
|
best_model = model
|
||||||
|
|
||||||
|
if best_model:
|
||||||
|
output["summary"]["best_model"] = best_model
|
||||||
|
output["summary"]["best_pass_rate"] = round(best_rate, 2)
|
||||||
|
|
||||||
|
# Build comparison structure
|
||||||
|
for suite_name, cases in comparison_data.items():
|
||||||
|
output["comparison"][suite_name] = {}
|
||||||
|
|
||||||
|
for case_name, case_models in cases.items():
|
||||||
|
case_output: dict[str, Any] = {
|
||||||
|
"results_by_model": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add context from first model if requested
|
||||||
|
if include_context:
|
||||||
|
first_model_result = next(iter(case_models.values()), {})
|
||||||
|
system_msg = first_model_result.get("system_message")
|
||||||
|
addl_msgs = first_model_result.get("additional_messages")
|
||||||
|
if system_msg:
|
||||||
|
case_output["system_message"] = system_msg
|
||||||
|
if addl_msgs:
|
||||||
|
case_output["additional_messages"] = addl_msgs
|
||||||
|
|
||||||
|
for model in model_order:
|
||||||
|
if model not in case_models:
|
||||||
|
case_output["results_by_model"][model] = {"status": "missing"}
|
||||||
|
continue
|
||||||
|
|
||||||
|
case_result = case_models[model]
|
||||||
|
evaluation = case_result["evaluation"]
|
||||||
|
|
||||||
|
model_data: dict[str, Any] = {
|
||||||
|
"status": self._get_status(evaluation),
|
||||||
|
"score": round(evaluation.score * 100, 2),
|
||||||
|
"passed": evaluation.passed,
|
||||||
|
"warning": evaluation.warning,
|
||||||
|
}
|
||||||
|
|
||||||
|
if evaluation.failure_reason:
|
||||||
|
model_data["failure_reason"] = evaluation.failure_reason
|
||||||
|
|
||||||
|
if show_details and evaluation.results:
|
||||||
|
model_data["details"] = self._serialize_critic_results(evaluation.results)
|
||||||
|
|
||||||
|
case_output["results_by_model"][model] = model_data
|
||||||
|
|
||||||
|
# Find best model for this case
|
||||||
|
best, best_score = find_best_model(case_models)
|
||||||
|
case_output["best_model"] = best
|
||||||
|
case_output["best_score"] = round(best_score * 100, 2)
|
||||||
|
|
||||||
|
output["comparison"][suite_name][case_name] = case_output
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
def _serialize_case(
|
||||||
|
self, case: dict[str, Any], show_details: bool, include_context: bool = False
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Serialize a single evaluation case."""
|
||||||
|
evaluation = case["evaluation"]
|
||||||
|
|
||||||
|
case_data: dict[str, Any] = {
|
||||||
|
"name": case["name"],
|
||||||
|
"input": case.get("input", ""),
|
||||||
|
"status": self._get_status(evaluation),
|
||||||
|
"score": round(evaluation.score * 100, 2),
|
||||||
|
"passed": evaluation.passed,
|
||||||
|
"warning": evaluation.warning,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add context if requested
|
||||||
|
if include_context:
|
||||||
|
system_msg = case.get("system_message")
|
||||||
|
addl_msgs = case.get("additional_messages")
|
||||||
|
if system_msg:
|
||||||
|
case_data["system_message"] = system_msg
|
||||||
|
if addl_msgs:
|
||||||
|
case_data["additional_messages"] = addl_msgs
|
||||||
|
|
||||||
|
if evaluation.failure_reason:
|
||||||
|
case_data["failure_reason"] = evaluation.failure_reason
|
||||||
|
|
||||||
|
if show_details and evaluation.results:
|
||||||
|
case_data["details"] = self._serialize_critic_results(evaluation.results)
|
||||||
|
|
||||||
|
return case_data
|
||||||
|
|
||||||
|
def _serialize_critic_results(self, results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Serialize critic results for detailed output."""
|
||||||
|
serialized = []
|
||||||
|
for critic_result in results:
|
||||||
|
item: dict[str, Any] = {
|
||||||
|
"field": critic_result["field"],
|
||||||
|
"match": critic_result["match"],
|
||||||
|
"score": critic_result["score"],
|
||||||
|
"weight": critic_result["weight"],
|
||||||
|
"expected": critic_result["expected"],
|
||||||
|
"actual": critic_result["actual"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if "is_criticized" in critic_result:
|
||||||
|
item["is_criticized"] = critic_result["is_criticized"]
|
||||||
|
|
||||||
|
serialized.append(item)
|
||||||
|
|
||||||
|
return serialized
|
||||||
|
|
||||||
|
def _get_status(self, evaluation: Any) -> str:
|
||||||
|
"""Get status string from evaluation."""
|
||||||
|
if evaluation.passed:
|
||||||
|
return "passed"
|
||||||
|
elif evaluation.warning:
|
||||||
|
return "warned"
|
||||||
|
else:
|
||||||
|
return "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureJsonFormatter(CaptureFormatter):
|
||||||
|
"""JSON formatter for capture results."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_extension(self) -> str:
|
||||||
|
return "json"
|
||||||
|
|
||||||
|
def format(
|
||||||
|
self,
|
||||||
|
captures: CaptureResults,
|
||||||
|
include_context: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Format capture results as JSON."""
|
||||||
|
# Check for multi-model captures
|
||||||
|
if is_multi_model_capture(captures):
|
||||||
|
output_data = self._format_multi_model(captures, include_context)
|
||||||
|
else:
|
||||||
|
output_data = {
|
||||||
|
"type": "capture",
|
||||||
|
"captures": [cap.to_dict(include_context=include_context) for cap in captures],
|
||||||
|
}
|
||||||
|
return json.dumps(output_data, indent=2)
|
||||||
|
|
||||||
|
def _format_multi_model(
|
||||||
|
self,
|
||||||
|
captures: CaptureResults,
|
||||||
|
include_context: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Format multi-model capture results with track-aware structure."""
|
||||||
|
from arcade_cli.formatters.base import group_captures_by_case_then_track
|
||||||
|
|
||||||
|
grouped_data, model_order, track_order = group_captures_by_case_then_track(captures)
|
||||||
|
has_tracks = len(track_order) > 1 or (track_order and track_order[0] is not None)
|
||||||
|
|
||||||
|
track_names = [t for t in track_order if t is not None] if has_tracks else []
|
||||||
|
|
||||||
|
output: dict[str, Any] = {
|
||||||
|
"type": "multi_model_capture",
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"models": model_order,
|
||||||
|
"tracks": track_names if track_names else None,
|
||||||
|
"summary": {
|
||||||
|
"total_suites": len(grouped_data),
|
||||||
|
"total_cases": sum(len(cases) for cases in grouped_data.values()),
|
||||||
|
"models_count": len(model_order),
|
||||||
|
"tracks_count": len(track_names) if track_names else 0,
|
||||||
|
},
|
||||||
|
"grouped_by_case": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for suite_name, cases in grouped_data.items():
|
||||||
|
output["grouped_by_case"][suite_name] = {}
|
||||||
|
|
||||||
|
for case_name, case_data in cases.items():
|
||||||
|
case_output: dict[str, Any] = {
|
||||||
|
"user_message": case_data.get("user_message", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
if include_context:
|
||||||
|
if case_data.get("system_message"):
|
||||||
|
case_output["system_message"] = case_data["system_message"]
|
||||||
|
if case_data.get("additional_messages"):
|
||||||
|
case_output["additional_messages"] = case_data["additional_messages"]
|
||||||
|
|
||||||
|
tracks_data = case_data.get("tracks", {})
|
||||||
|
track_keys = list(tracks_data.keys())
|
||||||
|
has_multiple_tracks = len(track_keys) > 1 or (
|
||||||
|
len(track_keys) == 1 and track_keys[0] != "_default"
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_multiple_tracks:
|
||||||
|
# Structure with tracks
|
||||||
|
case_output["tracks"] = {}
|
||||||
|
for track_key in track_keys:
|
||||||
|
track_display = track_key if track_key != "_default" else "default"
|
||||||
|
track_data = tracks_data[track_key]
|
||||||
|
models_dict = track_data.get("models", {})
|
||||||
|
|
||||||
|
track_output: dict[str, Any] = {"models": {}}
|
||||||
|
for model in model_order:
|
||||||
|
if model not in models_dict:
|
||||||
|
track_output["models"][model] = {"status": "missing"}
|
||||||
|
continue
|
||||||
|
|
||||||
|
captured_case = models_dict[model]
|
||||||
|
track_output["models"][model] = {
|
||||||
|
"tool_calls": [
|
||||||
|
{"name": tc.name, "args": tc.args}
|
||||||
|
for tc in captured_case.tool_calls
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
case_output["tracks"][track_display] = track_output
|
||||||
|
else:
|
||||||
|
# No tracks - flat structure
|
||||||
|
track_key = track_keys[0] if track_keys else "_default"
|
||||||
|
track_data = tracks_data.get(track_key, {})
|
||||||
|
models_dict = track_data.get("models", {})
|
||||||
|
|
||||||
|
case_output["models"] = {}
|
||||||
|
for model in model_order:
|
||||||
|
if model not in models_dict:
|
||||||
|
case_output["models"][model] = {"status": "missing"}
|
||||||
|
continue
|
||||||
|
|
||||||
|
captured_case = models_dict[model]
|
||||||
|
case_output["models"][model] = {
|
||||||
|
"tool_calls": [
|
||||||
|
{"name": tc.name, "args": tc.args}
|
||||||
|
for tc in captured_case.tool_calls
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
output["grouped_by_case"][suite_name][case_name] = case_output
|
||||||
|
|
||||||
|
return output
|
||||||
1284
libs/arcade-cli/arcade_cli/formatters/markdown.py
Normal file
1284
libs/arcade-cli/arcade_cli/formatters/markdown.py
Normal file
File diff suppressed because it is too large
Load diff
1086
libs/arcade-cli/arcade_cli/formatters/text.py
Normal file
1086
libs/arcade-cli/arcade_cli/formatters/text.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -11,8 +11,6 @@ import typer
|
||||||
from arcade_core.constants import CREDENTIALS_FILE_PATH, PROD_COORDINATOR_HOST, PROD_ENGINE_HOST
|
from arcade_core.constants import CREDENTIALS_FILE_PATH, PROD_COORDINATOR_HOST, PROD_ENGINE_HOST
|
||||||
from arcadepy import Arcade
|
from arcadepy import Arcade
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.text import Text
|
|
||||||
from tqdm import tqdm
|
|
||||||
|
|
||||||
from arcade_cli.authn import (
|
from arcade_cli.authn import (
|
||||||
OAuthLoginError,
|
OAuthLoginError,
|
||||||
|
|
@ -22,9 +20,7 @@ from arcade_cli.authn import (
|
||||||
perform_oauth_login,
|
perform_oauth_login,
|
||||||
save_credentials_from_whoami,
|
save_credentials_from_whoami,
|
||||||
)
|
)
|
||||||
from arcade_cli.display import (
|
from arcade_cli.evals_runner import run_capture, run_evaluations
|
||||||
display_eval_results,
|
|
||||||
)
|
|
||||||
from arcade_cli.org import app as org_app
|
from arcade_cli.org import app as org_app
|
||||||
from arcade_cli.project import app as project_app
|
from arcade_cli.project import app as project_app
|
||||||
from arcade_cli.secret import app as secret_app
|
from arcade_cli.secret import app as secret_app
|
||||||
|
|
@ -32,14 +28,19 @@ from arcade_cli.server import app as server_app
|
||||||
from arcade_cli.show import show_logic
|
from arcade_cli.show import show_logic
|
||||||
from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup
|
from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup
|
||||||
from arcade_cli.utils import (
|
from arcade_cli.utils import (
|
||||||
|
ModelSpec,
|
||||||
Provider,
|
Provider,
|
||||||
compute_base_url,
|
compute_base_url,
|
||||||
|
expand_provider_configs,
|
||||||
|
get_default_model,
|
||||||
get_eval_files,
|
get_eval_files,
|
||||||
handle_cli_error,
|
handle_cli_error,
|
||||||
load_eval_suites,
|
load_eval_suites,
|
||||||
log_engine_health,
|
log_engine_health,
|
||||||
|
parse_output_paths,
|
||||||
|
parse_provider_spec,
|
||||||
require_dependency,
|
require_dependency,
|
||||||
resolve_provider_api_key,
|
resolve_provider_api_keys,
|
||||||
version_callback,
|
version_callback,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -404,23 +405,54 @@ def evals(
|
||||||
"-c",
|
"-c",
|
||||||
help="Maximum number of concurrent evaluations (default: 1)",
|
help="Maximum number of concurrent evaluations (default: 1)",
|
||||||
),
|
),
|
||||||
models: str = typer.Option(
|
use_provider: Optional[str] = typer.Option(
|
||||||
"gpt-4o",
|
|
||||||
"--models",
|
|
||||||
"-m",
|
|
||||||
help="The models to use for evaluation (default: gpt-4o). Use commas to separate multiple models. All models must belong to the same provider.",
|
|
||||||
),
|
|
||||||
provider: Provider = typer.Option(
|
|
||||||
Provider.OPENAI,
|
|
||||||
"--provider",
|
|
||||||
"-p",
|
|
||||||
help="The provider of the models to use for evaluation.",
|
|
||||||
),
|
|
||||||
provider_api_key: str = typer.Option(
|
|
||||||
None,
|
None,
|
||||||
"--provider-api-key",
|
"--use-provider",
|
||||||
|
"-p",
|
||||||
|
help="Provider(s) and models to use. Format: 'provider' or 'provider:model1,model2'. "
|
||||||
|
"Multiple providers: separate with spaces. "
|
||||||
|
"Examples: 'openai' or 'openai:gpt-4o anthropic:claude-sonnet-4-5-20250929'",
|
||||||
|
),
|
||||||
|
api_key: Optional[list[str]] = typer.Option(
|
||||||
|
None,
|
||||||
|
"--api-key",
|
||||||
"-k",
|
"-k",
|
||||||
help="The model provider API key. If not provided, will look for the appropriate environment variable based on the provider (e.g., OPENAI_API_KEY for openai provider), first in the current environment, then in the current working directory's .env file.",
|
help="API key(s) for provider(s). Format: 'provider:key'. "
|
||||||
|
"Can be repeated. Examples: --api-key openai:sk-... --api-key anthropic:sk-ant-...",
|
||||||
|
),
|
||||||
|
only_failed: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--only-failed",
|
||||||
|
"-f",
|
||||||
|
help="Show only failed evaluations",
|
||||||
|
),
|
||||||
|
output: Optional[list[str]] = typer.Option(
|
||||||
|
None,
|
||||||
|
"--output",
|
||||||
|
"-o",
|
||||||
|
help="Output file(s) with auto-detected format from extension. "
|
||||||
|
"Examples: -o results.json, -o results.md -o results.html, -o results (all formats). "
|
||||||
|
"Can be repeated for multiple formats.",
|
||||||
|
),
|
||||||
|
capture: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--capture",
|
||||||
|
help="Run in capture mode - record tool calls without evaluation scoring",
|
||||||
|
),
|
||||||
|
include_context: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--include-context",
|
||||||
|
help="Include system_message and additional_messages in output (works for both eval and capture modes)",
|
||||||
|
),
|
||||||
|
host: Optional[str] = typer.Option(
|
||||||
|
None,
|
||||||
|
"--host",
|
||||||
|
help="Arcade API host for gateway connections (e.g., 'api.bosslevel.dev')",
|
||||||
|
),
|
||||||
|
port: Optional[int] = typer.Option(
|
||||||
|
None,
|
||||||
|
"--port",
|
||||||
|
help="Arcade API port for gateway connections (default: 443 for HTTPS)",
|
||||||
),
|
),
|
||||||
debug: bool = typer.Option(False, "--debug", help="Show debug information"),
|
debug: bool = typer.Option(False, "--debug", help="Show debug information"),
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -444,27 +476,87 @@ def evals(
|
||||||
pip_install_command=r"pip install arcade-tdk",
|
pip_install_command=r"pip install arcade-tdk",
|
||||||
)
|
)
|
||||||
|
|
||||||
models_list = models.split(",") # Use 'models_list' to avoid shadowing
|
# --- Build model specs from flags ---
|
||||||
|
model_specs: list[ModelSpec] = []
|
||||||
|
|
||||||
# Resolve the API key for the provider
|
# Resolve API keys from --api-key flags and environment
|
||||||
resolved_api_key = resolve_provider_api_key(provider, provider_api_key)
|
api_keys = resolve_provider_api_keys(api_keys_specs=api_key)
|
||||||
if not resolved_api_key:
|
|
||||||
provider_env_vars = {
|
if use_provider:
|
||||||
Provider.OPENAI: "OPENAI_API_KEY",
|
# Parse provider specs - supports space-separated values
|
||||||
}
|
# e.g., "openai:gpt-4o anthropic:claude"
|
||||||
env_var_name = provider_env_vars.get(provider, f"{provider.upper()}_API_KEY")
|
provider_specs = use_provider.split()
|
||||||
handle_cli_error(
|
try:
|
||||||
f"API key not found for provider '{provider.value}'. "
|
provider_configs = [parse_provider_spec(spec) for spec in provider_specs]
|
||||||
f"Please provide it via --provider-api-key,-k argument, set the {env_var_name} environment variable, "
|
except ValueError as e:
|
||||||
f"or add it to a .env file in the current directory.",
|
handle_cli_error(str(e), should_exit=True)
|
||||||
should_exit=True,
|
return # For type checker
|
||||||
)
|
|
||||||
|
# Expand to model specs
|
||||||
|
try:
|
||||||
|
model_specs = expand_provider_configs(provider_configs, api_keys)
|
||||||
|
except ValueError as e:
|
||||||
|
handle_cli_error(str(e), should_exit=True)
|
||||||
|
return # For type checker
|
||||||
|
else:
|
||||||
|
# Default: OpenAI with default model
|
||||||
|
if not api_keys.get(Provider.OPENAI):
|
||||||
|
handle_cli_error(
|
||||||
|
"API key not found for provider 'openai'. "
|
||||||
|
"Please provide it via --api-key openai:KEY, set the OPENAI_API_KEY environment variable, "
|
||||||
|
"or add it to a .env file in the current directory.\n\n"
|
||||||
|
"Tip: Use --use-provider to specify a different provider (e.g., --use-provider anthropic)",
|
||||||
|
should_exit=True,
|
||||||
|
)
|
||||||
|
return # For type checker
|
||||||
|
|
||||||
|
model_specs = [
|
||||||
|
ModelSpec(
|
||||||
|
provider=Provider.OPENAI,
|
||||||
|
model=get_default_model(Provider.OPENAI),
|
||||||
|
api_key=api_keys[Provider.OPENAI], # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not model_specs:
|
||||||
|
handle_cli_error("No models specified. Use --use-provider to specify models.")
|
||||||
|
return
|
||||||
|
|
||||||
eval_files = get_eval_files(directory)
|
eval_files = get_eval_files(directory)
|
||||||
if not eval_files:
|
if not eval_files:
|
||||||
return
|
return
|
||||||
|
|
||||||
console.print("\nRunning evaluations", style="bold")
|
# Warn about incompatible flag combinations
|
||||||
|
if capture:
|
||||||
|
console.print("\nRunning in capture mode", style="bold cyan")
|
||||||
|
if only_failed:
|
||||||
|
console.print("[yellow]⚠️ --only-failed is ignored in capture mode[/yellow]")
|
||||||
|
if show_details:
|
||||||
|
console.print("[yellow]⚠️ --details is ignored in capture mode[/yellow]")
|
||||||
|
else:
|
||||||
|
console.print("\nRunning evaluations", style="bold")
|
||||||
|
|
||||||
|
# Show which models will be used
|
||||||
|
unique_providers = {spec.provider.value for spec in model_specs}
|
||||||
|
if len(unique_providers) > 1:
|
||||||
|
console.print(
|
||||||
|
f"[bold cyan]Using {len(model_specs)} model(s) across {len(unique_providers)} providers[/bold cyan]"
|
||||||
|
)
|
||||||
|
for spec in model_specs:
|
||||||
|
console.print(f" • {spec.display_name}", style="dim")
|
||||||
|
|
||||||
|
# Set arcade URL override BEFORE loading suites (so MCP connections use it)
|
||||||
|
if host or port:
|
||||||
|
# Build URL from --host and --port
|
||||||
|
if not host:
|
||||||
|
handle_cli_error("--port requires --host to be specified", should_exit=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Default to HTTPS on port 443
|
||||||
|
scheme = "https"
|
||||||
|
port_str = f":{port}" if port and port != 443 else ""
|
||||||
|
constructed_url = f"{scheme}://{host}{port_str}"
|
||||||
|
os.environ["ARCADE_API_BASE_URL"] = constructed_url
|
||||||
|
|
||||||
# Use the new function to load eval suites
|
# Use the new function to load eval suites
|
||||||
eval_suites = load_eval_suites(eval_files)
|
eval_suites = load_eval_suites(eval_files)
|
||||||
|
|
@ -480,39 +572,44 @@ def evals(
|
||||||
style="bold",
|
style="bold",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def run_evaluations() -> None:
|
# Parse output paths with smart format detection
|
||||||
all_evaluations = []
|
final_output_file: str | None = None
|
||||||
tasks = []
|
final_output_formats: list[str] = []
|
||||||
for suite_func in eval_suites:
|
|
||||||
console.print(
|
|
||||||
Text.assemble(
|
|
||||||
("Running evaluations in ", "bold"),
|
|
||||||
(suite_func.__name__, "bold blue"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for model in models_list:
|
|
||||||
task = asyncio.create_task(
|
|
||||||
suite_func(
|
|
||||||
provider_api_key=resolved_api_key,
|
|
||||||
model=model,
|
|
||||||
max_concurrency=max_concurrent,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
tasks.append(task)
|
|
||||||
|
|
||||||
# Track progress and results as suite functions complete
|
if output:
|
||||||
with tqdm(total=len(tasks), desc="Evaluations Progress") as pbar:
|
try:
|
||||||
results = []
|
final_output_file, final_output_formats = parse_output_paths(output)
|
||||||
for f in asyncio.as_completed(tasks):
|
except ValueError as e:
|
||||||
results.append(await f)
|
handle_cli_error(str(e), should_exit=True)
|
||||||
pbar.update(1)
|
return
|
||||||
|
|
||||||
# TODO error handling on each eval
|
|
||||||
all_evaluations.extend(results)
|
|
||||||
display_eval_results(all_evaluations, show_details=show_details)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
asyncio.run(run_evaluations())
|
if capture:
|
||||||
|
asyncio.run(
|
||||||
|
run_capture(
|
||||||
|
eval_suites=eval_suites,
|
||||||
|
model_specs=model_specs,
|
||||||
|
max_concurrent=max_concurrent,
|
||||||
|
include_context=include_context,
|
||||||
|
output_file=final_output_file,
|
||||||
|
output_format=",".join(final_output_formats) if final_output_formats else "txt",
|
||||||
|
console=console,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
asyncio.run(
|
||||||
|
run_evaluations(
|
||||||
|
eval_suites=eval_suites,
|
||||||
|
model_specs=model_specs,
|
||||||
|
max_concurrent=max_concurrent,
|
||||||
|
show_details=show_details,
|
||||||
|
output_file=final_output_file,
|
||||||
|
output_format=",".join(final_output_formats) if final_output_formats else "txt",
|
||||||
|
failed_only=only_failed,
|
||||||
|
include_context=include_context,
|
||||||
|
console=console,
|
||||||
|
)
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
handle_cli_error("Failed to run evaluations", e, debug)
|
handle_cli_error("Failed to run evaluations", e, debug)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,13 @@ import os
|
||||||
import shlex
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
import webbrowser
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from importlib import metadata
|
from importlib import metadata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from textwrap import dedent
|
from textwrap import dedent
|
||||||
from typing import Any, Callable, Union, cast
|
from typing import Any, Callable, cast
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import idna
|
import idna
|
||||||
|
|
@ -35,18 +34,9 @@ from arcadepy import (
|
||||||
Arcade,
|
Arcade,
|
||||||
)
|
)
|
||||||
from arcadepy.types import AuthorizationResponse
|
from arcadepy.types import AuthorizationResponse
|
||||||
from openai import OpenAI, Stream
|
|
||||||
from openai.types.chat.chat_completion import Choice as ChatCompletionChoice
|
|
||||||
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
|
|
||||||
from openai.types.chat.chat_completion_chunk import (
|
|
||||||
Choice as ChatCompletionChunkChoice,
|
|
||||||
)
|
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.live import Live
|
|
||||||
from rich.markdown import Markdown
|
|
||||||
from rich.markup import escape
|
from rich.markup import escape
|
||||||
from rich.text import Text
|
|
||||||
from typer.core import TyperGroup
|
from typer.core import TyperGroup
|
||||||
from typer.models import Context
|
from typer.models import Context
|
||||||
|
|
||||||
|
|
@ -77,6 +67,302 @@ class Provider(str, Enum):
|
||||||
"""Supported model providers for evaluations."""
|
"""Supported model providers for evaluations."""
|
||||||
|
|
||||||
OPENAI = "openai"
|
OPENAI = "openai"
|
||||||
|
ANTHROPIC = "anthropic"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Default Models Configuration
|
||||||
|
# ============================================================================
|
||||||
|
# Edit these values to change the default models used by the CLI.
|
||||||
|
# These are used when --models is not specified.
|
||||||
|
#
|
||||||
|
# Note: Anthropic models include date suffixes (e.g., -20250929) which may need
|
||||||
|
# periodic updates. Check https://docs.anthropic.com/en/docs/about-claude/models
|
||||||
|
# for the latest model identifiers.
|
||||||
|
|
||||||
|
DEFAULT_MODELS: dict[Provider, str] = {
|
||||||
|
Provider.OPENAI: "gpt-4o",
|
||||||
|
Provider.ANTHROPIC: "claude-sonnet-4-5-20250929",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_model(provider: Provider) -> str:
|
||||||
|
"""Get the default model for a provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: The provider to get the default model for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The default model name for the provider.
|
||||||
|
"""
|
||||||
|
return DEFAULT_MODELS.get(provider, "gpt-4o")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Output Format Detection
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
ALL_OUTPUT_FORMATS = ["txt", "md", "html", "json"]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_output_paths(output_paths: list[str] | None) -> tuple[str | None, list[str]]:
|
||||||
|
"""Parse --output/-o paths into base path and format list.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Single file with extension: "results.json" → ("results", ["json"])
|
||||||
|
- Multiple files: ["results.md", "results.html"] → ("results", ["md", "html"])
|
||||||
|
- No extension: "results" → ("results", ["txt", "md", "html", "json"])
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_paths: List of output paths from --output/-o flag.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (base_path, formats). Returns (None, []) if no paths.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If paths have inconsistent base names or invalid extensions.
|
||||||
|
"""
|
||||||
|
if not output_paths:
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
# Extract base path and formats
|
||||||
|
base_path = None
|
||||||
|
formats: list[str] = []
|
||||||
|
|
||||||
|
for path_str in output_paths:
|
||||||
|
path = Path(path_str)
|
||||||
|
stem = path.stem
|
||||||
|
ext = path.suffix.lstrip(".")
|
||||||
|
|
||||||
|
# Determine base path (all paths should have same base)
|
||||||
|
if base_path is None:
|
||||||
|
base_path = str(Path(path.parent) / stem)
|
||||||
|
elif str(Path(path.parent) / stem) != base_path:
|
||||||
|
raise ValueError(
|
||||||
|
f"Output paths have different base names: '{base_path}' vs '{Path(path.parent) / stem}'. "
|
||||||
|
"All outputs must use the same base path."
|
||||||
|
)
|
||||||
|
|
||||||
|
# No extension means all formats
|
||||||
|
if not ext:
|
||||||
|
formats = ALL_OUTPUT_FORMATS.copy()
|
||||||
|
break
|
||||||
|
|
||||||
|
# Validate extension
|
||||||
|
if ext not in ALL_OUTPUT_FORMATS:
|
||||||
|
valid = ", ".join(ALL_OUTPUT_FORMATS)
|
||||||
|
raise ValueError(f"Invalid output format '.{ext}'. Valid extensions: {valid}")
|
||||||
|
|
||||||
|
if ext not in formats:
|
||||||
|
formats.append(ext)
|
||||||
|
|
||||||
|
return base_path, formats
|
||||||
|
|
||||||
|
|
||||||
|
def parse_api_key_spec(spec: str) -> tuple[Provider, str]:
|
||||||
|
"""Parse --api-key value into (provider, key).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
spec: API key spec string. Format: "provider:key"
|
||||||
|
Examples: "openai:sk-...", "anthropic:sk-ant-..."
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (Provider, api_key_string).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If format is invalid or provider is unknown.
|
||||||
|
"""
|
||||||
|
if ":" not in spec:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid --api-key format: '{spec}'. "
|
||||||
|
"Expected format: 'provider:key' (e.g., 'openai:sk-...')"
|
||||||
|
)
|
||||||
|
|
||||||
|
provider_str, key = spec.split(":", 1)
|
||||||
|
provider_str = provider_str.strip().lower()
|
||||||
|
key = key.strip()
|
||||||
|
|
||||||
|
if not key:
|
||||||
|
raise ValueError(f"Empty API key for provider '{provider_str}'")
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = Provider(provider_str)
|
||||||
|
except ValueError:
|
||||||
|
valid_providers = [p.value for p in Provider]
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid provider '{provider_str}' in --api-key. "
|
||||||
|
f"Valid providers: {', '.join(valid_providers)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return provider, key
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Multi-Provider Model Specification
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProviderConfig:
|
||||||
|
"""Configuration for a single provider from CLI input.
|
||||||
|
|
||||||
|
Parsed from --use-provider flag values like:
|
||||||
|
- "openai" -> provider=OPENAI, models=[] (use default)
|
||||||
|
- "openai:gpt-4o,gpt-4o-mini" -> provider=OPENAI, models=["gpt-4o", "gpt-4o-mini"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
provider: Provider
|
||||||
|
models: list[str] # Empty list means use default model
|
||||||
|
|
||||||
|
def get_models(self) -> list[str]:
|
||||||
|
"""Get models, using default if none specified."""
|
||||||
|
if self.models:
|
||||||
|
return self.models
|
||||||
|
return [get_default_model(self.provider)]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelSpec:
|
||||||
|
"""A specific model to run evaluations against.
|
||||||
|
|
||||||
|
This is the expanded form used by the runner - one ModelSpec per
|
||||||
|
(provider, model, api_key) combination.
|
||||||
|
"""
|
||||||
|
|
||||||
|
provider: Provider
|
||||||
|
model: str
|
||||||
|
api_key: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
"""Get display name in format 'provider/model'."""
|
||||||
|
return f"{self.provider.value}/{self.model}"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_provider_spec(spec: str) -> ProviderConfig:
|
||||||
|
"""Parse a --use-provider value into a ProviderConfig.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
spec: Provider spec string. Examples:
|
||||||
|
- "openai" -> use OpenAI with default model
|
||||||
|
- "openai:gpt-4o" -> use OpenAI with gpt-4o
|
||||||
|
- "anthropic:claude-sonnet-4-5-20250929,claude-3-haiku-20240307"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProviderConfig with parsed provider and models.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If provider name is invalid.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> parse_provider_spec("openai")
|
||||||
|
ProviderConfig(provider=Provider.OPENAI, models=[])
|
||||||
|
>>> parse_provider_spec("openai:gpt-4o,gpt-4o-mini")
|
||||||
|
ProviderConfig(provider=Provider.OPENAI, models=['gpt-4o', 'gpt-4o-mini'])
|
||||||
|
"""
|
||||||
|
if ":" in spec:
|
||||||
|
provider_str, models_str = spec.split(":", 1)
|
||||||
|
models = [m.strip() for m in models_str.split(",") if m.strip()]
|
||||||
|
else:
|
||||||
|
provider_str = spec.strip()
|
||||||
|
models = []
|
||||||
|
|
||||||
|
# Validate provider
|
||||||
|
provider_str_lower = provider_str.lower()
|
||||||
|
try:
|
||||||
|
provider = Provider(provider_str_lower)
|
||||||
|
except ValueError:
|
||||||
|
valid_providers = [p.value for p in Provider]
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid provider '{provider_str}'. Valid providers: {', '.join(valid_providers)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ProviderConfig(provider=provider, models=models)
|
||||||
|
|
||||||
|
|
||||||
|
def expand_provider_configs(
|
||||||
|
configs: list[ProviderConfig],
|
||||||
|
api_keys: dict[Provider, str | None],
|
||||||
|
) -> list[ModelSpec]:
|
||||||
|
"""Expand provider configs into individual ModelSpecs with resolved API keys.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
configs: List of ProviderConfig from parsed --use-provider flags.
|
||||||
|
api_keys: Dict mapping Provider to API key (from flags or env vars).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ModelSpec, one per (provider, model) combination.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If API key is missing for any provider.
|
||||||
|
"""
|
||||||
|
model_specs: list[ModelSpec] = []
|
||||||
|
|
||||||
|
for config in configs:
|
||||||
|
api_key = api_keys.get(config.provider)
|
||||||
|
if not api_key:
|
||||||
|
env_var = f"{config.provider.value.upper()}_API_KEY"
|
||||||
|
raise ValueError(
|
||||||
|
f"API key required for provider '{config.provider.value}'. "
|
||||||
|
f"Provide via --{config.provider.value}-key or set {env_var} environment variable."
|
||||||
|
)
|
||||||
|
|
||||||
|
for model in config.get_models():
|
||||||
|
model_specs.append(ModelSpec(provider=config.provider, model=model, api_key=api_key))
|
||||||
|
|
||||||
|
return model_specs
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_provider_api_keys(
|
||||||
|
api_keys_specs: list[str] | None = None,
|
||||||
|
) -> dict[Provider, str | None]:
|
||||||
|
"""Resolve API keys for all providers from flags and environment.
|
||||||
|
|
||||||
|
Priority: --api-key flag > environment variable > .env file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_keys_specs: List of provider:key specs from --api-key flags.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping Provider to resolved API key (or None if not found).
|
||||||
|
"""
|
||||||
|
from dotenv import dotenv_values
|
||||||
|
|
||||||
|
# Load .env file
|
||||||
|
env_values = dotenv_values(".env")
|
||||||
|
|
||||||
|
# Start with empty dict
|
||||||
|
keys: dict[Provider, str | None] = {
|
||||||
|
Provider.OPENAI: None,
|
||||||
|
Provider.ANTHROPIC: None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse --api-key provider:key specs (highest priority)
|
||||||
|
if api_keys_specs:
|
||||||
|
for spec in api_keys_specs:
|
||||||
|
try:
|
||||||
|
provider, key = parse_api_key_spec(spec)
|
||||||
|
keys[provider] = key
|
||||||
|
except ValueError as e:
|
||||||
|
# Re-raise to let CLI handle error
|
||||||
|
raise ValueError(str(e)) from e
|
||||||
|
|
||||||
|
# Fallback to environment variables and .env file
|
||||||
|
def resolve_key_from_env(env_var: str) -> str | None:
|
||||||
|
# Check current environment
|
||||||
|
key = os.environ.get(env_var)
|
||||||
|
if key:
|
||||||
|
return key
|
||||||
|
# Check .env file
|
||||||
|
return env_values.get(env_var)
|
||||||
|
|
||||||
|
# Set from environment if not already set by --api-key
|
||||||
|
if keys[Provider.OPENAI] is None:
|
||||||
|
keys[Provider.OPENAI] = resolve_key_from_env("OPENAI_API_KEY")
|
||||||
|
if keys[Provider.ANTHROPIC] is None:
|
||||||
|
keys[Provider.ANTHROPIC] = resolve_key_from_env("ANTHROPIC_API_KEY")
|
||||||
|
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
class CLIError(Exception):
|
class CLIError(Exception):
|
||||||
|
|
@ -319,77 +605,6 @@ def get_tools_from_engine(
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
|
|
||||||
def get_tool_messages(choice: dict) -> list[dict]:
|
|
||||||
if hasattr(choice, "tool_messages") and choice.tool_messages:
|
|
||||||
return choice.tool_messages # type: ignore[no-any-return]
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StreamingResult:
|
|
||||||
role: str
|
|
||||||
full_message: str
|
|
||||||
tool_messages: list
|
|
||||||
tool_authorization: dict | None
|
|
||||||
|
|
||||||
|
|
||||||
def handle_streaming_content(stream: Stream[ChatCompletionChunk], model: str) -> StreamingResult:
|
|
||||||
"""
|
|
||||||
Display the streamed markdown chunks as a single line.
|
|
||||||
"""
|
|
||||||
from rich.live import Live
|
|
||||||
|
|
||||||
full_message = ""
|
|
||||||
tool_messages = []
|
|
||||||
tool_authorization = None
|
|
||||||
role = ""
|
|
||||||
printed_role: bool = False
|
|
||||||
|
|
||||||
with Live(console=console, refresh_per_second=10) as live:
|
|
||||||
for chunk in stream:
|
|
||||||
choice = chunk.choices[0]
|
|
||||||
role = choice.delta.role or role
|
|
||||||
|
|
||||||
# Display and get tool messages if they exist
|
|
||||||
tool_messages += get_tool_messages(choice) # type: ignore[arg-type]
|
|
||||||
tool_authorization = get_tool_authorization(choice)
|
|
||||||
|
|
||||||
chunk_message = choice.delta.content
|
|
||||||
|
|
||||||
if role == "assistant" and tool_authorization:
|
|
||||||
continue # Skip the message if it's an auth request (handled later in handle_tool_authorization)
|
|
||||||
|
|
||||||
if role == "assistant" and not printed_role:
|
|
||||||
console.print(f"\n[blue][bold]Assistant[/bold] ({model}):[/blue] ")
|
|
||||||
printed_role = True
|
|
||||||
|
|
||||||
if chunk_message:
|
|
||||||
full_message += chunk_message
|
|
||||||
markdown_chunk = Markdown(full_message)
|
|
||||||
live.update(markdown_chunk)
|
|
||||||
|
|
||||||
# Markdownify URLs in the final message if applicable
|
|
||||||
if role == "assistant":
|
|
||||||
full_message = markdownify_urls(full_message)
|
|
||||||
live.update(Markdown(full_message))
|
|
||||||
|
|
||||||
return StreamingResult(role, full_message, tool_messages, tool_authorization)
|
|
||||||
|
|
||||||
|
|
||||||
def markdownify_urls(message: str) -> str:
|
|
||||||
"""
|
|
||||||
Convert URLs in the message to markdown links.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
|
|
||||||
# This regex will match URLs that are not already formatted as markdown links:
|
|
||||||
# [Link text](https://example.com)
|
|
||||||
url_pattern = r"(?<!\]\()https?://\S+"
|
|
||||||
|
|
||||||
# Wrap all URLs in the message with markdown links
|
|
||||||
return re.sub(url_pattern, r"[Link](\g<0>)", message)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_and_get_config(
|
def validate_and_get_config(
|
||||||
validate_api: bool = True,
|
validate_api: bool = True,
|
||||||
validate_user: bool = True,
|
validate_user: bool = True,
|
||||||
|
|
@ -555,106 +770,6 @@ def log_engine_health(client: Arcade) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ChatInteractionResult:
|
|
||||||
history: list[dict]
|
|
||||||
tool_messages: list[dict]
|
|
||||||
tool_authorization: dict | None
|
|
||||||
|
|
||||||
|
|
||||||
def handle_chat_interaction(
|
|
||||||
client: OpenAI,
|
|
||||||
model: str,
|
|
||||||
history: list[dict],
|
|
||||||
user_email: str | None,
|
|
||||||
stream: bool = False,
|
|
||||||
) -> ChatInteractionResult:
|
|
||||||
"""
|
|
||||||
Handle a single chat-request/chat-response interaction for both streamed and non-streamed responses.
|
|
||||||
Handling the chat response includes:
|
|
||||||
- Streaming the response if the stream flag is set
|
|
||||||
- Displaying the response in the console
|
|
||||||
- Getting the tool messages and tool authorization from the response
|
|
||||||
- Updating the history with the response, tool calls, and tool responses
|
|
||||||
"""
|
|
||||||
if stream:
|
|
||||||
# TODO Fix this in the client so users don't deal with these
|
|
||||||
# typing issues
|
|
||||||
response = client.chat.completions.create( # type: ignore[call-overload]
|
|
||||||
model=model,
|
|
||||||
messages=history,
|
|
||||||
tool_choice="generate",
|
|
||||||
user=user_email,
|
|
||||||
stream=True,
|
|
||||||
)
|
|
||||||
streaming_result = handle_streaming_content(response, model)
|
|
||||||
role, message_content = streaming_result.role, streaming_result.full_message
|
|
||||||
tool_messages, tool_authorization = (
|
|
||||||
streaming_result.tool_messages,
|
|
||||||
streaming_result.tool_authorization,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
response = client.chat.completions.create( # type: ignore[call-overload]
|
|
||||||
model=model,
|
|
||||||
messages=history,
|
|
||||||
tool_choice="generate",
|
|
||||||
user=user_email,
|
|
||||||
stream=False,
|
|
||||||
)
|
|
||||||
message_content = response.choices[0].message.content or ""
|
|
||||||
|
|
||||||
# Get extra fields from the response
|
|
||||||
tool_messages = get_tool_messages(response.choices[0])
|
|
||||||
tool_authorization = get_tool_authorization(response.choices[0])
|
|
||||||
|
|
||||||
role = response.choices[0].message.role
|
|
||||||
|
|
||||||
if role == "assistant" and tool_authorization:
|
|
||||||
pass # Skip the message if it's an auth request (handled later in handle_tool_authorization)
|
|
||||||
elif role == "assistant":
|
|
||||||
message_content = markdownify_urls(message_content)
|
|
||||||
console.print(
|
|
||||||
f"\n[blue][bold]Assistant[/bold] ({model}):[/blue] ",
|
|
||||||
Markdown(message_content),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
console.print(f"\n[bold]{role}:[/bold] {message_content}")
|
|
||||||
|
|
||||||
history += tool_messages
|
|
||||||
history.append({"role": role, "content": message_content})
|
|
||||||
|
|
||||||
return ChatInteractionResult(history, tool_messages, tool_authorization)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_tool_authorization(
|
|
||||||
arcade_client: Arcade,
|
|
||||||
tool_authorization: AuthorizationResponse,
|
|
||||||
history: list[dict[str, Any]],
|
|
||||||
openai_client: OpenAI,
|
|
||||||
model: str,
|
|
||||||
user_email: str | None,
|
|
||||||
stream: bool,
|
|
||||||
) -> ChatInteractionResult:
|
|
||||||
with Live(console=console, refresh_per_second=4) as live:
|
|
||||||
if tool_authorization.url:
|
|
||||||
authorization_url = str(tool_authorization.url)
|
|
||||||
webbrowser.open(authorization_url)
|
|
||||||
message = (
|
|
||||||
"You'll need to authorize this action in your browser.\n\n"
|
|
||||||
f"If a browser doesn't open automatically, click [this link]({authorization_url}) "
|
|
||||||
f"or copy this URL and paste it into your browser:\n\n{authorization_url}"
|
|
||||||
)
|
|
||||||
live.update(Markdown(message, style="dim"))
|
|
||||||
|
|
||||||
wait_for_authorization_completion(arcade_client, tool_authorization)
|
|
||||||
|
|
||||||
message = "Thanks for authorizing the action! Sending your request..."
|
|
||||||
live.update(Text(message, style="dim"))
|
|
||||||
|
|
||||||
history.pop()
|
|
||||||
return handle_chat_interaction(openai_client, model, history, user_email, stream)
|
|
||||||
|
|
||||||
|
|
||||||
def wait_for_authorization_completion(
|
def wait_for_authorization_completion(
|
||||||
client: Arcade, tool_authorization: AuthorizationResponse | None
|
client: Arcade, tool_authorization: AuthorizationResponse | None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -677,28 +792,6 @@ def wait_for_authorization_completion(
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
def get_tool_authorization(
|
|
||||||
choice: Union[ChatCompletionChoice, ChatCompletionChunkChoice],
|
|
||||||
) -> dict | None:
|
|
||||||
"""
|
|
||||||
Get the tool authorization from a chat response's choice.
|
|
||||||
"""
|
|
||||||
if hasattr(choice, "tool_authorizations") and choice.tool_authorizations:
|
|
||||||
return choice.tool_authorizations[0] # type: ignore[no-any-return]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_authorization_pending(tool_authorization: dict | None) -> bool:
|
|
||||||
"""
|
|
||||||
Check if the authorization for a tool call is pending.
|
|
||||||
Expects a chat response's choice.tool_authorizations as input.
|
|
||||||
"""
|
|
||||||
is_auth_pending = (
|
|
||||||
tool_authorization is not None and tool_authorization.get("status", "") == "pending"
|
|
||||||
)
|
|
||||||
return is_auth_pending
|
|
||||||
|
|
||||||
|
|
||||||
def get_eval_files(directory: str) -> list[Path]:
|
def get_eval_files(directory: str) -> list[Path]:
|
||||||
"""
|
"""
|
||||||
Get a list of evaluation files starting with 'eval_' and ending with '.py' in the given directory.
|
Get a list of evaluation files starting with 'eval_' and ending with '.py' in the given directory.
|
||||||
|
|
@ -1020,6 +1113,7 @@ def resolve_provider_api_key(provider: Provider, provider_api_key: str | None =
|
||||||
# Map providers to their environment variable names
|
# Map providers to their environment variable names
|
||||||
provider_env_vars = {
|
provider_env_vars = {
|
||||||
Provider.OPENAI: "OPENAI_API_KEY",
|
Provider.OPENAI: "OPENAI_API_KEY",
|
||||||
|
Provider.ANTHROPIC: "ANTHROPIC_API_KEY",
|
||||||
}
|
}
|
||||||
|
|
||||||
env_var_name = provider_env_vars.get(provider)
|
env_var_name = provider_env_vars.get(provider)
|
||||||
|
|
@ -1042,6 +1136,65 @@ def resolve_provider_api_key(provider: Provider, provider_api_key: str | None =
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def filter_failed_evaluations(
|
||||||
|
all_evaluations: list[list[dict[str, Any]]],
|
||||||
|
) -> tuple[list[list[dict[str, Any]]], tuple[int, int, int, int]]:
|
||||||
|
"""
|
||||||
|
Filter evaluation results to show only failed cases and calculate original counts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
all_evaluations: List of evaluation results with structure:
|
||||||
|
[[{model: str, rubric: str, cases: [{name, input, evaluation}]}]]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (filtered_evaluations, original_counts) where original_counts is
|
||||||
|
(total_cases, total_passed, total_failed, total_warned)
|
||||||
|
"""
|
||||||
|
original_total_cases = 0
|
||||||
|
original_total_passed = 0
|
||||||
|
original_total_failed = 0
|
||||||
|
original_total_warned = 0
|
||||||
|
|
||||||
|
# Calculate original counts before filtering
|
||||||
|
for eval_suite in all_evaluations:
|
||||||
|
for model_results in eval_suite:
|
||||||
|
for case in model_results.get("cases", []):
|
||||||
|
evaluation = case["evaluation"]
|
||||||
|
original_total_cases += 1
|
||||||
|
if evaluation.passed:
|
||||||
|
original_total_passed += 1
|
||||||
|
elif evaluation.warning:
|
||||||
|
original_total_warned += 1
|
||||||
|
else:
|
||||||
|
original_total_failed += 1
|
||||||
|
|
||||||
|
# Filter to show only failed evaluations
|
||||||
|
filtered_evaluations = []
|
||||||
|
for eval_suite in all_evaluations:
|
||||||
|
filtered_suite = []
|
||||||
|
for model_results in eval_suite:
|
||||||
|
filtered_cases = [
|
||||||
|
case
|
||||||
|
for case in model_results.get("cases", [])
|
||||||
|
if not case["evaluation"].passed and not case["evaluation"].warning
|
||||||
|
]
|
||||||
|
if filtered_cases: # Only include model results with failed cases
|
||||||
|
filtered_model_results = model_results.copy()
|
||||||
|
filtered_model_results["cases"] = filtered_cases
|
||||||
|
filtered_suite.append(filtered_model_results)
|
||||||
|
if filtered_suite:
|
||||||
|
filtered_evaluations.append(filtered_suite)
|
||||||
|
|
||||||
|
original_counts = (
|
||||||
|
original_total_cases,
|
||||||
|
original_total_passed,
|
||||||
|
original_total_failed,
|
||||||
|
original_total_warned,
|
||||||
|
)
|
||||||
|
|
||||||
|
return filtered_evaluations, original_counts
|
||||||
|
|
||||||
|
|
||||||
def require_dependency(
|
def require_dependency(
|
||||||
package_name: str,
|
package_name: str,
|
||||||
command_name: str,
|
command_name: str,
|
||||||
|
|
|
||||||
34
libs/arcade-core/arcade_core/converters/__init__.py
Normal file
34
libs/arcade-core/arcade_core/converters/__init__.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""Converters for transforming tool definitions between formats."""
|
||||||
|
|
||||||
|
from .anthropic import (
|
||||||
|
AnthropicInputSchema,
|
||||||
|
AnthropicInputSchemaProperty,
|
||||||
|
AnthropicToolList,
|
||||||
|
AnthropicToolSchema,
|
||||||
|
to_anthropic,
|
||||||
|
)
|
||||||
|
from .openai import (
|
||||||
|
OpenAIFunctionParameterProperty,
|
||||||
|
OpenAIFunctionParameters,
|
||||||
|
OpenAIFunctionSchema,
|
||||||
|
OpenAIToolList,
|
||||||
|
OpenAIToolSchema,
|
||||||
|
to_openai,
|
||||||
|
)
|
||||||
|
from .utils import denormalize_tool_name, normalize_tool_name
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AnthropicInputSchema",
|
||||||
|
"AnthropicInputSchemaProperty",
|
||||||
|
"AnthropicToolList",
|
||||||
|
"AnthropicToolSchema",
|
||||||
|
"OpenAIFunctionParameterProperty",
|
||||||
|
"OpenAIFunctionParameters",
|
||||||
|
"OpenAIFunctionSchema",
|
||||||
|
"OpenAIToolList",
|
||||||
|
"OpenAIToolSchema",
|
||||||
|
"denormalize_tool_name",
|
||||||
|
"normalize_tool_name",
|
||||||
|
"to_anthropic",
|
||||||
|
"to_openai",
|
||||||
|
]
|
||||||
194
libs/arcade-core/arcade_core/converters/anthropic.py
Normal file
194
libs/arcade-core/arcade_core/converters/anthropic.py
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
"""Converter for converting Arcade ToolDefinition to Anthropic tool schema."""
|
||||||
|
|
||||||
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
|
from arcade_core.catalog import MaterializedTool
|
||||||
|
from arcade_core.converters.utils import normalize_tool_name
|
||||||
|
from arcade_core.schema import InputParameter, ValueSchema
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Type definitions for JSON tool schemas used by Anthropic APIs.
|
||||||
|
# Defines the proper types for tool schemas to ensure
|
||||||
|
# compatibility with Anthropic's Messages API tool use feature.
|
||||||
|
# Reference: https://docs.anthropic.com/en/docs/build-with-claude/tool-use
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicInputSchemaProperty(TypedDict, total=False):
|
||||||
|
"""Type definition for a property within Anthropic input schema."""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
"""The JSON Schema type for this property."""
|
||||||
|
|
||||||
|
description: str
|
||||||
|
"""Description of the property."""
|
||||||
|
|
||||||
|
enum: list[Any]
|
||||||
|
"""Allowed values for enum properties."""
|
||||||
|
|
||||||
|
items: dict[str, Any]
|
||||||
|
"""Schema for array items when type is 'array'."""
|
||||||
|
|
||||||
|
properties: dict[str, "AnthropicInputSchemaProperty"]
|
||||||
|
"""Nested properties when type is 'object'."""
|
||||||
|
|
||||||
|
required: list[str]
|
||||||
|
"""Required fields for nested objects."""
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicInputSchema(TypedDict, total=False):
|
||||||
|
"""Type definition for Anthropic tool input schema."""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
"""Must be 'object' for tool input schemas."""
|
||||||
|
|
||||||
|
properties: dict[str, AnthropicInputSchemaProperty]
|
||||||
|
"""The properties of the tool input parameters."""
|
||||||
|
|
||||||
|
required: list[str]
|
||||||
|
"""List of required parameter names."""
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicToolSchema(TypedDict, total=False):
|
||||||
|
"""
|
||||||
|
Schema for a tool definition passed to Anthropic's `tools` parameter.
|
||||||
|
|
||||||
|
Unlike OpenAI, Anthropic uses a flat structure without a wrapper object.
|
||||||
|
The schema uses `input_schema` instead of `parameters`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
"""The name of the tool."""
|
||||||
|
|
||||||
|
description: str
|
||||||
|
"""Description of what the tool does."""
|
||||||
|
|
||||||
|
input_schema: AnthropicInputSchema
|
||||||
|
"""JSON Schema describing the tool's input parameters."""
|
||||||
|
|
||||||
|
|
||||||
|
# Type alias for a list of Anthropic tool schemas
|
||||||
|
AnthropicToolList = list[AnthropicToolSchema]
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Converters
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
def to_anthropic(tool: MaterializedTool) -> AnthropicToolSchema:
|
||||||
|
"""Convert a MaterializedTool to Anthropic tool schema format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool: The MaterializedTool to convert
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The Anthropic tool schema format (what is passed to the Anthropic API)
|
||||||
|
"""
|
||||||
|
name = normalize_tool_name(tool.definition.fully_qualified_name)
|
||||||
|
description = tool.description
|
||||||
|
input_schema = _convert_input_parameters_to_json_schema(tool.definition.input.parameters)
|
||||||
|
|
||||||
|
return _create_tool_schema(name, description, input_schema)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_tool_schema(
|
||||||
|
name: str, description: str, input_schema: AnthropicInputSchema
|
||||||
|
) -> AnthropicToolSchema:
|
||||||
|
"""Create a properly typed Anthropic tool schema.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: The name of the tool
|
||||||
|
description: Description of what the tool does
|
||||||
|
input_schema: JSON schema for the tool input parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A properly typed AnthropicToolSchema
|
||||||
|
"""
|
||||||
|
tool: AnthropicToolSchema = {
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"input_schema": input_schema,
|
||||||
|
}
|
||||||
|
|
||||||
|
return tool
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_value_schema_to_json_schema(
|
||||||
|
value_schema: ValueSchema,
|
||||||
|
) -> AnthropicInputSchemaProperty:
|
||||||
|
"""Convert Arcade ValueSchema to JSON Schema format for Anthropic."""
|
||||||
|
type_mapping = {
|
||||||
|
"string": "string",
|
||||||
|
"integer": "integer",
|
||||||
|
"number": "number",
|
||||||
|
"boolean": "boolean",
|
||||||
|
"json": "object",
|
||||||
|
"array": "array",
|
||||||
|
}
|
||||||
|
|
||||||
|
schema: AnthropicInputSchemaProperty = {"type": type_mapping[value_schema.val_type]}
|
||||||
|
|
||||||
|
if value_schema.val_type == "array" and value_schema.inner_val_type:
|
||||||
|
items_schema: dict[str, Any] = {"type": type_mapping[value_schema.inner_val_type]}
|
||||||
|
|
||||||
|
# For arrays, enum should be applied to the items, not the array itself
|
||||||
|
if value_schema.enum:
|
||||||
|
items_schema["enum"] = value_schema.enum
|
||||||
|
|
||||||
|
schema["items"] = items_schema
|
||||||
|
else:
|
||||||
|
# Handle enum for non-array types
|
||||||
|
if value_schema.enum:
|
||||||
|
schema["enum"] = value_schema.enum
|
||||||
|
|
||||||
|
# Handle object properties
|
||||||
|
if value_schema.val_type == "json" and value_schema.properties:
|
||||||
|
schema["properties"] = {
|
||||||
|
name: _convert_value_schema_to_json_schema(nested_schema)
|
||||||
|
for name, nested_schema in value_schema.properties.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_input_parameters_to_json_schema(
|
||||||
|
parameters: list[InputParameter],
|
||||||
|
) -> AnthropicInputSchema:
|
||||||
|
"""Convert list of InputParameter to JSON schema parameters object.
|
||||||
|
|
||||||
|
Unlike OpenAI's strict mode, Anthropic uses standard JSON Schema:
|
||||||
|
- Only actually required parameters are listed in 'required'
|
||||||
|
- No need to add 'null' to optional parameter types
|
||||||
|
- No 'additionalProperties: false' requirement
|
||||||
|
"""
|
||||||
|
if not parameters:
|
||||||
|
# Minimal JSON schema for a tool with no input parameters
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
properties: dict[str, AnthropicInputSchemaProperty] = {}
|
||||||
|
required: list[str] = []
|
||||||
|
|
||||||
|
for parameter in parameters:
|
||||||
|
param_schema = _convert_value_schema_to_json_schema(parameter.value_schema)
|
||||||
|
|
||||||
|
if parameter.description:
|
||||||
|
param_schema["description"] = parameter.description
|
||||||
|
|
||||||
|
properties[parameter.name] = param_schema
|
||||||
|
|
||||||
|
# Only add actually required parameters to the required list
|
||||||
|
if parameter.required:
|
||||||
|
required.append(parameter.name)
|
||||||
|
|
||||||
|
json_schema: AnthropicInputSchema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": properties,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only include 'required' if there are required parameters
|
||||||
|
if required:
|
||||||
|
json_schema["required"] = required
|
||||||
|
|
||||||
|
return json_schema
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from typing import Any, Literal, TypedDict
|
from typing import Any, Literal, TypedDict
|
||||||
|
|
||||||
from arcade_core.catalog import MaterializedTool
|
from arcade_core.catalog import MaterializedTool
|
||||||
|
from arcade_core.converters.utils import normalize_tool_name
|
||||||
from arcade_core.schema import InputParameter, ValueSchema
|
from arcade_core.schema import InputParameter, ValueSchema
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
|
|
@ -101,7 +102,7 @@ def to_openai(tool: MaterializedTool) -> OpenAIToolSchema:
|
||||||
Returns:
|
Returns:
|
||||||
The OpenAI JsonToolSchema format (what is passed to the OpenAI API)
|
The OpenAI JsonToolSchema format (what is passed to the OpenAI API)
|
||||||
"""
|
"""
|
||||||
name = tool.definition.fully_qualified_name.replace(".", "_")
|
name = normalize_tool_name(tool.definition.fully_qualified_name)
|
||||||
description = tool.description
|
description = tool.description
|
||||||
parameters_schema = _convert_input_parameters_to_json_schema(tool.definition.input.parameters)
|
parameters_schema = _convert_input_parameters_to_json_schema(tool.definition.input.parameters)
|
||||||
return _create_tool_schema(name, description, parameters_schema)
|
return _create_tool_schema(name, description, parameters_schema)
|
||||||
|
|
|
||||||
54
libs/arcade-core/arcade_core/converters/utils.py
Normal file
54
libs/arcade-core/arcade_core/converters/utils.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
"""Shared utilities for tool name conversion across providers.
|
||||||
|
|
||||||
|
This module contains common utilities used by both OpenAI and Anthropic converters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_tool_name(name: str) -> str:
|
||||||
|
"""
|
||||||
|
Normalize a tool name for LLM provider compatibility.
|
||||||
|
|
||||||
|
Both OpenAI and Anthropic have restrictions on tool names:
|
||||||
|
- OpenAI: allows alphanumeric, hyphens, underscores (max 64 chars)
|
||||||
|
- Anthropic: allows alphanumeric and underscores only (no dots)
|
||||||
|
|
||||||
|
Arcade uses dot notation for fully qualified names (e.g., "Google.Search"),
|
||||||
|
so we normalize by replacing dots with underscores.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: The original tool name (e.g., "Google.Search")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The normalized tool name (e.g., "Google_Search")
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> normalize_tool_name("Google.Search")
|
||||||
|
'Google_Search'
|
||||||
|
>>> normalize_tool_name("MyTool")
|
||||||
|
'MyTool'
|
||||||
|
>>> normalize_tool_name("Namespace.Sub.Tool")
|
||||||
|
'Namespace_Sub_Tool'
|
||||||
|
"""
|
||||||
|
return name.replace(".", "_")
|
||||||
|
|
||||||
|
|
||||||
|
def denormalize_tool_name(normalized_name: str, separator: str = ".") -> str:
|
||||||
|
"""
|
||||||
|
Reverse the normalization of a tool name.
|
||||||
|
|
||||||
|
This converts provider-format names back to Arcade's dot notation.
|
||||||
|
Note: This is a best-effort reversal and may not be accurate if the original
|
||||||
|
name contained underscores.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
normalized_name: The normalized tool name (e.g., "Google_Search")
|
||||||
|
separator: The separator to use (default: ".")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The denormalized tool name (e.g., "Google.Search")
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> denormalize_tool_name("Google_Search")
|
||||||
|
'Google.Search'
|
||||||
|
"""
|
||||||
|
return normalized_name.replace("_", separator)
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "arcade-core"
|
name = "arcade-core"
|
||||||
version = "4.1.0"
|
version = "4.2.0"
|
||||||
description = "Arcade Core - Core library for Arcade platform"
|
description = "Arcade Core - Core library for Arcade platform"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,49 @@
|
||||||
|
from ._evalsuite._providers import ProviderName
|
||||||
|
from ._evalsuite._tool_registry import MCPToolDefinition
|
||||||
|
from .capture import CapturedCase, CapturedToolCall, CaptureResult
|
||||||
from .critic import BinaryCritic, DatetimeCritic, NoneCritic, NumericCritic, SimilarityCritic
|
from .critic import BinaryCritic, DatetimeCritic, NoneCritic, NumericCritic, SimilarityCritic
|
||||||
from .eval import EvalRubric, EvalSuite, ExpectedToolCall, NamedExpectedToolCall, tool_eval
|
from .eval import (
|
||||||
|
AnyExpectedToolCall,
|
||||||
|
EvalRubric,
|
||||||
|
EvalSuite,
|
||||||
|
ExpectedMCPToolCall,
|
||||||
|
ExpectedToolCall,
|
||||||
|
NamedExpectedToolCall,
|
||||||
|
tool_eval,
|
||||||
|
)
|
||||||
|
from .loaders import (
|
||||||
|
clear_tools_cache,
|
||||||
|
load_arcade_mcp_gateway_async,
|
||||||
|
load_from_stdio_async,
|
||||||
|
load_mcp_remote_async,
|
||||||
|
load_stdio_arcade_async,
|
||||||
|
)
|
||||||
|
from .weights import FuzzyWeight, Weight, validate_and_normalize_critic_weights
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"AnyExpectedToolCall",
|
||||||
"BinaryCritic",
|
"BinaryCritic",
|
||||||
|
"CaptureResult",
|
||||||
|
"CapturedCase",
|
||||||
|
"CapturedToolCall",
|
||||||
"DatetimeCritic",
|
"DatetimeCritic",
|
||||||
"EvalRubric",
|
"EvalRubric",
|
||||||
"EvalSuite",
|
"EvalSuite",
|
||||||
|
"ExpectedMCPToolCall",
|
||||||
"ExpectedToolCall",
|
"ExpectedToolCall",
|
||||||
|
"FuzzyWeight",
|
||||||
|
"MCPToolDefinition",
|
||||||
"NamedExpectedToolCall",
|
"NamedExpectedToolCall",
|
||||||
"NoneCritic",
|
"NoneCritic",
|
||||||
"NumericCritic",
|
"NumericCritic",
|
||||||
|
"ProviderName",
|
||||||
"SimilarityCritic",
|
"SimilarityCritic",
|
||||||
|
"Weight",
|
||||||
|
"clear_tools_cache",
|
||||||
|
"load_arcade_mcp_gateway_async",
|
||||||
|
"load_mcp_remote_async",
|
||||||
|
"load_from_stdio_async",
|
||||||
|
"load_stdio_arcade_async",
|
||||||
"tool_eval",
|
"tool_eval",
|
||||||
|
"validate_and_normalize_critic_weights",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
1
libs/arcade-evals/arcade_evals/_evalsuite/__init__.py
Normal file
1
libs/arcade-evals/arcade_evals/_evalsuite/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Internal implementation details for EvalSuite"""
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""Anthropic tool schema conversion (internal).
|
||||||
|
|
||||||
|
Converts MCP-style tool schemas to Anthropic's tool format.
|
||||||
|
|
||||||
|
Anthropic uses standard JSON Schema, so conversion is minimal:
|
||||||
|
- Rename inputSchema -> input_schema (camelCase to snake_case)
|
||||||
|
- Normalize tool names (dots to underscores, as Anthropic doesn't allow dots)
|
||||||
|
- No strict mode transformations needed
|
||||||
|
- Standard JSON Schema constraints are preserved
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from arcade_core.converters.utils import normalize_tool_name as _normalize_tool_name
|
||||||
|
|
||||||
|
|
||||||
|
def convert_mcp_to_anthropic_tool(mcp_tool: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Convert an MCP tool definition to Anthropic tool format.
|
||||||
|
|
||||||
|
This is a minimal conversion since Anthropic accepts standard JSON Schema.
|
||||||
|
Changes:
|
||||||
|
- Rename `inputSchema` to `input_schema`
|
||||||
|
- Normalize tool name (dots to underscores)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mcp_tool: MCP-style tool definition with keys:
|
||||||
|
- name (required)
|
||||||
|
- description (optional)
|
||||||
|
- inputSchema (optional, JSON Schema)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Anthropic tool definition with keys:
|
||||||
|
- name
|
||||||
|
- description
|
||||||
|
- input_schema
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"name": _normalize_tool_name(mcp_tool["name"]),
|
||||||
|
"description": mcp_tool.get("description", ""),
|
||||||
|
"input_schema": mcp_tool.get("inputSchema", {"type": "object", "properties": {}}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def convert_mcp_tools_to_anthropic(mcp_tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Convert a list of MCP tool definitions to Anthropic tool format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mcp_tools: List of MCP-style tool definitions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Anthropic tool definitions.
|
||||||
|
"""
|
||||||
|
return [convert_mcp_to_anthropic_tool(tool) for tool in mcp_tools]
|
||||||
180
libs/arcade-evals/arcade_evals/_evalsuite/_capture.py
Normal file
180
libs/arcade-evals/arcade_evals/_evalsuite/_capture.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"""Capture mode mixin for EvalSuite.
|
||||||
|
|
||||||
|
This module provides the capture functionality as a mixin class,
|
||||||
|
keeping it separate from the main evaluation logic in eval.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from arcade_evals.capture import CapturedCase, CapturedToolCall, CaptureResult
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from arcade_evals._evalsuite._comparative import ComparativeCaseBuilder
|
||||||
|
from arcade_evals._evalsuite._providers import ProviderName
|
||||||
|
from arcade_evals._evalsuite._tool_registry import EvalSuiteToolRegistry
|
||||||
|
from arcade_evals._evalsuite._tracks import TrackManager
|
||||||
|
from arcade_evals._evalsuite._types import EvalRubric
|
||||||
|
from arcade_evals.eval import EvalCase
|
||||||
|
|
||||||
|
|
||||||
|
class _EvalSuiteCaptureMixin:
|
||||||
|
"""Mixin providing capture mode functionality for EvalSuite."""
|
||||||
|
|
||||||
|
# These attributes are defined in EvalSuite
|
||||||
|
name: str
|
||||||
|
cases: list[EvalCase]
|
||||||
|
max_concurrent: int
|
||||||
|
rubric: EvalRubric
|
||||||
|
_internal_registry: EvalSuiteToolRegistry | None
|
||||||
|
_comparative_case_builders: list[ComparativeCaseBuilder]
|
||||||
|
_track_manager: TrackManager
|
||||||
|
|
||||||
|
# These methods are defined in EvalSuite
|
||||||
|
async def _run_openai(
|
||||||
|
self,
|
||||||
|
client: Any,
|
||||||
|
model: str,
|
||||||
|
case: EvalCase,
|
||||||
|
registry: EvalSuiteToolRegistry | None = None,
|
||||||
|
) -> list[tuple[str, dict[str, Any]]]:
|
||||||
|
raise NotImplementedError # Implemented in EvalSuite
|
||||||
|
|
||||||
|
async def _run_anthropic(
|
||||||
|
self,
|
||||||
|
client: Any,
|
||||||
|
model: str,
|
||||||
|
case: EvalCase,
|
||||||
|
registry: EvalSuiteToolRegistry | None = None,
|
||||||
|
) -> list[tuple[str, dict[str, Any]]]:
|
||||||
|
raise NotImplementedError # Implemented in EvalSuite
|
||||||
|
|
||||||
|
def _process_tool_calls(
|
||||||
|
self,
|
||||||
|
tool_calls: list[tuple[str, dict[str, Any]]],
|
||||||
|
registry: EvalSuiteToolRegistry | None = None,
|
||||||
|
) -> list[tuple[str, dict[str, Any]]]:
|
||||||
|
raise NotImplementedError # Implemented in EvalSuite
|
||||||
|
|
||||||
|
def _create_eval_case(self, *args: Any, **kwargs: Any) -> EvalCase:
|
||||||
|
raise NotImplementedError # Implemented in EvalSuite
|
||||||
|
|
||||||
|
async def capture(
|
||||||
|
self,
|
||||||
|
client: Any, # AsyncOpenAI | AsyncAnthropic
|
||||||
|
model: str,
|
||||||
|
provider: ProviderName = "openai",
|
||||||
|
include_context: bool = False,
|
||||||
|
) -> CaptureResult:
|
||||||
|
"""
|
||||||
|
Run the evaluation suite in capture mode - records tool calls without scoring.
|
||||||
|
|
||||||
|
Capture mode runs each case and records the tool calls made by the model,
|
||||||
|
without evaluating or scoring them. This is useful for:
|
||||||
|
- Generating expected tool calls for new test cases
|
||||||
|
- Debugging model behavior
|
||||||
|
- Creating baseline recordings
|
||||||
|
|
||||||
|
Handles both regular cases and comparative cases. For comparative cases,
|
||||||
|
each track is captured separately with its own tool registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: The LLM client instance (AsyncOpenAI or AsyncAnthropic).
|
||||||
|
model: The model to use.
|
||||||
|
provider: The provider name ("openai" or "anthropic").
|
||||||
|
include_context: Whether to include system_message and additional_messages
|
||||||
|
in the output.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A CaptureResult containing all captured tool calls.
|
||||||
|
"""
|
||||||
|
all_captured: list[CapturedCase] = []
|
||||||
|
semaphore = asyncio.Semaphore(self.max_concurrent)
|
||||||
|
|
||||||
|
async def capture_case(
|
||||||
|
case: EvalCase,
|
||||||
|
registry: EvalSuiteToolRegistry | None = None,
|
||||||
|
track: str | None = None,
|
||||||
|
) -> CapturedCase:
|
||||||
|
"""Capture a case using the specified registry."""
|
||||||
|
async with semaphore:
|
||||||
|
use_registry = registry or self._internal_registry
|
||||||
|
if use_registry is None or use_registry.tool_count() == 0:
|
||||||
|
raise ValueError(
|
||||||
|
"No tools registered. Use add_* convenience methods or pass catalog=ToolCatalog."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get tool calls based on provider
|
||||||
|
if provider == "anthropic":
|
||||||
|
predicted_args = await self._run_anthropic(
|
||||||
|
client, model, case, registry=use_registry
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
predicted_args = await self._run_openai(
|
||||||
|
client, model, case, registry=use_registry
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process tool calls (resolve names, fill defaults)
|
||||||
|
filled_actual_tool_calls = self._process_tool_calls(
|
||||||
|
predicted_args, registry=use_registry
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert to CapturedToolCall objects
|
||||||
|
tool_calls = [
|
||||||
|
CapturedToolCall(name=name, args=args)
|
||||||
|
for name, args in filled_actual_tool_calls
|
||||||
|
]
|
||||||
|
|
||||||
|
return CapturedCase(
|
||||||
|
case_name=case.name,
|
||||||
|
user_message=case.user_message,
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
system_message=case.system_message if include_context else None,
|
||||||
|
additional_messages=case.additional_messages if include_context else None,
|
||||||
|
track_name=track,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Capture regular cases (using default registry)
|
||||||
|
if self.cases:
|
||||||
|
tasks = [capture_case(case) for case in self.cases]
|
||||||
|
regular_captured = await asyncio.gather(*tasks)
|
||||||
|
all_captured.extend(regular_captured)
|
||||||
|
|
||||||
|
# Capture comparative cases (each track separately)
|
||||||
|
if self._comparative_case_builders:
|
||||||
|
for builder in self._comparative_case_builders:
|
||||||
|
comp_case = builder.build()
|
||||||
|
|
||||||
|
# For each track configured in this comparative case
|
||||||
|
for track_name in comp_case.track_configs:
|
||||||
|
if not self._track_manager.has_track(track_name):
|
||||||
|
continue # Skip missing tracks
|
||||||
|
|
||||||
|
track_registry = self._track_manager.get_registry(track_name)
|
||||||
|
|
||||||
|
# Create an EvalCase from the comparative case
|
||||||
|
# Use case-specific rubric if defined, otherwise use suite default
|
||||||
|
case_rubric = comp_case.rubric or self.rubric
|
||||||
|
eval_case = self._create_eval_case(
|
||||||
|
name=comp_case.name, # Don't embed track in name - use track_name field
|
||||||
|
user_message=comp_case.user_message,
|
||||||
|
system_message=comp_case.system_message,
|
||||||
|
additional_messages=comp_case.additional_messages,
|
||||||
|
expected_tool_calls=[], # Not needed for capture
|
||||||
|
rubric=case_rubric,
|
||||||
|
critics=[], # Not needed for capture
|
||||||
|
)
|
||||||
|
|
||||||
|
captured = await capture_case(
|
||||||
|
eval_case, registry=track_registry, track=track_name
|
||||||
|
)
|
||||||
|
all_captured.append(captured)
|
||||||
|
|
||||||
|
return CaptureResult(
|
||||||
|
suite_name=self.name,
|
||||||
|
model=model,
|
||||||
|
provider=provider,
|
||||||
|
captured_cases=list(all_captured),
|
||||||
|
)
|
||||||
132
libs/arcade-evals/arcade_evals/_evalsuite/_comparative.py
Normal file
132
libs/arcade-evals/arcade_evals/_evalsuite/_comparative.py
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
"""Comparative case builder for multi-track evaluations.
|
||||||
|
|
||||||
|
Provides a fluent API for defining evaluation cases that run against
|
||||||
|
multiple tool tracks with track-specific expected results and critics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from arcade_evals._evalsuite._types import (
|
||||||
|
ComparativeCase,
|
||||||
|
EvalRubric,
|
||||||
|
ExpectedMCPToolCall,
|
||||||
|
ExpectedToolCall,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from arcade_evals.critic import Critic
|
||||||
|
|
||||||
|
|
||||||
|
class ComparativeCaseBuilder:
|
||||||
|
"""Fluent builder for creating comparative cases.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
builder = ComparativeCaseBuilder(
|
||||||
|
suite=suite,
|
||||||
|
name="weather_query",
|
||||||
|
user_message="What's the weather?",
|
||||||
|
)
|
||||||
|
builder.for_track(
|
||||||
|
"Google Weather",
|
||||||
|
expected_tool_calls=[...],
|
||||||
|
critics=[...],
|
||||||
|
).for_track(
|
||||||
|
"OpenWeather",
|
||||||
|
expected_tool_calls=[...],
|
||||||
|
critics=[...],
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
suite: Any, # EvalSuite - avoid circular import
|
||||||
|
name: str,
|
||||||
|
user_message: str,
|
||||||
|
system_message: str = "",
|
||||||
|
additional_messages: list[dict[str, str]] | None = None,
|
||||||
|
rubric: EvalRubric | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the builder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
suite: The parent EvalSuite.
|
||||||
|
name: Unique case name.
|
||||||
|
user_message: User message (shared across tracks).
|
||||||
|
system_message: System message (shared across tracks).
|
||||||
|
additional_messages: Additional context (shared).
|
||||||
|
rubric: Default rubric (shared, can be overridden).
|
||||||
|
"""
|
||||||
|
self._suite = suite
|
||||||
|
self._case = ComparativeCase(
|
||||||
|
name=name,
|
||||||
|
user_message=user_message,
|
||||||
|
system_message=system_message,
|
||||||
|
additional_messages=additional_messages or [],
|
||||||
|
rubric=rubric,
|
||||||
|
)
|
||||||
|
|
||||||
|
def for_track(
|
||||||
|
self,
|
||||||
|
track_name: str,
|
||||||
|
expected_tool_calls: list[ExpectedToolCall | ExpectedMCPToolCall],
|
||||||
|
critics: list[Critic] | None = None,
|
||||||
|
) -> ComparativeCaseBuilder:
|
||||||
|
"""Add track-specific configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
track_name: The track name (must be registered via add_*_tools).
|
||||||
|
expected_tool_calls: Expected tool calls for this track.
|
||||||
|
critics: Critics for this track.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Self for method chaining.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If track doesn't exist.
|
||||||
|
"""
|
||||||
|
# Validate track exists
|
||||||
|
if not self._suite._track_manager.has_track(track_name):
|
||||||
|
available = self._suite._track_manager.get_track_names()
|
||||||
|
raise ValueError(
|
||||||
|
f"Track '{track_name}' not found. "
|
||||||
|
f"Available tracks: {available}. "
|
||||||
|
f"Register tracks first using add_*_tools(track=...)."
|
||||||
|
)
|
||||||
|
|
||||||
|
self._case.add_track_config(
|
||||||
|
track_name=track_name,
|
||||||
|
expected_tool_calls=expected_tool_calls,
|
||||||
|
critics=critics,
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def build(self) -> ComparativeCase:
|
||||||
|
"""Build and return the comparative case.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The configured ComparativeCase.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If no tracks configured.
|
||||||
|
"""
|
||||||
|
if not self._case.track_configs:
|
||||||
|
raise ValueError(
|
||||||
|
f"No tracks configured for comparative case '{self._case.name}'. "
|
||||||
|
f"Use .for_track() to add at least one track configuration."
|
||||||
|
)
|
||||||
|
return self._case
|
||||||
|
|
||||||
|
@property
|
||||||
|
def case(self) -> ComparativeCase:
|
||||||
|
"""Access the underlying case for inspection.
|
||||||
|
|
||||||
|
Note: This is primarily for testing. The case may be incomplete
|
||||||
|
if tracks haven't been configured yet. Use build() to validate
|
||||||
|
and finalize the case.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ComparativeCase (may be incomplete).
|
||||||
|
"""
|
||||||
|
return self._case
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
"""Comparative evaluation execution mixin for EvalSuite.
|
||||||
|
|
||||||
|
This module provides the execution logic for comparative evaluations,
|
||||||
|
allowing the same cases to be run against multiple tool tracks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from arcade_evals._evalsuite._comparative import ComparativeCaseBuilder
|
||||||
|
from arcade_evals._evalsuite._types import ComparativeCase, EvalRubric
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from arcade_evals._evalsuite._providers import ProviderName
|
||||||
|
from arcade_evals._evalsuite._tool_registry import EvalSuiteToolRegistry
|
||||||
|
from arcade_evals._evalsuite._tracks import TrackManager
|
||||||
|
|
||||||
|
|
||||||
|
class _EvalSuiteComparativeMixin:
|
||||||
|
"""Mixin providing comparative evaluation execution methods."""
|
||||||
|
|
||||||
|
# Type hints for attributes from EvalSuite
|
||||||
|
name: str
|
||||||
|
system_message: str
|
||||||
|
rubric: EvalRubric # EvalSuite always has a rubric (default_factory)
|
||||||
|
max_concurrent: int
|
||||||
|
_comparative_case_builders: list[ComparativeCaseBuilder]
|
||||||
|
_track_manager: TrackManager
|
||||||
|
_create_eval_case: Any # Method from EvalSuite to create EvalCase
|
||||||
|
_convert_to_named_expected_tool_call: Any # Method from EvalSuite
|
||||||
|
_add_none_critics: Any # Method from EvalSuite
|
||||||
|
_process_tool_calls: Any # Method from EvalSuite
|
||||||
|
_run_openai: Any # Method from EvalSuite
|
||||||
|
_run_anthropic: Any # Method from EvalSuite
|
||||||
|
|
||||||
|
def add_comparative_case(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
user_message: str,
|
||||||
|
system_message: str | None = None,
|
||||||
|
additional_messages: list[dict[str, str]] | None = None,
|
||||||
|
rubric: EvalRubric | None = None,
|
||||||
|
) -> ComparativeCaseBuilder:
|
||||||
|
"""Create a comparative case that runs against multiple tool tracks.
|
||||||
|
|
||||||
|
Use .for_track() on the returned builder to configure track-specific
|
||||||
|
expected tool calls and critics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Unique case name.
|
||||||
|
user_message: User message (shared across all tracks).
|
||||||
|
system_message: System message (shared, defaults to suite's system_message).
|
||||||
|
additional_messages: Additional context messages (shared).
|
||||||
|
rubric: Evaluation rubric (shared, defaults to suite's rubric).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ComparativeCaseBuilder for fluent track configuration.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
suite.add_comparative_case(
|
||||||
|
name="weather_query",
|
||||||
|
user_message="What's the weather in NYC?",
|
||||||
|
).for_track(
|
||||||
|
"Google Weather",
|
||||||
|
expected_tool_calls=[ExpectedMCPToolCall("Google_GetWeather", city="NYC")],
|
||||||
|
critics=[RangeCritic(field="temperature", min_val=0, max_val=100)],
|
||||||
|
).for_track(
|
||||||
|
"OpenWeather",
|
||||||
|
expected_tool_calls=[ExpectedMCPToolCall("get_current", location="NYC")],
|
||||||
|
critics=[RangeCritic(field="main.temp", min_val=273, max_val=373)],
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
builder = ComparativeCaseBuilder(
|
||||||
|
suite=self,
|
||||||
|
name=name,
|
||||||
|
user_message=user_message,
|
||||||
|
system_message=system_message or self.system_message,
|
||||||
|
additional_messages=additional_messages,
|
||||||
|
rubric=rubric or self.rubric,
|
||||||
|
)
|
||||||
|
# Store the builder (validated at execution time to allow fluent configuration)
|
||||||
|
self._comparative_case_builders.append(builder)
|
||||||
|
return builder
|
||||||
|
|
||||||
|
async def run_comparative(
|
||||||
|
self,
|
||||||
|
client: Any,
|
||||||
|
model: str,
|
||||||
|
provider: ProviderName = "openai",
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Run comparative cases across all configured tracks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: The LLM client instance.
|
||||||
|
model: The model to evaluate.
|
||||||
|
provider: The provider name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping track names to their results.
|
||||||
|
Each track result contains:
|
||||||
|
- model: The model name
|
||||||
|
- suite_name: The suite name
|
||||||
|
- track_name: The track name
|
||||||
|
- cases: List of case results
|
||||||
|
|
||||||
|
Example:
|
||||||
|
results = await suite.run_comparative(client, "gpt-4o")
|
||||||
|
# results["Google Weather"]["cases"][0] -> first case result
|
||||||
|
# results["OpenWeather"]["cases"][0] -> same case, different track
|
||||||
|
"""
|
||||||
|
if not self._comparative_case_builders:
|
||||||
|
raise ValueError(
|
||||||
|
"No comparative cases defined. Use add_comparative_case() to add cases."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build and validate all cases upfront
|
||||||
|
comparative_cases: list[ComparativeCase] = []
|
||||||
|
all_required_tracks: set[str] = set()
|
||||||
|
for builder in self._comparative_case_builders:
|
||||||
|
comp_case = builder.build() # Validates that tracks are configured
|
||||||
|
comparative_cases.append(comp_case)
|
||||||
|
all_required_tracks.update(comp_case.track_configs.keys())
|
||||||
|
|
||||||
|
# Validate all required tracks exist upfront (fail fast)
|
||||||
|
missing_tracks = [t for t in all_required_tracks if not self._track_manager.has_track(t)]
|
||||||
|
if missing_tracks:
|
||||||
|
available = self._track_manager.get_track_names()
|
||||||
|
raise ValueError(
|
||||||
|
f"Missing track registries: {missing_tracks}. "
|
||||||
|
f"Available tracks: {available}. "
|
||||||
|
f"Ensure you registered tools with track='<track_name>'."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize track results structure
|
||||||
|
track_results: dict[str, dict[str, Any]] = {}
|
||||||
|
for track_name in all_required_tracks:
|
||||||
|
track_results[track_name] = {
|
||||||
|
"model": model,
|
||||||
|
"suite_name": self.name,
|
||||||
|
"track_name": track_name,
|
||||||
|
"rubric": self.rubric,
|
||||||
|
"cases": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prepare all async tasks for parallel execution
|
||||||
|
semaphore = asyncio.Semaphore(self.max_concurrent)
|
||||||
|
tasks: list[tuple[str, Any]] = [] # (track_name, task)
|
||||||
|
|
||||||
|
for comp_case in comparative_cases:
|
||||||
|
for track_name, track_config in comp_case.track_configs.items():
|
||||||
|
registry = self._track_manager.get_registry(track_name)
|
||||||
|
# We validated above that all registries exist, so this should never be None
|
||||||
|
if registry is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Registry for '{track_name}' unexpectedly None after validation"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create EvalCase from comparative case + track config
|
||||||
|
expected_tool_calls = [
|
||||||
|
self._convert_to_named_expected_tool_call(tc)
|
||||||
|
for tc in track_config.expected_tool_calls
|
||||||
|
]
|
||||||
|
critics = self._add_none_critics(expected_tool_calls, track_config.critics or [])
|
||||||
|
|
||||||
|
eval_case = self._create_eval_case(
|
||||||
|
name=comp_case.name,
|
||||||
|
system_message=comp_case.system_message,
|
||||||
|
user_message=comp_case.user_message,
|
||||||
|
expected_tool_calls=expected_tool_calls,
|
||||||
|
rubric=comp_case.rubric or self.rubric,
|
||||||
|
critics=critics,
|
||||||
|
additional_messages=comp_case.additional_messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create task for this case+track combination
|
||||||
|
async def run_track_case(
|
||||||
|
_case: Any, # EvalCase
|
||||||
|
_reg: EvalSuiteToolRegistry,
|
||||||
|
_t_name: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
async with semaphore:
|
||||||
|
start = time.time()
|
||||||
|
print(f" [TASK START] {_case.name} @ {_t_name}", flush=True)
|
||||||
|
if provider == "anthropic":
|
||||||
|
predicted_args = await self._run_anthropic(
|
||||||
|
client, model, _case, registry=_reg
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
predicted_args = await self._run_openai(
|
||||||
|
client, model, _case, registry=_reg
|
||||||
|
)
|
||||||
|
elapsed = time.time() - start
|
||||||
|
print(
|
||||||
|
f" [TASK DONE] {_case.name} @ {_t_name} ({elapsed:.1f}s)",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
filled_actual_tool_calls = self._process_tool_calls(
|
||||||
|
predicted_args, registry=_reg
|
||||||
|
)
|
||||||
|
evaluation = _case.evaluate(filled_actual_tool_calls)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": _case.name,
|
||||||
|
"track": _t_name,
|
||||||
|
"input": _case.user_message,
|
||||||
|
"system_message": _case.system_message,
|
||||||
|
"additional_messages": _case.additional_messages,
|
||||||
|
"expected_tool_calls": [
|
||||||
|
{"name": tc.name, "args": tc.args}
|
||||||
|
for tc in _case.expected_tool_calls
|
||||||
|
],
|
||||||
|
"predicted_tool_calls": [
|
||||||
|
{"name": name, "args": args}
|
||||||
|
for name, args in filled_actual_tool_calls
|
||||||
|
],
|
||||||
|
"evaluation": evaluation,
|
||||||
|
}
|
||||||
|
|
||||||
|
task = run_track_case(eval_case, registry, track_name)
|
||||||
|
tasks.append((track_name, task))
|
||||||
|
|
||||||
|
# Execute all tasks in parallel (respecting max_concurrent via semaphore)
|
||||||
|
results = await asyncio.gather(*[task for _, task in tasks])
|
||||||
|
|
||||||
|
# Organize results by track
|
||||||
|
for (track_name, _), result in zip(tasks, results):
|
||||||
|
track_results[track_name]["cases"].append(result)
|
||||||
|
|
||||||
|
return track_results
|
||||||
265
libs/arcade-evals/arcade_evals/_evalsuite/_convenience.py
Normal file
265
libs/arcade-evals/arcade_evals/_evalsuite/_convenience.py
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
"""EvalSuite convenience methods (internal-only).
|
||||||
|
|
||||||
|
This module contains only the functionality introduced in this PR:
|
||||||
|
- tool registration convenience methods
|
||||||
|
- unified internal registry plumbing helpers
|
||||||
|
- track-based tool registration for comparative evaluations
|
||||||
|
|
||||||
|
It is intentionally not exported from `arcade_evals.__init__`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable
|
||||||
|
|
||||||
|
from arcade_evals._evalsuite._tool_registry import EvalSuiteToolRegistry, MCPToolDefinition
|
||||||
|
from arcade_evals._evalsuite._tracks import TrackManager
|
||||||
|
from arcade_evals.loaders import (
|
||||||
|
load_arcade_mcp_gateway_async,
|
||||||
|
load_from_stdio_async,
|
||||||
|
load_mcp_remote_async,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from arcade_core import ToolCatalog
|
||||||
|
|
||||||
|
|
||||||
|
class _EvalSuiteConvenienceMixin:
|
||||||
|
"""Mixin providing convenience tool registration methods."""
|
||||||
|
|
||||||
|
_internal_registry: EvalSuiteToolRegistry | None
|
||||||
|
_track_manager: TrackManager
|
||||||
|
_python_tool_func_map: dict[str, Callable]
|
||||||
|
_python_func_to_tool_name: dict[Callable, str]
|
||||||
|
strict_mode: bool # Attribute from EvalSuite dataclass
|
||||||
|
|
||||||
|
def _get_registry(self, track: str | None = None) -> EvalSuiteToolRegistry:
|
||||||
|
"""Get the registry for a track or the default internal registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
track: Optional track name. If provided, gets or creates the track registry.
|
||||||
|
If None, uses the default internal registry.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The appropriate EvalSuiteToolRegistry.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If internal registry not initialized.
|
||||||
|
"""
|
||||||
|
if track is not None:
|
||||||
|
# Get existing track registry or create new one
|
||||||
|
registry = self._track_manager.get_registry(track)
|
||||||
|
if registry is None:
|
||||||
|
# Create new registry for this track
|
||||||
|
registry = EvalSuiteToolRegistry(strict_mode=self.strict_mode)
|
||||||
|
self._track_manager.create_track(track, registry)
|
||||||
|
return registry
|
||||||
|
|
||||||
|
# Default: use internal registry
|
||||||
|
if self._internal_registry is None:
|
||||||
|
raise RuntimeError("Internal registry not initialized. This should not happen.")
|
||||||
|
return self._internal_registry
|
||||||
|
|
||||||
|
def get_tracks(self) -> list[str]:
|
||||||
|
"""Get all registered track names.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of track names in registration order.
|
||||||
|
"""
|
||||||
|
return self._track_manager.get_track_names()
|
||||||
|
|
||||||
|
def add_tool_definitions(
|
||||||
|
self,
|
||||||
|
tools: list[MCPToolDefinition],
|
||||||
|
*,
|
||||||
|
track: str | None = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Add tool definitions directly from MCP-style dictionaries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tools: List of tool definitions. Each must have:
|
||||||
|
- name (str): Required. The unique tool name.
|
||||||
|
- description (str): Optional. Defaults to "".
|
||||||
|
- inputSchema (dict): Optional. JSON Schema for parameters.
|
||||||
|
Defaults to {"type": "object", "properties": {}}.
|
||||||
|
track: Optional track name. If provided, tools are added to that track's
|
||||||
|
isolated registry. Use for comparative evaluations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Self for method chaining.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If a tool definition is not a dictionary.
|
||||||
|
ValueError: If a tool definition is missing 'name' or the name is already registered.
|
||||||
|
"""
|
||||||
|
registry = self._get_registry(track)
|
||||||
|
for tool in tools:
|
||||||
|
if not isinstance(tool, dict):
|
||||||
|
raise TypeError("Tool definitions must be dictionaries")
|
||||||
|
if "name" not in tool:
|
||||||
|
raise ValueError("Tool definition must include 'name'")
|
||||||
|
# Copy to avoid mutating input dict
|
||||||
|
tool_copy = dict(tool)
|
||||||
|
tool_copy.setdefault("description", "")
|
||||||
|
tool_copy.setdefault("inputSchema", {"type": "object", "properties": {}})
|
||||||
|
registry.add_tool(tool_copy)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def add_mcp_server(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
timeout: int = 10,
|
||||||
|
track: str | None = None,
|
||||||
|
use_sse: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
"""Add tools from an MCP HTTP server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The MCP server URL.
|
||||||
|
headers: Optional HTTP headers.
|
||||||
|
timeout: Connection timeout in seconds.
|
||||||
|
track: Optional track name for comparative evaluations.
|
||||||
|
use_sse: If True, use Server-Sent Events (SSE) transport.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Self for method chaining.
|
||||||
|
"""
|
||||||
|
registry = self._get_registry(track)
|
||||||
|
tools = await load_mcp_remote_async(url, timeout=timeout, headers=headers, use_sse=use_sse)
|
||||||
|
if not tools:
|
||||||
|
warnings.warn(
|
||||||
|
f"No tools loaded from {url}. Server may be unavailable.",
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
registry.add_tools(tools)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def add_mcp_stdio_server(
|
||||||
|
self,
|
||||||
|
command: list[str],
|
||||||
|
*,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
timeout: int = 10,
|
||||||
|
track: str | None = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Add tools from an MCP stdio server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: Command to start the MCP server.
|
||||||
|
env: Optional environment variables.
|
||||||
|
timeout: Connection timeout in seconds.
|
||||||
|
track: Optional track name for comparative evaluations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Self for method chaining.
|
||||||
|
"""
|
||||||
|
registry = self._get_registry(track)
|
||||||
|
tools = await load_from_stdio_async(command, timeout=timeout, env=env)
|
||||||
|
if not tools:
|
||||||
|
warnings.warn(
|
||||||
|
f"No tools loaded from stdio command: {' '.join(command)}",
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
registry.add_tools(tools)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def add_arcade_gateway(
|
||||||
|
self,
|
||||||
|
gateway_slug: str,
|
||||||
|
*,
|
||||||
|
arcade_api_key: str | None = None,
|
||||||
|
arcade_user_id: str | None = None,
|
||||||
|
base_url: str | None = None,
|
||||||
|
timeout: int = 10,
|
||||||
|
track: str | None = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Add tools from an Arcade MCP gateway.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
gateway_slug: The Arcade gateway slug.
|
||||||
|
arcade_api_key: Optional API key.
|
||||||
|
arcade_user_id: Optional user ID.
|
||||||
|
base_url: Optional base URL.
|
||||||
|
timeout: Connection timeout in seconds.
|
||||||
|
track: Optional track name for comparative evaluations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Self for method chaining.
|
||||||
|
"""
|
||||||
|
registry = self._get_registry(track)
|
||||||
|
|
||||||
|
tools = await load_arcade_mcp_gateway_async(
|
||||||
|
gateway_slug,
|
||||||
|
arcade_api_key=arcade_api_key,
|
||||||
|
arcade_user_id=arcade_user_id,
|
||||||
|
base_url=base_url, # Let loader handle default/env var
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not tools:
|
||||||
|
warnings.warn(
|
||||||
|
f"No tools loaded from Arcade gateway: {gateway_slug}",
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
registry.add_tools(tools)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def add_tool_catalog(
|
||||||
|
self,
|
||||||
|
catalog: ToolCatalog,
|
||||||
|
*,
|
||||||
|
track: str | None = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Add tools from a ToolCatalog to the internal registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
catalog: A ToolCatalog containing registered Python tools.
|
||||||
|
track: Optional track name for comparative evaluations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Self for method chaining.
|
||||||
|
"""
|
||||||
|
# Delegate to the shared helper method defined in EvalSuite
|
||||||
|
self._register_catalog_tools(catalog, track=track) # type: ignore[attr-defined]
|
||||||
|
return self
|
||||||
|
|
||||||
|
def get_tool_count(self, track: str | None = None) -> int:
|
||||||
|
"""Get the number of registered tools.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
track: Optional track name. If provided, counts tools in that track.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of tools.
|
||||||
|
"""
|
||||||
|
if track is not None:
|
||||||
|
registry = self._track_manager.get_registry(track)
|
||||||
|
return registry.tool_count() if registry else 0
|
||||||
|
if self._internal_registry is None:
|
||||||
|
return 0
|
||||||
|
return self._internal_registry.tool_count()
|
||||||
|
|
||||||
|
def list_tool_names(self, track: str | None = None) -> list[str]:
|
||||||
|
"""List all registered tool names.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
track: Optional track name. If provided, lists tools in that track.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tool names.
|
||||||
|
"""
|
||||||
|
if track is not None:
|
||||||
|
registry = self._track_manager.get_registry(track)
|
||||||
|
return registry.tool_names() if registry else []
|
||||||
|
if self._internal_registry is None:
|
||||||
|
return []
|
||||||
|
return self._internal_registry.tool_names()
|
||||||
149
libs/arcade-evals/arcade_evals/_evalsuite/_openai_schema.py
Normal file
149
libs/arcade-evals/arcade_evals/_evalsuite/_openai_schema.py
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
"""OpenAI tool schema conversion (internal).
|
||||||
|
|
||||||
|
Converts MCP-style tool schemas to OpenAI's tool format with strict mode support.
|
||||||
|
|
||||||
|
OpenAI strict mode requirements:
|
||||||
|
- additionalProperties: false at all object levels
|
||||||
|
- properties and required present on all object schemas
|
||||||
|
- required includes ALL properties (optional params use null union types)
|
||||||
|
- Unsupported JSON Schema keywords are stripped
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Maximum recursion depth to prevent infinite loops in circular schema references
|
||||||
|
_MAX_SCHEMA_DEPTH = 50
|
||||||
|
|
||||||
|
# Keywords not supported by OpenAI strict mode that should be stripped
|
||||||
|
_UNSUPPORTED_STRICT_MODE_KEYWORDS = frozenset({
|
||||||
|
"minimum",
|
||||||
|
"maximum",
|
||||||
|
"exclusiveMinimum",
|
||||||
|
"exclusiveMaximum",
|
||||||
|
"minLength",
|
||||||
|
"maxLength",
|
||||||
|
"pattern",
|
||||||
|
"format",
|
||||||
|
"default",
|
||||||
|
"nullable",
|
||||||
|
"minItems",
|
||||||
|
"maxItems",
|
||||||
|
"uniqueItems",
|
||||||
|
"minProperties",
|
||||||
|
"maxProperties",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaConversionError(Exception):
|
||||||
|
"""Raised when schema conversion fails."""
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_strict_mode_schema(parameters: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Convert an input JSON schema (MCP `inputSchema`) to OpenAI strict mode format.
|
||||||
|
|
||||||
|
OpenAI strict mode requires:
|
||||||
|
- additionalProperties: false at all object levels
|
||||||
|
- properties and required present on all object schemas
|
||||||
|
- required includes ALL properties
|
||||||
|
- optional params become union types with null (e.g., ["string", "null"])
|
||||||
|
- unsupported JSON Schema keywords stripped
|
||||||
|
"""
|
||||||
|
result = copy.deepcopy(parameters)
|
||||||
|
strict_schema = _apply_strict_mode_recursive(result, depth=0)
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": strict_schema.get("properties", {}),
|
||||||
|
"required": strict_schema.get("required", []),
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_strict_mode_recursive(schema: dict[str, Any], *, depth: int = 0) -> dict[str, Any]:
|
||||||
|
if depth > _MAX_SCHEMA_DEPTH:
|
||||||
|
raise SchemaConversionError(
|
||||||
|
f"Schema nesting exceeds maximum depth of {_MAX_SCHEMA_DEPTH}. "
|
||||||
|
"This may indicate a circular reference in the schema."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Strip unsupported keywords that OpenAI strict mode doesn't allow
|
||||||
|
for keyword in _UNSUPPORTED_STRICT_MODE_KEYWORDS:
|
||||||
|
schema.pop(keyword, None)
|
||||||
|
|
||||||
|
# OpenAI strict mode enum handling:
|
||||||
|
# 1. OpenAI requires enum values to be strings
|
||||||
|
# 2. OpenAI validates that enum values match the declared type
|
||||||
|
# 3. When we convert enum values to strings, we must also change the type to "string"
|
||||||
|
#
|
||||||
|
# Example: {"type": "integer", "enum": [0, 1, 2]} becomes {"type": "string", "enum": ["0", "1", "2"]}
|
||||||
|
# Example: {"type": ["integer", "null"], "enum": [0, 1]} becomes {"type": ["string", "null"], "enum": ["0", "1"]}
|
||||||
|
#
|
||||||
|
# Without this fix, OpenAI returns: "enum value 0 does not validate against {'type': ['integer', 'null']}"
|
||||||
|
if "enum" in schema:
|
||||||
|
schema["enum"] = [str(v) for v in schema["enum"]]
|
||||||
|
# Change type to string to match the stringified enum values
|
||||||
|
current_type = schema.get("type")
|
||||||
|
if current_type and current_type != "string":
|
||||||
|
if isinstance(current_type, str):
|
||||||
|
schema["type"] = "string"
|
||||||
|
elif isinstance(current_type, list) and "string" not in current_type:
|
||||||
|
# Replace non-string types with string, preserve null if present
|
||||||
|
has_null = "null" in current_type
|
||||||
|
if has_null:
|
||||||
|
schema["type"] = ["string", "null"]
|
||||||
|
else:
|
||||||
|
# Single type without null should be simplified to string
|
||||||
|
schema["type"] = "string"
|
||||||
|
|
||||||
|
schema_type = schema.get("type")
|
||||||
|
|
||||||
|
if schema_type == "object":
|
||||||
|
schema["additionalProperties"] = False
|
||||||
|
schema.setdefault("properties", {})
|
||||||
|
|
||||||
|
properties = schema.get("properties", {})
|
||||||
|
required = set(schema.get("required", []))
|
||||||
|
|
||||||
|
new_properties: dict[str, Any] = {}
|
||||||
|
all_param_names: list[str] = []
|
||||||
|
|
||||||
|
for param_name, param_schema in properties.items():
|
||||||
|
if not isinstance(param_schema, dict):
|
||||||
|
new_properties[param_name] = param_schema
|
||||||
|
all_param_names.append(param_name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
processed_schema = _apply_strict_mode_recursive(param_schema, depth=depth + 1)
|
||||||
|
|
||||||
|
# Optional param: add null to type union
|
||||||
|
if param_name not in required:
|
||||||
|
param_type = processed_schema.get("type")
|
||||||
|
if isinstance(param_type, str):
|
||||||
|
processed_schema["type"] = [param_type, "null"]
|
||||||
|
elif isinstance(param_type, list) and "null" not in param_type:
|
||||||
|
processed_schema["type"] = [*param_type, "null"]
|
||||||
|
|
||||||
|
new_properties[param_name] = processed_schema
|
||||||
|
all_param_names.append(param_name)
|
||||||
|
|
||||||
|
schema["properties"] = new_properties
|
||||||
|
schema["required"] = all_param_names
|
||||||
|
|
||||||
|
elif schema_type == "array":
|
||||||
|
items = schema.get("items")
|
||||||
|
if isinstance(items, dict):
|
||||||
|
schema["items"] = _apply_strict_mode_recursive(items, depth=depth + 1)
|
||||||
|
|
||||||
|
for combiner in ("anyOf", "oneOf", "allOf"):
|
||||||
|
if combiner in schema:
|
||||||
|
schema[combiner] = [
|
||||||
|
_apply_strict_mode_recursive(option, depth=depth + 1)
|
||||||
|
if isinstance(option, dict)
|
||||||
|
else option
|
||||||
|
for option in schema[combiner]
|
||||||
|
]
|
||||||
|
|
||||||
|
return schema
|
||||||
151
libs/arcade-evals/arcade_evals/_evalsuite/_providers.py
Normal file
151
libs/arcade-evals/arcade_evals/_evalsuite/_providers.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
"""Provider abstractions and message conversion utilities.
|
||||||
|
|
||||||
|
This module contains:
|
||||||
|
- ProviderName type for supported LLM providers
|
||||||
|
- Message conversion utilities for different provider formats
|
||||||
|
|
||||||
|
Anthropic has different message format requirements than OpenAI:
|
||||||
|
- Only "user" and "assistant" roles (system is a separate parameter)
|
||||||
|
- tool_use/tool_result content blocks instead of tool_calls/tool role
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Supported LLM providers for evaluations
|
||||||
|
ProviderName = Literal["openai", "anthropic"]
|
||||||
|
|
||||||
|
|
||||||
|
def convert_messages_to_anthropic(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Convert OpenAI-format messages to Anthropic format.
|
||||||
|
|
||||||
|
Anthropic only supports "user" and "assistant" roles (system is a separate parameter).
|
||||||
|
|
||||||
|
Key differences handled:
|
||||||
|
- "system" -> skipped (handled separately in Anthropic API)
|
||||||
|
- "user" -> "user" (pass through)
|
||||||
|
- "assistant" -> "assistant" (pass through)
|
||||||
|
- "assistant" with "tool_calls" -> "assistant" with tool_use content blocks
|
||||||
|
- "tool" -> "user" with tool_result content block
|
||||||
|
- "function" (legacy) -> "user" with tool_result content block
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of OpenAI-format messages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Anthropic-format messages
|
||||||
|
"""
|
||||||
|
anthropic_messages: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
role = msg.get("role", "")
|
||||||
|
|
||||||
|
if role == "system":
|
||||||
|
# Skip system messages - Anthropic API takes system as a separate parameter.
|
||||||
|
# In _run_anthropic(), we pass system=case.system_message to client.messages.create().
|
||||||
|
# This is the correct approach per Anthropic's API design.
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif role == "user":
|
||||||
|
# User messages convert directly
|
||||||
|
content = msg.get("content", "")
|
||||||
|
if content:
|
||||||
|
anthropic_messages.append({"role": "user", "content": content})
|
||||||
|
|
||||||
|
elif role == "assistant":
|
||||||
|
if "tool_calls" in msg and msg.get("tool_calls"):
|
||||||
|
# Convert OpenAI tool_calls to Anthropic tool_use blocks
|
||||||
|
# Anthropic supports mixed content: text blocks + tool_use blocks
|
||||||
|
content_blocks: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
# Include text content if present (assistant can say something before using tools)
|
||||||
|
text_content = msg.get("content")
|
||||||
|
if text_content:
|
||||||
|
content_blocks.append({"type": "text", "text": text_content})
|
||||||
|
|
||||||
|
# Add tool_use blocks
|
||||||
|
for tool_call in msg.get("tool_calls", []):
|
||||||
|
function = tool_call.get("function")
|
||||||
|
if not function:
|
||||||
|
continue # Skip malformed tool calls
|
||||||
|
|
||||||
|
# Parse arguments JSON
|
||||||
|
arguments_str = function.get("arguments", "{}")
|
||||||
|
try:
|
||||||
|
arguments = json.loads(arguments_str) if arguments_str else {}
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to parse tool arguments JSON for '%s': %s. Using empty dict.",
|
||||||
|
function.get("name", "unknown"),
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
arguments = {}
|
||||||
|
|
||||||
|
content_blocks.append({
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": tool_call.get("id", ""),
|
||||||
|
"name": function.get("name", ""),
|
||||||
|
"input": arguments,
|
||||||
|
})
|
||||||
|
|
||||||
|
if content_blocks:
|
||||||
|
anthropic_messages.append({"role": "assistant", "content": content_blocks})
|
||||||
|
else:
|
||||||
|
# Regular assistant message (no tool calls)
|
||||||
|
content = msg.get("content", "")
|
||||||
|
if content:
|
||||||
|
anthropic_messages.append({"role": "assistant", "content": content})
|
||||||
|
|
||||||
|
elif role == "tool":
|
||||||
|
# Convert OpenAI tool response to Anthropic tool_result block
|
||||||
|
tool_result_block = {
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": msg.get("tool_call_id", ""),
|
||||||
|
"content": msg.get("content", ""),
|
||||||
|
}
|
||||||
|
# Batch consecutive tool results into the last user message
|
||||||
|
if anthropic_messages and anthropic_messages[-1]["role"] == "user":
|
||||||
|
# Add to existing user message's content array
|
||||||
|
last_content = anthropic_messages[-1]["content"]
|
||||||
|
if isinstance(last_content, list):
|
||||||
|
last_content.append(tool_result_block)
|
||||||
|
else:
|
||||||
|
# Convert string content to array with both blocks
|
||||||
|
anthropic_messages[-1]["content"] = [
|
||||||
|
{"type": "text", "text": last_content},
|
||||||
|
tool_result_block,
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
# Start new user message with tool result
|
||||||
|
anthropic_messages.append({"role": "user", "content": [tool_result_block]})
|
||||||
|
|
||||||
|
elif role == "function":
|
||||||
|
# Legacy OpenAI function role (deprecated) - same as tool
|
||||||
|
tool_result_block = {
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": msg.get("name", ""), # function uses "name" not "tool_call_id"
|
||||||
|
"content": msg.get("content", ""),
|
||||||
|
}
|
||||||
|
# Batch consecutive tool results into the last user message
|
||||||
|
if anthropic_messages and anthropic_messages[-1]["role"] == "user":
|
||||||
|
# Add to existing user message's content array
|
||||||
|
last_content = anthropic_messages[-1]["content"]
|
||||||
|
if isinstance(last_content, list):
|
||||||
|
last_content.append(tool_result_block)
|
||||||
|
else:
|
||||||
|
# Convert string content to array with both blocks
|
||||||
|
anthropic_messages[-1]["content"] = [
|
||||||
|
{"type": "text", "text": last_content},
|
||||||
|
tool_result_block,
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
# Start new user message with tool result
|
||||||
|
anthropic_messages.append({"role": "user", "content": [tool_result_block]})
|
||||||
|
|
||||||
|
return anthropic_messages
|
||||||
283
libs/arcade-evals/arcade_evals/_evalsuite/_tool_registry.py
Normal file
283
libs/arcade-evals/arcade_evals/_evalsuite/_tool_registry.py
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
"""EvalSuite internal unified tool registry (not part of the public API)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal, TypedDict
|
||||||
|
|
||||||
|
from arcade_core.converters.anthropic import to_anthropic
|
||||||
|
from arcade_core.converters.utils import normalize_tool_name
|
||||||
|
|
||||||
|
from arcade_evals._evalsuite._anthropic_schema import convert_mcp_to_anthropic_tool
|
||||||
|
from arcade_evals._evalsuite._openai_schema import convert_to_strict_mode_schema
|
||||||
|
|
||||||
|
ToolFormat = Literal["openai", "anthropic"]
|
||||||
|
|
||||||
|
|
||||||
|
class _MCPToolDefinitionRequired(TypedDict):
|
||||||
|
"""Required fields for MCP-style tool definition."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class MCPToolDefinition(_MCPToolDefinitionRequired, total=False):
|
||||||
|
"""MCP-style tool definition structure.
|
||||||
|
|
||||||
|
This is the format expected by `add_tool_definitions()` and used internally
|
||||||
|
by the EvalSuiteToolRegistry.
|
||||||
|
|
||||||
|
Required:
|
||||||
|
name: The unique tool name.
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
description: Human-readable description (defaults to "").
|
||||||
|
inputSchema: JSON Schema for input parameters
|
||||||
|
(defaults to {"type": "object", "properties": {}}).
|
||||||
|
"""
|
||||||
|
|
||||||
|
description: str
|
||||||
|
inputSchema: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class EvalSuiteToolRegistry:
|
||||||
|
"""
|
||||||
|
A minimal internal registry that stores tools in MCP-style descriptors:
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "...",
|
||||||
|
"description": "...",
|
||||||
|
"inputSchema": { ... JSON Schema ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
EvalSuite converts Python tools into this format too, so there is only one
|
||||||
|
runtime path for OpenAI tool formatting.
|
||||||
|
|
||||||
|
Note: Tools are stored with their original names (e.g., "Google.Search"),
|
||||||
|
but Anthropic requires underscores (e.g., "Google_Search"). The registry
|
||||||
|
maintains a mapping to look up tools by either format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, strict_mode: bool = True) -> None:
|
||||||
|
self._tools: dict[str, dict[str, Any]] = {}
|
||||||
|
self._strict_mode = strict_mode
|
||||||
|
# Mapping from normalized names (underscores) to original names (dots)
|
||||||
|
# e.g., {"Google_Search": "Google.Search"}
|
||||||
|
self._normalized_to_original: dict[str, str] = {}
|
||||||
|
# Store original MaterializedTool objects for direct Anthropic conversion (Python tools only)
|
||||||
|
self._materialized_tools: dict[str, Any] = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strict_mode(self) -> bool:
|
||||||
|
return self._strict_mode
|
||||||
|
|
||||||
|
@strict_mode.setter
|
||||||
|
def strict_mode(self, value: bool) -> None:
|
||||||
|
self._strict_mode = value
|
||||||
|
|
||||||
|
def add_tool(
|
||||||
|
self,
|
||||||
|
tool_descriptor: MCPToolDefinition | dict[str, Any],
|
||||||
|
materialized_tool: Any = None,
|
||||||
|
) -> None:
|
||||||
|
"""Add a tool to the registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_descriptor: MCP-style tool definition.
|
||||||
|
materialized_tool: Optional MaterializedTool for direct Anthropic conversion (Python tools only).
|
||||||
|
"""
|
||||||
|
if "name" not in tool_descriptor:
|
||||||
|
raise ValueError("Tool descriptor must have a 'name' field")
|
||||||
|
name = tool_descriptor["name"]
|
||||||
|
if name in self._tools:
|
||||||
|
raise ValueError(
|
||||||
|
f"Tool '{name}' already registered. "
|
||||||
|
"Each tool name must be unique across all sources (MCP servers, gateways, catalogs)."
|
||||||
|
)
|
||||||
|
self._tools[name] = dict(tool_descriptor)
|
||||||
|
|
||||||
|
# Store MaterializedTool if provided (for direct Anthropic conversion)
|
||||||
|
if materialized_tool is not None:
|
||||||
|
self._materialized_tools[name] = materialized_tool
|
||||||
|
|
||||||
|
# Build normalized name mapping for Anthropic/OpenAI lookups
|
||||||
|
# e.g., "Google.Search" -> normalized key "Google_Search"
|
||||||
|
normalized_name = normalize_tool_name(name)
|
||||||
|
if normalized_name != name:
|
||||||
|
# Check for collision: if the normalized name already exists as a direct tool
|
||||||
|
# (e.g., registering "Google.Search" when "Google_Search" already exists),
|
||||||
|
# the normalized lookup would be ambiguous
|
||||||
|
if normalized_name in self._tools:
|
||||||
|
# The underscore version is registered directly, so normalized lookups
|
||||||
|
# should prefer that. Don't add to mapping to avoid ambiguity.
|
||||||
|
pass
|
||||||
|
elif normalized_name in self._normalized_to_original:
|
||||||
|
# Another dotted tool already maps to this normalized name
|
||||||
|
# e.g., "A.B" and "A_B" (as "A.B") would both normalize to "A_B"
|
||||||
|
# Keep the first mapping to avoid silent overwrites
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
self._normalized_to_original[normalized_name] = name
|
||||||
|
|
||||||
|
def add_tools(self, tools: list[MCPToolDefinition] | list[dict[str, Any]]) -> None:
|
||||||
|
for tool in tools:
|
||||||
|
self.add_tool(tool)
|
||||||
|
|
||||||
|
def list_tools_for_model(self, tool_format: ToolFormat = "openai") -> list[dict[str, Any]]:
|
||||||
|
if tool_format == "openai":
|
||||||
|
return self._to_openai_format()
|
||||||
|
elif tool_format == "anthropic":
|
||||||
|
return self._to_anthropic_format()
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Tool format '{tool_format}' is not supported")
|
||||||
|
|
||||||
|
def _to_openai_format(self) -> list[dict[str, Any]]:
|
||||||
|
"""Convert stored MCP tools to OpenAI function calling format.
|
||||||
|
|
||||||
|
Note: Tool names are normalized (dots replaced with underscores) because
|
||||||
|
OpenAI function names don't allow dots.
|
||||||
|
"""
|
||||||
|
openai_tools: list[dict[str, Any]] = []
|
||||||
|
for tool in self._tools.values():
|
||||||
|
parameters = tool.get("inputSchema", {"type": "object", "properties": {}})
|
||||||
|
if self._strict_mode and isinstance(parameters, dict):
|
||||||
|
parameters = convert_to_strict_mode_schema(parameters)
|
||||||
|
|
||||||
|
# Normalize tool name for OpenAI (e.g., "Google.Search" -> "Google_Search")
|
||||||
|
# OpenAI function names don't allow dots
|
||||||
|
tool_name = normalize_tool_name(tool["name"])
|
||||||
|
|
||||||
|
openai_tool: dict[str, Any] = {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": tool_name,
|
||||||
|
"description": tool.get("description", ""),
|
||||||
|
"parameters": parameters,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if self._strict_mode:
|
||||||
|
openai_tool["function"]["strict"] = True
|
||||||
|
openai_tools.append(openai_tool)
|
||||||
|
|
||||||
|
return openai_tools
|
||||||
|
|
||||||
|
def _to_anthropic_format(self) -> list[dict[str, Any]]:
|
||||||
|
"""Convert stored tools to Anthropic format.
|
||||||
|
|
||||||
|
Uses direct to_anthropic() from arcade-core for Python tools (when MaterializedTool available),
|
||||||
|
falls back to convert_mcp_to_anthropic_tool() for MCP/remote tools (JSON descriptors only).
|
||||||
|
"""
|
||||||
|
anthropic_tools: list[dict[str, Any]] = []
|
||||||
|
for tool_name, tool_descriptor in self._tools.items():
|
||||||
|
# Python tools: use direct converter (we have MaterializedTool)
|
||||||
|
if tool_name in self._materialized_tools:
|
||||||
|
anthropic_tool = to_anthropic(self._materialized_tools[tool_name])
|
||||||
|
anthropic_tools.append(dict(anthropic_tool))
|
||||||
|
else:
|
||||||
|
# MCP/remote tools: convert from JSON descriptor (no MaterializedTool available)
|
||||||
|
# Used for tools from: load_mcp_remote_async(), load_from_stdio_async(),
|
||||||
|
# load_arcade_mcp_gateway_async(), or add_tool_definitions()
|
||||||
|
anthropic_tools.append(convert_mcp_to_anthropic_tool(tool_descriptor))
|
||||||
|
|
||||||
|
return anthropic_tools
|
||||||
|
|
||||||
|
def _resolve_tool_name(self, tool_name: str) -> str | None:
|
||||||
|
"""Resolve a tool name to its original registry key.
|
||||||
|
|
||||||
|
Handles both original names (e.g., "Google.Search") and
|
||||||
|
normalized names (e.g., "Google_Search" from Anthropic).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: The tool name to resolve.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The original tool name if found, None otherwise.
|
||||||
|
"""
|
||||||
|
# First, try direct lookup
|
||||||
|
if tool_name in self._tools:
|
||||||
|
return tool_name
|
||||||
|
# Then, check if it's a normalized name (from Anthropic)
|
||||||
|
original_name = self._normalized_to_original.get(tool_name)
|
||||||
|
if original_name and original_name in self._tools:
|
||||||
|
return original_name
|
||||||
|
return None
|
||||||
|
|
||||||
|
def normalize_args(self, tool_name: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Apply schema defaults to arguments.
|
||||||
|
|
||||||
|
Fills in default values from the tool schema for:
|
||||||
|
- Missing parameters (key not in args)
|
||||||
|
- Null parameters (value is None), which OpenAI strict mode sends for optional params
|
||||||
|
|
||||||
|
This ensures that optional parameters with defaults are properly filled
|
||||||
|
even when the model sends null values.
|
||||||
|
"""
|
||||||
|
resolved_name = self._resolve_tool_name(tool_name)
|
||||||
|
tool = self._tools.get(resolved_name) if resolved_name else None
|
||||||
|
if not tool:
|
||||||
|
return args
|
||||||
|
|
||||||
|
schema = tool.get("inputSchema", {})
|
||||||
|
if not isinstance(schema, dict):
|
||||||
|
return args
|
||||||
|
|
||||||
|
properties = schema.get("properties", {})
|
||||||
|
if not isinstance(properties, dict):
|
||||||
|
return args
|
||||||
|
|
||||||
|
normalized = dict(args)
|
||||||
|
for prop_name, prop_schema in properties.items():
|
||||||
|
# Apply default if parameter is missing OR if it's null (None)
|
||||||
|
# OpenAI strict mode sends null for optional parameters that weren't provided
|
||||||
|
should_apply_default = (
|
||||||
|
isinstance(prop_schema, dict)
|
||||||
|
and "default" in prop_schema
|
||||||
|
and (prop_name not in normalized or normalized[prop_name] is None)
|
||||||
|
)
|
||||||
|
if should_apply_default:
|
||||||
|
normalized[prop_name] = prop_schema["default"]
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def get_tool_schema(self, tool_name: str) -> dict[str, Any] | None:
|
||||||
|
resolved_name = self._resolve_tool_name(tool_name)
|
||||||
|
return self._tools.get(resolved_name) if resolved_name else None
|
||||||
|
|
||||||
|
def has_tool(self, tool_name: str) -> bool:
|
||||||
|
return self._resolve_tool_name(tool_name) is not None
|
||||||
|
|
||||||
|
def resolve_tool_name(self, tool_name: str) -> str | None:
|
||||||
|
"""Public method to resolve a tool name to its original registry key.
|
||||||
|
|
||||||
|
This is useful for callers that need to look up tools by names
|
||||||
|
returned from providers (e.g., Anthropic returns underscore names).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: The tool name to resolve.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The original tool name if found, None otherwise.
|
||||||
|
"""
|
||||||
|
return self._resolve_tool_name(tool_name)
|
||||||
|
|
||||||
|
def process_tool_call(self, tool_name: str, args: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||||
|
"""Resolve tool name and apply schema defaults in one step.
|
||||||
|
|
||||||
|
This combines name resolution (for Anthropic underscore -> dot conversion)
|
||||||
|
with schema default application.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: The tool name (may be in provider format like "Google_Search").
|
||||||
|
args: The arguments from the tool call.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (resolved_name, args_with_defaults).
|
||||||
|
resolved_name will be the original registered name (e.g., "Google.Search")
|
||||||
|
or the input name if not found in registry.
|
||||||
|
"""
|
||||||
|
resolved_name = self._resolve_tool_name(tool_name) or tool_name
|
||||||
|
args_with_defaults = self.normalize_args(tool_name, args)
|
||||||
|
return resolved_name, args_with_defaults
|
||||||
|
|
||||||
|
def tool_names(self) -> list[str]:
|
||||||
|
return list(self._tools.keys())
|
||||||
|
|
||||||
|
def tool_count(self) -> int:
|
||||||
|
return len(self._tools)
|
||||||
97
libs/arcade-evals/arcade_evals/_evalsuite/_tracks.py
Normal file
97
libs/arcade-evals/arcade_evals/_evalsuite/_tracks.py
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
"""Track management for comparative evaluations.
|
||||||
|
|
||||||
|
A track represents an isolated tool registry with a unique name.
|
||||||
|
This enables running the same evaluation cases against different
|
||||||
|
tool sources (e.g., different MCP servers) for comparison.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from arcade_evals._evalsuite._tool_registry import EvalSuiteToolRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class TrackManager:
|
||||||
|
"""Manages named tracks, each with its own isolated tool registry.
|
||||||
|
|
||||||
|
Tracks enable comparative evaluations where the same cases are run
|
||||||
|
against different tool sources.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
manager = TrackManager()
|
||||||
|
manager.create_track("Google Weather", registry1)
|
||||||
|
manager.create_track("OpenWeather", registry2)
|
||||||
|
|
||||||
|
for track_name in manager.get_track_names():
|
||||||
|
registry = manager.get_registry(track_name)
|
||||||
|
# Run cases against this registry
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._tracks: dict[str, EvalSuiteToolRegistry] = {}
|
||||||
|
|
||||||
|
def create_track(self, name: str, registry: EvalSuiteToolRegistry) -> str:
|
||||||
|
"""Create a new track with an isolated registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Unique track name.
|
||||||
|
registry: The tool registry for this track.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The track name (for use as track ID).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If track name already exists.
|
||||||
|
"""
|
||||||
|
if name in self._tracks:
|
||||||
|
raise ValueError(f"Track '{name}' already exists. Use a unique track name.")
|
||||||
|
self._tracks[name] = registry
|
||||||
|
return name
|
||||||
|
|
||||||
|
def get_registry(self, track_name: str) -> EvalSuiteToolRegistry | None:
|
||||||
|
"""Get the registry for a track.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
track_name: The track name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The registry if found, None otherwise.
|
||||||
|
"""
|
||||||
|
return self._tracks.get(track_name)
|
||||||
|
|
||||||
|
def get_track_names(self) -> list[str]:
|
||||||
|
"""Get all registered track names.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of track names in registration order.
|
||||||
|
"""
|
||||||
|
return list(self._tracks.keys())
|
||||||
|
|
||||||
|
def has_track(self, name: str) -> bool:
|
||||||
|
"""Check if a track exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: The track name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if track exists, False otherwise.
|
||||||
|
"""
|
||||||
|
return name in self._tracks
|
||||||
|
|
||||||
|
def track_count(self) -> int:
|
||||||
|
"""Get number of registered tracks.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of tracks.
|
||||||
|
"""
|
||||||
|
return len(self._tracks)
|
||||||
|
|
||||||
|
def get_all_registries(self) -> dict[str, EvalSuiteToolRegistry]:
|
||||||
|
"""Get all registries by track name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping track names to registries.
|
||||||
|
"""
|
||||||
|
return dict(self._tracks)
|
||||||
176
libs/arcade-evals/arcade_evals/_evalsuite/_types.py
Normal file
176
libs/arcade-evals/arcade_evals/_evalsuite/_types.py
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
"""Shared types for eval suite modules.
|
||||||
|
|
||||||
|
This module contains dataclasses and types that are shared between
|
||||||
|
eval.py and the _evalsuite submodules, avoiding circular imports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from arcade_evals.critic import Critic
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExpectedToolCall:
|
||||||
|
"""
|
||||||
|
Represents an expected tool call for a Python tool (registered via ToolCatalog).
|
||||||
|
|
||||||
|
Use this for Python functions decorated with @tool.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
func: The Python function itself.
|
||||||
|
args: A dictionary containing the expected arguments for the tool.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
ExpectedToolCall(func=my_tool_function, args={"x": 1, "y": 2})
|
||||||
|
ExpectedToolCall(my_tool_function, {"x": 1}) # Positional args supported
|
||||||
|
"""
|
||||||
|
|
||||||
|
func: Callable
|
||||||
|
args: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExpectedMCPToolCall:
|
||||||
|
"""
|
||||||
|
Represents an expected tool call identified by tool name (string).
|
||||||
|
|
||||||
|
Use this for:
|
||||||
|
- Tools loaded from MCP servers (local stdio or remote HTTP)
|
||||||
|
- Tools loaded from Arcade Gateways
|
||||||
|
- Manual tool definitions (dictionaries with name/description/inputSchema)
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
tool_name: The name of the tool (e.g., "Weather_GetCurrent").
|
||||||
|
args: A dictionary containing the expected arguments for the tool.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
ExpectedMCPToolCall(tool_name="Calculator_Add", args={"a": 5, "b": 3})
|
||||||
|
ExpectedMCPToolCall("Calculator_Add", {"a": 5}) # Positional args supported
|
||||||
|
"""
|
||||||
|
|
||||||
|
tool_name: str
|
||||||
|
args: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
# Type alias for mixed usage (Python tools + MCP tools in same test case)
|
||||||
|
AnyExpectedToolCall = ExpectedToolCall | ExpectedMCPToolCall
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NamedExpectedToolCall:
|
||||||
|
"""
|
||||||
|
Represents a tool call with its name and arguments.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: The name of the tool.
|
||||||
|
args: A dictionary containing the expected arguments for the tool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
args: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EvalRubric:
|
||||||
|
"""
|
||||||
|
Defines the rubric for evaluating an AI model's performance on a task.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
fail_threshold: The minimum score required to pass the evaluation (between 0.0 and 1.0).
|
||||||
|
warn_threshold: The score threshold for issuing a warning (between 0.0 and 1.0).
|
||||||
|
fail_on_tool_selection: Whether to fail the evaluation if the tool selection is incorrect.
|
||||||
|
fail_on_tool_call_quantity: Whether to fail the evaluation if the number of tool calls is incorrect.
|
||||||
|
tool_selection_weight: The weight assigned to the tool selection score (between 0.0 and 1.0).
|
||||||
|
"""
|
||||||
|
|
||||||
|
fail_threshold: float = 0.8
|
||||||
|
warn_threshold: float = 0.9
|
||||||
|
fail_on_tool_selection: bool = True
|
||||||
|
fail_on_tool_call_quantity: bool = True
|
||||||
|
tool_selection_weight: float = 1.0
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Return a complete string representation of the rubric configuration."""
|
||||||
|
return (
|
||||||
|
f"EvalRubric(fail_threshold={self.fail_threshold}, "
|
||||||
|
f"warn_threshold={self.warn_threshold}, "
|
||||||
|
f"fail_on_tool_selection={self.fail_on_tool_selection}, "
|
||||||
|
f"fail_on_tool_call_quantity={self.fail_on_tool_call_quantity}, "
|
||||||
|
f"tool_selection_weight={self.tool_selection_weight})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""Return the same string representation for repr."""
|
||||||
|
return self.__str__()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrackConfig:
|
||||||
|
"""Configuration for a single track within a comparative case.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
expected_tool_calls: Expected tool calls for this track.
|
||||||
|
critics: Critics to evaluate tool arguments for this track.
|
||||||
|
"""
|
||||||
|
|
||||||
|
expected_tool_calls: list[ExpectedToolCall | ExpectedMCPToolCall]
|
||||||
|
critics: list[Critic] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ComparativeCase:
|
||||||
|
"""A case that runs against multiple tracks for comparison.
|
||||||
|
|
||||||
|
Shared context (messages) is defined once, while each track has
|
||||||
|
its own expected tool calls and critics.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: Unique case name.
|
||||||
|
user_message: User message (shared across tracks).
|
||||||
|
system_message: System message (shared across tracks).
|
||||||
|
additional_messages: Additional context messages (shared).
|
||||||
|
rubric: Evaluation rubric (shared, can be overridden per track).
|
||||||
|
track_configs: Track-specific configurations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
user_message: str
|
||||||
|
system_message: str = ""
|
||||||
|
additional_messages: list[dict[str, str]] = field(default_factory=list)
|
||||||
|
rubric: EvalRubric | None = None
|
||||||
|
track_configs: dict[str, TrackConfig] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def add_track_config(
|
||||||
|
self,
|
||||||
|
track_name: str,
|
||||||
|
expected_tool_calls: list[ExpectedToolCall | ExpectedMCPToolCall],
|
||||||
|
critics: list[Critic] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Add configuration for a track.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
track_name: The track name.
|
||||||
|
expected_tool_calls: Expected tool calls for this track.
|
||||||
|
critics: Critics for this track.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If track already configured.
|
||||||
|
"""
|
||||||
|
if track_name in self.track_configs:
|
||||||
|
raise ValueError(f"Track '{track_name}' already configured for case '{self.name}'.")
|
||||||
|
self.track_configs[track_name] = TrackConfig(
|
||||||
|
expected_tool_calls=expected_tool_calls,
|
||||||
|
critics=critics or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_configured_tracks(self) -> list[str]:
|
||||||
|
"""Get list of tracks configured for this case.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of track names.
|
||||||
|
"""
|
||||||
|
return list(self.track_configs.keys())
|
||||||
186
libs/arcade-evals/arcade_evals/capture.py
Normal file
186
libs/arcade-evals/arcade_evals/capture.py
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
"""
|
||||||
|
Capture mode for EvalSuite.
|
||||||
|
|
||||||
|
Capture mode runs evaluation cases and records tool calls from the model
|
||||||
|
without scoring or evaluating them. This is useful for:
|
||||||
|
- Generating expected tool calls for new test cases
|
||||||
|
- Debugging model behavior
|
||||||
|
- Creating baseline recordings
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from arcade_evals.eval import EvalSuite
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CapturedToolCall:
|
||||||
|
"""
|
||||||
|
A captured tool call from the model during capture mode.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: The name of the tool that was called.
|
||||||
|
args: The arguments passed to the tool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
args: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Convert to dictionary for JSON serialization."""
|
||||||
|
return {"name": self.name, "args": self.args}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CapturedCase:
|
||||||
|
"""
|
||||||
|
Result of running a single case in capture mode.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
case_name: The name of the evaluation case.
|
||||||
|
user_message: The user message that triggered the tool calls.
|
||||||
|
tool_calls: List of tool calls made by the model.
|
||||||
|
system_message: The system message (included if include_context is True).
|
||||||
|
additional_messages: Additional messages (included if include_context is True).
|
||||||
|
track_name: The track name for comparative captures (None for regular cases).
|
||||||
|
"""
|
||||||
|
|
||||||
|
case_name: str
|
||||||
|
user_message: str
|
||||||
|
tool_calls: list[CapturedToolCall] = field(default_factory=list)
|
||||||
|
system_message: str | None = None
|
||||||
|
additional_messages: list[dict[str, Any]] | None = None
|
||||||
|
track_name: str | None = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _try_parse_json(value: str) -> Any:
|
||||||
|
"""Try to parse a JSON string, returning the original string if parsing fails."""
|
||||||
|
try:
|
||||||
|
return json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Normalize additional_messages by parsing JSON strings into proper objects.
|
||||||
|
|
||||||
|
OpenAI returns:
|
||||||
|
- Tool call arguments as JSON strings in assistant messages
|
||||||
|
- Tool response content as JSON strings in tool messages
|
||||||
|
|
||||||
|
For cleaner output, we parse these into proper objects.
|
||||||
|
"""
|
||||||
|
normalized = []
|
||||||
|
for msg in messages:
|
||||||
|
msg_copy = dict(msg)
|
||||||
|
|
||||||
|
# Parse tool call arguments in assistant messages
|
||||||
|
if "tool_calls" in msg_copy and isinstance(msg_copy["tool_calls"], list):
|
||||||
|
normalized_tool_calls = []
|
||||||
|
for tc in msg_copy["tool_calls"]:
|
||||||
|
tc_copy = dict(tc)
|
||||||
|
if "function" in tc_copy and isinstance(tc_copy["function"], dict):
|
||||||
|
func = dict(tc_copy["function"])
|
||||||
|
if "arguments" in func and isinstance(func["arguments"], str):
|
||||||
|
func["arguments"] = CapturedCase._try_parse_json(func["arguments"])
|
||||||
|
tc_copy["function"] = func
|
||||||
|
normalized_tool_calls.append(tc_copy)
|
||||||
|
msg_copy["tool_calls"] = normalized_tool_calls
|
||||||
|
|
||||||
|
# Parse content in tool response messages
|
||||||
|
if msg_copy.get("role") == "tool" and isinstance(msg_copy.get("content"), str):
|
||||||
|
msg_copy["content"] = CapturedCase._try_parse_json(msg_copy["content"])
|
||||||
|
|
||||||
|
normalized.append(msg_copy)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def to_dict(self, include_context: bool = False) -> dict[str, Any]:
|
||||||
|
"""Convert to dictionary for JSON serialization."""
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"case_name": self.case_name,
|
||||||
|
"user_message": self.user_message,
|
||||||
|
"tool_calls": [tc.to_dict() for tc in self.tool_calls],
|
||||||
|
}
|
||||||
|
if self.track_name:
|
||||||
|
result["track_name"] = self.track_name
|
||||||
|
if include_context:
|
||||||
|
result["system_message"] = self.system_message
|
||||||
|
# Normalize additional_messages to parse JSON string arguments
|
||||||
|
raw_messages = self.additional_messages or []
|
||||||
|
result["additional_messages"] = self._normalize_messages(raw_messages)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CaptureResult:
|
||||||
|
"""
|
||||||
|
Result of running an EvalSuite in capture mode.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
suite_name: The name of the evaluation suite.
|
||||||
|
model: The model used for capture.
|
||||||
|
provider: The provider used (openai, anthropic).
|
||||||
|
captured_cases: List of captured cases with tool calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
suite_name: str
|
||||||
|
model: str
|
||||||
|
provider: str
|
||||||
|
captured_cases: list[CapturedCase] = field(default_factory=list)
|
||||||
|
|
||||||
|
def to_dict(self, include_context: bool = False) -> dict[str, Any]:
|
||||||
|
"""Convert to dictionary for JSON serialization."""
|
||||||
|
return {
|
||||||
|
"suite_name": self.suite_name,
|
||||||
|
"model": self.model,
|
||||||
|
"provider": self.provider,
|
||||||
|
"captured_cases": [c.to_dict(include_context) for c in self.captured_cases],
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_json(self, include_context: bool = False, indent: int = 2) -> str:
|
||||||
|
"""Convert to JSON string."""
|
||||||
|
return json.dumps(self.to_dict(include_context), indent=indent)
|
||||||
|
|
||||||
|
def write_to_file(self, file_path: str, include_context: bool = False, indent: int = 2) -> None:
|
||||||
|
"""Write capture results to a JSON file."""
|
||||||
|
with open(file_path, "w") as f:
|
||||||
|
f.write(self.to_json(include_context, indent))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Helper functions for running capture mode ---
|
||||||
|
|
||||||
|
|
||||||
|
async def _capture_with_openai(
|
||||||
|
suite: EvalSuite, api_key: str, model: str, include_context: bool = False
|
||||||
|
) -> CaptureResult:
|
||||||
|
"""Run capture mode with OpenAI client."""
|
||||||
|
async with AsyncOpenAI(api_key=api_key) as client:
|
||||||
|
return await suite.capture(
|
||||||
|
client, model, provider="openai", include_context=include_context
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _capture_with_anthropic(
|
||||||
|
suite: EvalSuite, api_key: str, model: str, include_context: bool = False
|
||||||
|
) -> CaptureResult:
|
||||||
|
"""Run capture mode with Anthropic client."""
|
||||||
|
try:
|
||||||
|
from anthropic import AsyncAnthropic
|
||||||
|
except ImportError as e:
|
||||||
|
raise ImportError(
|
||||||
|
"The 'anthropic' package is required for Anthropic provider. "
|
||||||
|
"Install it with: pip install anthropic"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
async with AsyncAnthropic(api_key=api_key) as client:
|
||||||
|
return await suite.capture(
|
||||||
|
client, model, provider="anthropic", include_context=include_context
|
||||||
|
)
|
||||||
|
|
@ -7,16 +7,34 @@ import pytz
|
||||||
from dateutil import parser
|
from dateutil import parser
|
||||||
|
|
||||||
from arcade_evals.errors import WeightError
|
from arcade_evals.errors import WeightError
|
||||||
|
from arcade_evals.weights import FuzzyWeight, Weight, resolve_weight
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Critic(ABC):
|
class Critic(ABC):
|
||||||
|
"""
|
||||||
|
Base class for all critics.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
critic_field: The field name this critic evaluates.
|
||||||
|
weight: The weight for this critic. Can be a float (0.0-1.0) or FuzzyWeight enum.
|
||||||
|
When using FuzzyWeight, weights are auto-normalized to sum to 1.0.
|
||||||
|
"""
|
||||||
|
|
||||||
critic_field: str
|
critic_field: str
|
||||||
weight: float
|
weight: Weight
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if self.weight < 0 or self.weight > 1:
|
if isinstance(self.weight, FuzzyWeight):
|
||||||
raise WeightError(f"Critic weight must be between 0 and 1, got {self.weight}")
|
return
|
||||||
|
|
||||||
|
if self.weight < 0:
|
||||||
|
raise WeightError(f"Critic weight must be non-negative, got {self.weight}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def resolved_weight(self) -> float:
|
||||||
|
"""Get the weight as a float value."""
|
||||||
|
return resolve_weight(self.weight)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def evaluate(self, expected: Any, actual: Any) -> dict[str, Any]:
|
def evaluate(self, expected: Any, actual: Any) -> dict[str, Any]:
|
||||||
|
|
@ -32,6 +50,10 @@ class NoneCritic(Critic):
|
||||||
a NoneCritic is used to indicate that the field was not criticized.
|
a NoneCritic is used to indicate that the field was not criticized.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Marker attribute to identify placeholder critics without isinstance checks
|
||||||
|
# (avoids circular imports in weights.py)
|
||||||
|
_is_placeholder: ClassVar[bool] = True
|
||||||
|
|
||||||
weight: float = 0.0
|
weight: float = 0.0
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
|
|
@ -108,7 +130,7 @@ class BinaryCritic(Critic):
|
||||||
actual_casted = actual
|
actual_casted = actual
|
||||||
|
|
||||||
match = expected == actual_casted
|
match = expected == actual_casted
|
||||||
return {"match": match, "score": self.weight if match else 0.0}
|
return {"match": match, "score": self.resolved_weight if match else 0.0}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -158,7 +180,10 @@ class NumericCritic(Critic):
|
||||||
normalized_expected = float((float(expected) - min_val) / (max_val - min_val))
|
normalized_expected = float((float(expected) - min_val) / (max_val - min_val))
|
||||||
normalized_actual = float((float(actual) - min_val) / (max_val - min_val))
|
normalized_actual = float((float(actual) - min_val) / (max_val - min_val))
|
||||||
score = float(1 - abs(normalized_expected - normalized_actual))
|
score = float(1 - abs(normalized_expected - normalized_actual))
|
||||||
return {"match": bool(score >= self.match_threshold), "score": float(score * self.weight)}
|
return {
|
||||||
|
"match": bool(score >= self.match_threshold),
|
||||||
|
"score": float(score * self.resolved_weight),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -207,7 +232,23 @@ class SimilarityCritic(Critic):
|
||||||
self.similarity_threshold = similarity_threshold
|
self.similarity_threshold = similarity_threshold
|
||||||
self.metric = metric
|
self.metric = metric
|
||||||
|
|
||||||
def evaluate(self, expected: str, actual: str) -> dict[str, float | bool]:
|
def evaluate(self, expected: Any, actual: Any) -> dict[str, float | bool]:
|
||||||
|
# IMPORTANT: Convert non-string values to strings before TF-IDF comparison.
|
||||||
|
# sklearn's TfidfVectorizer calls .lower() on inputs, which fails on lists/dicts.
|
||||||
|
# This commonly occurs when SimilarityCritic is used for tool arguments that are
|
||||||
|
# lists (e.g., teams_to_add=["Engineering", "Platform"]) instead of strings.
|
||||||
|
# Lists are joined with spaces to create comparable text representations.
|
||||||
|
if not isinstance(expected, str):
|
||||||
|
expected = (
|
||||||
|
" ".join(str(item) for item in expected)
|
||||||
|
if isinstance(expected, list)
|
||||||
|
else str(expected)
|
||||||
|
)
|
||||||
|
if not isinstance(actual, str):
|
||||||
|
actual = (
|
||||||
|
" ".join(str(item) for item in actual) if isinstance(actual, list) else str(actual)
|
||||||
|
)
|
||||||
|
|
||||||
if self.metric == "cosine":
|
if self.metric == "cosine":
|
||||||
try:
|
try:
|
||||||
from sklearn.feature_extraction.text import TfidfVectorizer
|
from sklearn.feature_extraction.text import TfidfVectorizer
|
||||||
|
|
@ -216,14 +257,35 @@ class SimilarityCritic(Critic):
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"Use `pip install 'arcade-evals` to install the required dependencies for similarity metrics."
|
"Use `pip install 'arcade-evals` to install the required dependencies for similarity metrics."
|
||||||
)
|
)
|
||||||
vectorizer = TfidfVectorizer()
|
|
||||||
tfidf_matrix = vectorizer.fit_transform([expected, actual])
|
# Handle edge case: empty strings or strings with no valid tokens
|
||||||
similarity = cosine_similarity(tfidf_matrix[0], tfidf_matrix[1])[0][0]
|
# TfidfVectorizer fails with "empty vocabulary" for such inputs
|
||||||
|
if not expected.strip() or not actual.strip():
|
||||||
|
# Both empty = match, one empty = no match
|
||||||
|
is_match = expected.strip() == actual.strip()
|
||||||
|
return {
|
||||||
|
"match": is_match,
|
||||||
|
"score": self.resolved_weight if is_match else 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
vectorizer = TfidfVectorizer()
|
||||||
|
tfidf_matrix = vectorizer.fit_transform([expected, actual])
|
||||||
|
similarity = float(cosine_similarity(tfidf_matrix[0], tfidf_matrix[1])[0][0])
|
||||||
|
except ValueError:
|
||||||
|
# TfidfVectorizer raises ValueError for empty vocabulary
|
||||||
|
# (e.g., only numbers/punctuation which get filtered as stop words)
|
||||||
|
# Fall back to exact string match
|
||||||
|
is_match = expected == actual
|
||||||
|
return {
|
||||||
|
"match": is_match,
|
||||||
|
"score": self.resolved_weight if is_match else 0.0,
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported similarity metric: {self.metric}")
|
raise ValueError(f"Unsupported similarity metric: {self.metric}")
|
||||||
return {
|
return {
|
||||||
"match": similarity >= self.similarity_threshold,
|
"match": similarity >= self.similarity_threshold,
|
||||||
"score": min(similarity * self.weight, self.weight),
|
"score": min(similarity * self.resolved_weight, self.resolved_weight),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -278,7 +340,7 @@ class DatetimeCritic(Critic):
|
||||||
|
|
||||||
if time_diff_seconds <= tolerance_seconds:
|
if time_diff_seconds <= tolerance_seconds:
|
||||||
# Full score if within tolerance
|
# Full score if within tolerance
|
||||||
return {"match": True, "score": self.weight}
|
return {"match": True, "score": self.resolved_weight}
|
||||||
elif time_diff_seconds >= max_difference_seconds:
|
elif time_diff_seconds >= max_difference_seconds:
|
||||||
# No score if beyond max_difference
|
# No score if beyond max_difference
|
||||||
return {"match": False, "score": 0.0}
|
return {"match": False, "score": 0.0}
|
||||||
|
|
@ -287,5 +349,5 @@ class DatetimeCritic(Critic):
|
||||||
ratio = 1 - (time_diff_seconds / max_difference_seconds)
|
ratio = 1 - (time_diff_seconds / max_difference_seconds)
|
||||||
# Ensure ratio is not negative
|
# Ensure ratio is not negative
|
||||||
ratio = max(ratio, 0)
|
ratio = max(ratio, 0)
|
||||||
score = self.weight * ratio
|
score = self.resolved_weight * ratio
|
||||||
return {"match": False, "score": score}
|
return {"match": False, "score": score}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import asyncio
|
||||||
import functools
|
import functools
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any, Callable
|
from typing import TYPE_CHECKING, Any, Callable
|
||||||
|
|
||||||
|
|
@ -11,64 +12,46 @@ from arcade_core.schema import TOOL_NAME_SEPARATOR
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
from scipy.optimize import linear_sum_assignment
|
from scipy.optimize import linear_sum_assignment
|
||||||
|
|
||||||
|
from arcade_evals._evalsuite._capture import _EvalSuiteCaptureMixin
|
||||||
|
from arcade_evals._evalsuite._comparative_execution import _EvalSuiteComparativeMixin
|
||||||
|
from arcade_evals._evalsuite._convenience import _EvalSuiteConvenienceMixin
|
||||||
|
from arcade_evals._evalsuite._providers import (
|
||||||
|
ProviderName,
|
||||||
|
convert_messages_to_anthropic,
|
||||||
|
)
|
||||||
|
from arcade_evals._evalsuite._tool_registry import EvalSuiteToolRegistry
|
||||||
|
from arcade_evals._evalsuite._tracks import TrackManager
|
||||||
|
|
||||||
|
# Import shared types from _types module (breaks circular dependencies)
|
||||||
|
from arcade_evals._evalsuite._types import (
|
||||||
|
AnyExpectedToolCall,
|
||||||
|
EvalRubric,
|
||||||
|
ExpectedMCPToolCall,
|
||||||
|
ExpectedToolCall,
|
||||||
|
NamedExpectedToolCall,
|
||||||
|
)
|
||||||
from arcade_evals.critic import NoneCritic
|
from arcade_evals.critic import NoneCritic
|
||||||
from arcade_evals.errors import WeightError
|
from arcade_evals.weights import validate_and_normalize_critic_weights
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from arcade_core import ToolCatalog
|
from arcade_core import ToolCatalog
|
||||||
|
|
||||||
|
from arcade_evals._evalsuite._comparative import ComparativeCaseBuilder
|
||||||
from arcade_evals.critic import Critic
|
from arcade_evals.critic import Critic
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@dataclass
|
# Re-export for backwards compatibility (these are now defined in _types.py)
|
||||||
class ExpectedToolCall:
|
__all__ = [
|
||||||
"""
|
"AnyExpectedToolCall",
|
||||||
Represents an expected tool call with the function itself and arguments.
|
"EvalCase",
|
||||||
|
"EvalRubric",
|
||||||
Attributes:
|
"EvalSuite",
|
||||||
func: The function itself.
|
"EvaluationResult",
|
||||||
args: A dictionary containing the expected arguments for the tool.
|
"ExpectedMCPToolCall",
|
||||||
"""
|
"ExpectedToolCall",
|
||||||
|
"NamedExpectedToolCall",
|
||||||
func: Callable
|
]
|
||||||
args: dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class NamedExpectedToolCall:
|
|
||||||
"""
|
|
||||||
Represents a tool call with its name and arguments.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
name: The name of the tool.
|
|
||||||
args: A dictionary containing the expected arguments for the tool.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
args: dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EvalRubric:
|
|
||||||
"""
|
|
||||||
Defines the rubric for evaluating an AI model's performance on a task.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
fail_threshold: The minimum score required to pass the evaluation (between 0.0 and 1.0).
|
|
||||||
warn_threshold: The score threshold for issuing a warning (between 0.0 and 1.0).
|
|
||||||
fail_on_tool_selection: Whether to fail the evaluation if the tool selection is incorrect.
|
|
||||||
fail_on_tool_call_quantity: Whether to fail the evaluation if the number of tool calls is incorrect.
|
|
||||||
tool_selection_weight: The weight assigned to the tool selection score (between 0.0 and 1.0).
|
|
||||||
"""
|
|
||||||
|
|
||||||
fail_threshold: float = 0.8
|
|
||||||
warn_threshold: float = 0.9
|
|
||||||
fail_on_tool_selection: bool = True
|
|
||||||
fail_on_tool_call_quantity: bool = True
|
|
||||||
tool_selection_weight: float = 1.0
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"Fail threshold: {self.fail_threshold}\nWarn threshold: {self.warn_threshold}\n"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -93,8 +76,14 @@ class EvaluationResult:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def fail(self) -> bool:
|
def fail(self) -> bool:
|
||||||
|
"""Returns True if the evaluation failed (excluding warnings)."""
|
||||||
return not self.passed and not self.warning
|
return not self.passed and not self.warning
|
||||||
|
|
||||||
|
@property
|
||||||
|
def warn(self) -> bool:
|
||||||
|
"""Returns True if the evaluation is in warning state."""
|
||||||
|
return self.warning
|
||||||
|
|
||||||
def add(
|
def add(
|
||||||
self,
|
self,
|
||||||
field: str,
|
field: str,
|
||||||
|
|
@ -151,6 +140,13 @@ class EvaluationResult:
|
||||||
self.score = total_score / total_weight if total_weight > 0 else 0.0
|
self.score = total_score / total_weight if total_weight > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# Import capture mode helpers (defined in capture.py to keep this file focused)
|
||||||
|
from arcade_evals.capture import ( # noqa: E402
|
||||||
|
_capture_with_anthropic,
|
||||||
|
_capture_with_openai,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EvalCase:
|
class EvalCase:
|
||||||
"""
|
"""
|
||||||
|
|
@ -176,29 +172,11 @@ class EvalCase:
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if self.critics is not None:
|
if self.critics is not None:
|
||||||
self._validate_critics()
|
validate_and_normalize_critic_weights(self.critics)
|
||||||
else:
|
else:
|
||||||
# if no critics are provided, set to empty list
|
# if no critics are provided, set to empty list
|
||||||
self.critics = []
|
self.critics = []
|
||||||
|
|
||||||
def _validate_critics(self) -> None:
|
|
||||||
"""
|
|
||||||
Validate the sum of critic weights.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
WeightError: If the sum of critic weights exceeds 1.0.
|
|
||||||
"""
|
|
||||||
if not self.critics:
|
|
||||||
return
|
|
||||||
|
|
||||||
total_weight = sum(critic.weight for critic in self.critics)
|
|
||||||
if total_weight > 1.0:
|
|
||||||
raise WeightError(f"Sum of critic weights must not exceed 1.0, got {total_weight}")
|
|
||||||
|
|
||||||
for critic in self.critics:
|
|
||||||
if critic.weight < 0.1 and not isinstance(critic, NoneCritic):
|
|
||||||
raise WeightError(f"Critic weights should be at least 0.1, got {critic.weight}")
|
|
||||||
|
|
||||||
def check_tool_selection_failure(self, actual_tools: list[str]) -> bool:
|
def check_tool_selection_failure(self, actual_tools: list[str]) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if tool selection failure should occur.
|
Check if tool selection failure should occur.
|
||||||
|
|
@ -306,21 +284,25 @@ class EvalCase:
|
||||||
try:
|
try:
|
||||||
result = critic.evaluate(expected_value, actual_value)
|
result = critic.evaluate(expected_value, actual_value)
|
||||||
total_score += result["score"]
|
total_score += result["score"]
|
||||||
total_weight += critic.weight
|
total_weight += critic.resolved_weight
|
||||||
evaluation_result.add(
|
evaluation_result.add(
|
||||||
critic.critic_field,
|
critic.critic_field,
|
||||||
result,
|
result,
|
||||||
critic.weight,
|
critic.resolved_weight,
|
||||||
expected_value,
|
expected_value,
|
||||||
actual_value,
|
actual_value,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# TODO: log or console
|
logger.warning(
|
||||||
print(f"Critic evaluation failed for field '{critic.critic_field}': {e}")
|
"Critic evaluation failed for field '%s': %s",
|
||||||
|
critic.critic_field,
|
||||||
|
e,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
evaluation_result.add(
|
evaluation_result.add(
|
||||||
critic.critic_field,
|
critic.critic_field,
|
||||||
{"match": False, "score": 0.0},
|
{"match": False, "score": 0.0},
|
||||||
critic.weight,
|
critic.resolved_weight,
|
||||||
expected_value,
|
expected_value,
|
||||||
actual_value,
|
actual_value,
|
||||||
)
|
)
|
||||||
|
|
@ -378,8 +360,10 @@ class EvalCase:
|
||||||
result = critic.evaluate(expected_value, actual_value)
|
result = critic.evaluate(expected_value, actual_value)
|
||||||
score += result.get("score", 0.0)
|
score += result.get("score", 0.0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(
|
logger.warning(
|
||||||
f"Critic evaluation failed for field '{critic.critic_field}': {e}"
|
"Critic evaluation failed for field '%s': %s",
|
||||||
|
critic.critic_field,
|
||||||
|
e,
|
||||||
)
|
)
|
||||||
cost_matrix[i, j] = score
|
cost_matrix[i, j] = score
|
||||||
|
|
||||||
|
|
@ -387,7 +371,7 @@ class EvalCase:
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EvalSuite:
|
class EvalSuite(_EvalSuiteCaptureMixin, _EvalSuiteConvenienceMixin, _EvalSuiteComparativeMixin):
|
||||||
"""
|
"""
|
||||||
A suite for evaluating AI model performance on specific tasks or scenarios.
|
A suite for evaluating AI model performance on specific tasks or scenarios.
|
||||||
|
|
||||||
|
|
@ -397,46 +381,166 @@ class EvalSuite:
|
||||||
Attributes:
|
Attributes:
|
||||||
name: The name of the evaluation suite.
|
name: The name of the evaluation suite.
|
||||||
system_message: The system message to be used for all cases in this suite.
|
system_message: The system message to be used for all cases in this suite.
|
||||||
catalog: A ToolCatalog object containing registered tools.
|
catalog: A ToolCatalog containing registered Python tools.
|
||||||
cases: A list of EvalCase objects representing individual test scenarios.
|
cases: A list of EvalCase objects representing individual test scenarios.
|
||||||
rubric: The evaluation rubric for this case.
|
rubric: The evaluation rubric for this case.
|
||||||
max_concurrent: Maximum number of concurrent evaluations.
|
max_concurrent: Maximum number of concurrent evaluations.
|
||||||
|
strict_mode: Whether to enable strict-mode schema conversion for MCP-style tools.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
system_message: str
|
system_message: str
|
||||||
catalog: "ToolCatalog"
|
catalog: "ToolCatalog | None" = None
|
||||||
cases: list[EvalCase] = field(default_factory=list)
|
cases: list[EvalCase] = field(default_factory=list)
|
||||||
rubric: EvalRubric = field(default_factory=EvalRubric)
|
rubric: EvalRubric = field(default_factory=EvalRubric)
|
||||||
max_concurrent: int = 1
|
max_concurrent: int = 1
|
||||||
|
strict_mode: bool = True
|
||||||
|
|
||||||
|
# Internal unified registry for MCP-style tools added via convenience methods.
|
||||||
|
_internal_registry: EvalSuiteToolRegistry | None = field(default=None, init=False, repr=False)
|
||||||
|
|
||||||
|
# Track manager for comparative evaluations (isolated registries per track).
|
||||||
|
_track_manager: TrackManager = field(default_factory=TrackManager, init=False, repr=False)
|
||||||
|
|
||||||
|
# Comparative case builders for multi-track evaluations (validated at execution time).
|
||||||
|
_comparative_case_builders: list["ComparativeCaseBuilder"] = field(
|
||||||
|
default_factory=list, init=False, repr=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Python tool helpers (used when Python tools are added via add_tool_catalog()).
|
||||||
|
_python_tool_func_map: dict[str, Callable] = field(default_factory=dict, init=False, repr=False)
|
||||||
|
_python_func_to_tool_name: dict[Callable, str] = field(
|
||||||
|
default_factory=dict, init=False, repr=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Initialize internal registry and auto-convert catalog if provided."""
|
||||||
|
# Always create the internal registry
|
||||||
|
self._internal_registry = EvalSuiteToolRegistry(strict_mode=self.strict_mode)
|
||||||
|
|
||||||
|
# If catalog was passed, convert those tools to the internal registry
|
||||||
|
if self.catalog is not None:
|
||||||
|
self._register_catalog_tools(self.catalog)
|
||||||
|
|
||||||
|
def _register_catalog_tools(self, catalog: "ToolCatalog", *, track: str | None = None) -> None:
|
||||||
|
"""Convert and register tools from a ToolCatalog to the internal registry.
|
||||||
|
|
||||||
|
This helper is used by both __post_init__ (for catalog= parameter) and
|
||||||
|
add_tool_catalog() (for post-init registration).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
catalog: The ToolCatalog to register.
|
||||||
|
track: Optional track name for comparative evaluations.
|
||||||
|
"""
|
||||||
|
registry = self._get_registry(track)
|
||||||
|
|
||||||
|
# Convert Python tools from ToolCatalog and store in unified registry format.
|
||||||
|
# We use to_openai() to extract the normalized tool schema, then pass the
|
||||||
|
# original MaterializedTool to the registry. This allows:
|
||||||
|
# - OpenAI: Uses the extracted MCP-style schema (stored in registry)
|
||||||
|
# - Anthropic: Uses direct to_anthropic() converter (via stored MaterializedTool)
|
||||||
|
# This avoids double-conversion overhead while maintaining unified storage.
|
||||||
|
for tool in catalog:
|
||||||
|
# Use OpenAI converter to get the tool name and base schema
|
||||||
|
openai_tool = to_openai(tool)
|
||||||
|
func_schema = openai_tool.get("function", {})
|
||||||
|
tool_name = func_schema.get("name")
|
||||||
|
if not tool_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
description = func_schema.get("description") or ""
|
||||||
|
parameters = func_schema.get("parameters") or {"type": "object", "properties": {}}
|
||||||
|
registry.add_tool(
|
||||||
|
{
|
||||||
|
"name": tool_name,
|
||||||
|
"description": description,
|
||||||
|
"inputSchema": dict(parameters),
|
||||||
|
},
|
||||||
|
materialized_tool=tool, # Pass for direct Anthropic conversion
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep track of Python function for defaults
|
||||||
|
python_func = getattr(tool, "tool", None)
|
||||||
|
if callable(python_func):
|
||||||
|
self._python_tool_func_map[tool_name] = python_func
|
||||||
|
self._python_func_to_tool_name[python_func] = tool_name
|
||||||
|
|
||||||
def _convert_to_named_expected_tool_call(
|
def _convert_to_named_expected_tool_call(
|
||||||
self, tc: ExpectedToolCall | tuple[Callable, dict[str, Any]]
|
self, tc: AnyExpectedToolCall | tuple[Callable, dict[str, Any]]
|
||||||
) -> NamedExpectedToolCall:
|
) -> NamedExpectedToolCall:
|
||||||
"""
|
"""
|
||||||
Convert an ExpectedToolCall or a tuple to a NamedExpectedToolCall
|
Convert an ExpectedToolCall, ExpectedMCPToolCall, or tuple to a NamedExpectedToolCall
|
||||||
with default arguments populated.
|
with default arguments populated.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
tc: The tool call, either as an ExpectedToolCall or a tuple.
|
tc: The tool call - ExpectedToolCall (Python), ExpectedMCPToolCall (MCP), or tuple.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A NamedExpectedToolCall instance.
|
A NamedExpectedToolCall instance.
|
||||||
"""
|
"""
|
||||||
|
# Handle MCP tools (ExpectedMCPToolCall)
|
||||||
|
if isinstance(tc, ExpectedMCPToolCall):
|
||||||
|
return self._convert_mcp_tool_call(tc.tool_name, tc.args)
|
||||||
|
|
||||||
|
# Handle Python tools (ExpectedToolCall or tuple)
|
||||||
if isinstance(tc, tuple):
|
if isinstance(tc, tuple):
|
||||||
func, args = tc
|
func, args = tc
|
||||||
else:
|
else:
|
||||||
|
# ExpectedToolCall
|
||||||
func = tc.func
|
func = tc.func
|
||||||
args = tc.args
|
args = tc.args
|
||||||
|
|
||||||
args_with_defaults = self._fill_args_with_defaults(func, args)
|
args_with_defaults = self._fill_args_with_defaults(func, args)
|
||||||
tool_name = str(self.catalog.find_tool_by_func(func).get_fully_qualified_name())
|
# Try convenience method registration first, then fall back to catalog
|
||||||
|
tool_name = self._python_func_to_tool_name.get(func)
|
||||||
|
if not tool_name:
|
||||||
|
if self.catalog is not None:
|
||||||
|
tool_name = str(self.catalog.find_tool_by_func(func).get_fully_qualified_name())
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
"Python tool callables require ToolCatalog or add_tool_catalog() registration."
|
||||||
|
)
|
||||||
return NamedExpectedToolCall(name=tool_name, args=args_with_defaults)
|
return NamedExpectedToolCall(name=tool_name, args=args_with_defaults)
|
||||||
|
|
||||||
|
def _convert_mcp_tool_call(self, tool_name: str, args: dict[str, Any]) -> NamedExpectedToolCall:
|
||||||
|
"""Convert an MCP tool reference to a NamedExpectedToolCall (NEW in this PR)."""
|
||||||
|
args_with_defaults = dict(args)
|
||||||
|
# Apply schema defaults from internal registry
|
||||||
|
if self._internal_registry is not None and self._internal_registry.has_tool(tool_name):
|
||||||
|
args_with_defaults = self._internal_registry.normalize_args(
|
||||||
|
tool_name, args_with_defaults
|
||||||
|
)
|
||||||
|
return NamedExpectedToolCall(name=tool_name, args=args_with_defaults)
|
||||||
|
|
||||||
|
def _create_eval_case(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
system_message: str,
|
||||||
|
user_message: str,
|
||||||
|
expected_tool_calls: list[NamedExpectedToolCall],
|
||||||
|
rubric: EvalRubric,
|
||||||
|
critics: list["Critic"],
|
||||||
|
additional_messages: list[dict[str, str]],
|
||||||
|
) -> "EvalCase":
|
||||||
|
"""Factory method to create EvalCase instances.
|
||||||
|
|
||||||
|
Used by the comparative mixin to create EvalCase without circular imports.
|
||||||
|
"""
|
||||||
|
return EvalCase(
|
||||||
|
name=name,
|
||||||
|
system_message=system_message,
|
||||||
|
user_message=user_message,
|
||||||
|
expected_tool_calls=expected_tool_calls,
|
||||||
|
rubric=rubric,
|
||||||
|
critics=critics,
|
||||||
|
additional_messages=additional_messages,
|
||||||
|
)
|
||||||
|
|
||||||
def add_case(
|
def add_case(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
user_message: str,
|
user_message: str,
|
||||||
expected_tool_calls: list[ExpectedToolCall] | list[tuple[Callable, dict[str, Any]]],
|
expected_tool_calls: list[AnyExpectedToolCall] | list[tuple[Callable, dict[str, Any]]],
|
||||||
critics: list["Critic"] | None = None,
|
critics: list["Critic"] | None = None,
|
||||||
system_message: str | None = None,
|
system_message: str | None = None,
|
||||||
rubric: EvalRubric | None = None,
|
rubric: EvalRubric | None = None,
|
||||||
|
|
@ -448,7 +552,7 @@ class EvalSuite:
|
||||||
Args:
|
Args:
|
||||||
name: The name of the evaluation case.
|
name: The name of the evaluation case.
|
||||||
user_message: The user's input message.
|
user_message: The user's input message.
|
||||||
expected_tool_calls: A list of expected tool calls as ExpectedToolCall instances.
|
expected_tool_calls: A list of expected tool calls (ExpectedToolCall, ExpectedMCPToolCall, or tuples).
|
||||||
critics: List of critics to evaluate the tool arguments.
|
critics: List of critics to evaluate the tool arguments.
|
||||||
system_message: The system message to be used.
|
system_message: The system message to be used.
|
||||||
rubric: The evaluation rubric for this case.
|
rubric: The evaluation rubric for this case.
|
||||||
|
|
@ -606,50 +710,83 @@ class EvalSuite:
|
||||||
)
|
)
|
||||||
self.cases.append(new_case)
|
self.cases.append(new_case)
|
||||||
|
|
||||||
async def run(self, client: AsyncOpenAI, model: str) -> dict[str, Any]:
|
def _process_tool_calls(
|
||||||
|
self,
|
||||||
|
tool_calls: list[tuple[str, dict[str, Any]]],
|
||||||
|
registry: EvalSuiteToolRegistry | None = None,
|
||||||
|
) -> list[tuple[str, dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
Process tool calls by resolving names and applying defaults.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_calls: List of (tool_name, args) tuples.
|
||||||
|
registry: Optional registry to use. If None, uses _internal_registry.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of processed (tool_name, args_with_defaults) tuples.
|
||||||
|
"""
|
||||||
|
effective_registry = registry or self._internal_registry
|
||||||
|
if effective_registry is None:
|
||||||
|
return tool_calls
|
||||||
|
|
||||||
|
processed_calls = []
|
||||||
|
for tool_name, args in tool_calls:
|
||||||
|
# Resolve name and apply schema defaults (handles Anthropic "Google_Search" -> "Google.Search")
|
||||||
|
resolved_name, args_with_defaults = effective_registry.process_tool_call(
|
||||||
|
tool_name, args
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply Python function defaults if available
|
||||||
|
if resolved_name in self._python_tool_func_map:
|
||||||
|
args_with_defaults = self._fill_args_with_defaults(
|
||||||
|
self._python_tool_func_map[resolved_name], args_with_defaults
|
||||||
|
)
|
||||||
|
|
||||||
|
processed_calls.append((resolved_name, args_with_defaults))
|
||||||
|
return processed_calls
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
client: Any, # AsyncOpenAI | AsyncAnthropic - use Any to avoid import dependency
|
||||||
|
model: str,
|
||||||
|
provider: ProviderName = "openai",
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Run the evaluation suite.
|
Run the evaluation suite.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: The AsyncOpenAI client instance.
|
client: The LLM client instance (AsyncOpenAI or AsyncAnthropic).
|
||||||
model: The model to evaluate.
|
model: The model to evaluate.
|
||||||
|
provider: The provider name ("openai" or "anthropic").
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A dictionary containing the evaluation results.
|
A dictionary containing the evaluation results.
|
||||||
"""
|
"""
|
||||||
results: dict[str, Any] = {"model": model, "rubric": self.rubric, "cases": []}
|
results: dict[str, Any] = {
|
||||||
|
"model": model,
|
||||||
|
"suite_name": self.name,
|
||||||
|
"rubric": self.rubric,
|
||||||
|
"cases": [],
|
||||||
|
}
|
||||||
|
|
||||||
semaphore = asyncio.Semaphore(self.max_concurrent)
|
semaphore = asyncio.Semaphore(self.max_concurrent)
|
||||||
|
|
||||||
async def sem_task(case: EvalCase) -> dict[str, Any]:
|
async def sem_task(case: EvalCase) -> dict[str, Any]:
|
||||||
async with semaphore:
|
async with semaphore:
|
||||||
# Prepare messages
|
# All tools are in internal registry (unified container)
|
||||||
messages = [{"role": "system", "content": case.system_message}]
|
if self._internal_registry is None or self._internal_registry.tool_count() == 0:
|
||||||
messages.extend(case.additional_messages)
|
raise ValueError(
|
||||||
messages.append({"role": "user", "content": case.user_message})
|
"No tools registered. Use add_* convenience methods or pass catalog=ToolCatalog."
|
||||||
|
)
|
||||||
|
|
||||||
tools = get_formatted_tools(self.catalog, tool_format="openai")
|
# Get tool calls based on provider
|
||||||
|
if provider == "anthropic":
|
||||||
|
predicted_args = await self._run_anthropic(client, model, case)
|
||||||
|
else:
|
||||||
|
predicted_args = await self._run_openai(client, model, case)
|
||||||
|
|
||||||
# Get the model response
|
# Process tool calls (resolve names, fill defaults)
|
||||||
response = await client.chat.completions.create( # type: ignore[call-overload]
|
filled_actual_tool_calls = self._process_tool_calls(predicted_args)
|
||||||
model=model,
|
|
||||||
messages=messages,
|
|
||||||
tool_choice="auto",
|
|
||||||
tools=tools,
|
|
||||||
user="eval_user",
|
|
||||||
seed=42,
|
|
||||||
stream=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract and fill default arguments for actual tool calls
|
|
||||||
predicted_args = get_tool_args(response)
|
|
||||||
filled_actual_tool_calls = []
|
|
||||||
for tool_name, args in predicted_args:
|
|
||||||
tool = self.catalog.get_tool_by_name(tool_name)
|
|
||||||
if tool is None:
|
|
||||||
raise ValueError(f"Tool '{tool_name}' not found in catalog.")
|
|
||||||
func = tool.tool
|
|
||||||
args_with_defaults = self._fill_args_with_defaults(func, args)
|
|
||||||
filled_actual_tool_calls.append((tool_name, args_with_defaults))
|
|
||||||
|
|
||||||
# Evaluate the case
|
# Evaluate the case
|
||||||
evaluation = case.evaluate(filled_actual_tool_calls)
|
evaluation = case.evaluate(filled_actual_tool_calls)
|
||||||
|
|
@ -658,6 +795,8 @@ class EvalSuite:
|
||||||
result = {
|
result = {
|
||||||
"name": case.name,
|
"name": case.name,
|
||||||
"input": case.user_message,
|
"input": case.user_message,
|
||||||
|
"system_message": case.system_message,
|
||||||
|
"additional_messages": case.additional_messages,
|
||||||
"expected_tool_calls": [
|
"expected_tool_calls": [
|
||||||
{"name": tc.name, "args": tc.args} for tc in case.expected_tool_calls
|
{"name": tc.name, "args": tc.args} for tc in case.expected_tool_calls
|
||||||
],
|
],
|
||||||
|
|
@ -674,6 +813,93 @@ class EvalSuite:
|
||||||
results["cases"] = case_results
|
results["cases"] = case_results
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
async def _run_openai(
|
||||||
|
self,
|
||||||
|
client: AsyncOpenAI,
|
||||||
|
model: str,
|
||||||
|
case: "EvalCase",
|
||||||
|
registry: EvalSuiteToolRegistry | None = None,
|
||||||
|
) -> list[tuple[str, dict[str, Any]]]:
|
||||||
|
"""Run evaluation using OpenAI client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: The OpenAI client.
|
||||||
|
model: The model name.
|
||||||
|
case: The evaluation case.
|
||||||
|
registry: Optional registry to use. If None, uses _internal_registry.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tool calls.
|
||||||
|
"""
|
||||||
|
effective_registry = registry or self._internal_registry
|
||||||
|
if effective_registry is None:
|
||||||
|
raise RuntimeError("No registry available")
|
||||||
|
|
||||||
|
# Prepare messages
|
||||||
|
messages: list[dict[str, Any]] = [{"role": "system", "content": case.system_message}]
|
||||||
|
messages.extend(case.additional_messages)
|
||||||
|
messages.append({"role": "user", "content": case.user_message})
|
||||||
|
|
||||||
|
tools = effective_registry.list_tools_for_model(tool_format="openai")
|
||||||
|
|
||||||
|
# Get the model response
|
||||||
|
response = await client.chat.completions.create( # type: ignore[arg-type]
|
||||||
|
model=model,
|
||||||
|
messages=messages,
|
||||||
|
tool_choice="auto",
|
||||||
|
tools=tools,
|
||||||
|
user="eval_user",
|
||||||
|
seed=42,
|
||||||
|
stream=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return get_tool_args(response, normalize_names=False)
|
||||||
|
|
||||||
|
async def _run_anthropic(
|
||||||
|
self,
|
||||||
|
client: Any, # AsyncAnthropic
|
||||||
|
model: str,
|
||||||
|
case: "EvalCase",
|
||||||
|
registry: EvalSuiteToolRegistry | None = None,
|
||||||
|
) -> list[tuple[str, dict[str, Any]]]:
|
||||||
|
"""Run evaluation using Anthropic client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: The Anthropic client.
|
||||||
|
model: The model name.
|
||||||
|
case: The evaluation case.
|
||||||
|
registry: Optional registry to use. If None, uses _internal_registry.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tool calls.
|
||||||
|
"""
|
||||||
|
effective_registry = registry or self._internal_registry
|
||||||
|
if effective_registry is None:
|
||||||
|
raise RuntimeError("No registry available")
|
||||||
|
|
||||||
|
# Convert OpenAI-format messages to Anthropic format
|
||||||
|
anthropic_messages = convert_messages_to_anthropic(case.additional_messages)
|
||||||
|
anthropic_messages.append({"role": "user", "content": case.user_message})
|
||||||
|
|
||||||
|
tools = effective_registry.list_tools_for_model(tool_format="anthropic")
|
||||||
|
|
||||||
|
# Get the model response
|
||||||
|
response = await client.messages.create(
|
||||||
|
model=model,
|
||||||
|
max_tokens=4096,
|
||||||
|
system=case.system_message,
|
||||||
|
messages=anthropic_messages,
|
||||||
|
tools=tools,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract tool calls from Anthropic response
|
||||||
|
tool_calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
for block in response.content:
|
||||||
|
if block.type == "tool_use":
|
||||||
|
tool_calls.append((block.name, block.input))
|
||||||
|
|
||||||
|
return tool_calls
|
||||||
|
|
||||||
|
|
||||||
def get_formatted_tools(catalog: "ToolCatalog", tool_format: str = "openai") -> OpenAIToolList:
|
def get_formatted_tools(catalog: "ToolCatalog", tool_format: str = "openai") -> OpenAIToolList:
|
||||||
"""Get the formatted tools from the catalog.
|
"""Get the formatted tools from the catalog.
|
||||||
|
|
@ -692,12 +918,16 @@ def get_formatted_tools(catalog: "ToolCatalog", tool_format: str = "openai") ->
|
||||||
raise ValueError(f"Tool format for '{tool_format}' is not supported")
|
raise ValueError(f"Tool format for '{tool_format}' is not supported")
|
||||||
|
|
||||||
|
|
||||||
def get_tool_args(chat_completion: Any) -> list[tuple[str, dict[str, Any]]]:
|
def get_tool_args(
|
||||||
|
chat_completion: Any, normalize_names: bool = True
|
||||||
|
) -> list[tuple[str, dict[str, Any]]]:
|
||||||
"""
|
"""
|
||||||
Returns the tool arguments from the chat completion object.
|
Returns the tool arguments from the chat completion object.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
chat_completion: The chat completion object.
|
chat_completion: The chat completion object.
|
||||||
|
normalize_names: Whether to normalize tool names (convert _ to .).
|
||||||
|
Set to False for MCP tools that use underscores.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A list of tuples containing the tool name and arguments.
|
A list of tuples containing the tool name and arguments.
|
||||||
|
|
@ -706,8 +936,11 @@ def get_tool_args(chat_completion: Any) -> list[tuple[str, dict[str, Any]]]:
|
||||||
message = chat_completion.choices[0].message
|
message = chat_completion.choices[0].message
|
||||||
if message.tool_calls:
|
if message.tool_calls:
|
||||||
for tool_call in message.tool_calls:
|
for tool_call in message.tool_calls:
|
||||||
|
tool_name = tool_call.function.name
|
||||||
|
if normalize_names:
|
||||||
|
tool_name = normalize_name(tool_name)
|
||||||
tool_args_list.append((
|
tool_args_list.append((
|
||||||
normalize_name(tool_call.function.name),
|
tool_name,
|
||||||
json.loads(tool_call.function.arguments),
|
json.loads(tool_call.function.arguments),
|
||||||
))
|
))
|
||||||
return tool_args_list
|
return tool_args_list
|
||||||
|
|
@ -749,20 +982,110 @@ def tool_eval() -> Callable[[Callable], Callable]:
|
||||||
provider_api_key: str,
|
provider_api_key: str,
|
||||||
model: str,
|
model: str,
|
||||||
max_concurrency: int = 1,
|
max_concurrency: int = 1,
|
||||||
) -> list[dict[str, Any]]:
|
provider: ProviderName = "openai",
|
||||||
suite = func()
|
capture_mode: bool = False,
|
||||||
|
include_context: bool = False,
|
||||||
|
) -> list[Any]:
|
||||||
|
"""
|
||||||
|
Run evaluation or capture mode.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
In evaluation mode: list[dict[str, Any]] with evaluation results.
|
||||||
|
In capture mode: list[CaptureResult] with captured tool calls.
|
||||||
|
"""
|
||||||
|
# Support both sync and async suite creation functions
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
if inspect.iscoroutinefunction(func):
|
||||||
|
suite = await func()
|
||||||
|
else:
|
||||||
|
result = func()
|
||||||
|
# Handle case where sync func returns a coroutine
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
suite = await result
|
||||||
|
else:
|
||||||
|
suite = result
|
||||||
|
|
||||||
if not isinstance(suite, EvalSuite):
|
if not isinstance(suite, EvalSuite):
|
||||||
raise TypeError("Eval function must return an EvalSuite")
|
raise TypeError("Eval function must return an EvalSuite")
|
||||||
suite.max_concurrent = max_concurrency
|
suite.max_concurrent = max_concurrency
|
||||||
results = []
|
|
||||||
async with AsyncOpenAI(
|
if capture_mode:
|
||||||
api_key=provider_api_key,
|
# Run in capture mode
|
||||||
) as client:
|
if provider == "anthropic":
|
||||||
result = await suite.run(client, model)
|
capture_result = await _capture_with_anthropic(
|
||||||
results.append(result)
|
suite, provider_api_key, model, include_context
|
||||||
return results
|
)
|
||||||
|
else:
|
||||||
|
capture_result = await _capture_with_openai(
|
||||||
|
suite, provider_api_key, model, include_context
|
||||||
|
)
|
||||||
|
return [capture_result]
|
||||||
|
else:
|
||||||
|
# Run in evaluation mode
|
||||||
|
if provider == "anthropic":
|
||||||
|
eval_result = await _run_with_anthropic(suite, provider_api_key, model)
|
||||||
|
else:
|
||||||
|
eval_result = await _run_with_openai(suite, provider_api_key, model)
|
||||||
|
|
||||||
|
# For comparative evaluations, eval_result is already a list of track results
|
||||||
|
# For regular evaluations, it's a single dict that needs wrapping
|
||||||
|
if isinstance(eval_result, list):
|
||||||
|
return eval_result
|
||||||
|
return [eval_result]
|
||||||
|
|
||||||
wrapper.__tool_eval__ = True # type: ignore[attr-defined]
|
wrapper.__tool_eval__ = True # type: ignore[attr-defined]
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_with_openai(
|
||||||
|
suite: "EvalSuite", api_key: str, model: str
|
||||||
|
) -> dict[str, Any] | list[dict[str, Any]]:
|
||||||
|
"""Run evaluation suite with OpenAI client.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
For regular evaluations: A single result dict.
|
||||||
|
For comparative evaluations: A list of result dicts (one per track).
|
||||||
|
"""
|
||||||
|
async with AsyncOpenAI(api_key=api_key) as client:
|
||||||
|
# Check if this suite has comparative cases
|
||||||
|
if suite._comparative_case_builders:
|
||||||
|
# Run comparative evaluation - returns dict[track_name, result]
|
||||||
|
track_results = await suite.run_comparative(client, model, provider="openai")
|
||||||
|
# Convert to list of results for consistent handling
|
||||||
|
return list(track_results.values())
|
||||||
|
else:
|
||||||
|
# Run regular evaluation
|
||||||
|
return await suite.run(client, model, provider="openai")
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_with_anthropic(
|
||||||
|
suite: "EvalSuite", api_key: str, model: str
|
||||||
|
) -> dict[str, Any] | list[dict[str, Any]]:
|
||||||
|
"""Run evaluation suite with Anthropic client.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
For regular evaluations: A single result dict.
|
||||||
|
For comparative evaluations: A list of result dicts (one per track).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from anthropic import AsyncAnthropic
|
||||||
|
except ImportError as e:
|
||||||
|
raise ImportError(
|
||||||
|
"The 'anthropic' package is required for Anthropic provider. "
|
||||||
|
"Install it with: pip install anthropic"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
async with AsyncAnthropic(api_key=api_key) as client:
|
||||||
|
# Check if this suite has comparative cases
|
||||||
|
if suite._comparative_case_builders:
|
||||||
|
# Run comparative evaluation - returns dict[track_name, result]
|
||||||
|
track_results = await suite.run_comparative(client, model, provider="anthropic")
|
||||||
|
# Convert to list of results for consistent handling
|
||||||
|
return list(track_results.values())
|
||||||
|
else:
|
||||||
|
# Run regular evaluation
|
||||||
|
return await suite.run(client, model, provider="anthropic")
|
||||||
|
|
|
||||||
440
libs/arcade-evals/arcade_evals/loaders.py
Normal file
440
libs/arcade-evals/arcade_evals/loaders.py
Normal file
|
|
@ -0,0 +1,440 @@
|
||||||
|
"""
|
||||||
|
MCP Server Tool Loaders.
|
||||||
|
|
||||||
|
Public API (async-only):
|
||||||
|
- `load_from_stdio_async`
|
||||||
|
- `load_mcp_remote_async`
|
||||||
|
- `load_arcade_mcp_gateway_async`
|
||||||
|
- `load_stdio_arcade_async`
|
||||||
|
|
||||||
|
Requires the MCP SDK: pip install mcp
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MCPSessionFilter(logging.Filter):
|
||||||
|
"""Filter to suppress/rewrite misleading MCP SDK session termination messages.
|
||||||
|
|
||||||
|
The MCP SDK logs "Session termination failed: 202" when sessions close gracefully.
|
||||||
|
HTTP 202 (Accepted) is the correct response for MCP notifications per spec,
|
||||||
|
not an error. This filter suppresses the misleading error message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
"""Return False to suppress log record, True to allow it."""
|
||||||
|
message = record.getMessage()
|
||||||
|
|
||||||
|
# Suppress the misleading "Session termination failed: 202" message
|
||||||
|
# HTTP 202 is the correct response for MCP session close notifications
|
||||||
|
is_termination_message = "Session termination failed" in message
|
||||||
|
has_202_code = "202" in message
|
||||||
|
|
||||||
|
return not (is_termination_message and has_202_code)
|
||||||
|
|
||||||
|
|
||||||
|
# Apply filter to MCP SDK loggers to suppress misleading session messages
|
||||||
|
def _configure_mcp_logging() -> None:
|
||||||
|
"""Configure MCP SDK logging to suppress misleading messages."""
|
||||||
|
mcp_loggers = [
|
||||||
|
"mcp",
|
||||||
|
"mcp.client",
|
||||||
|
"mcp.client.session",
|
||||||
|
"mcp.client.sse",
|
||||||
|
"mcp.client.stdio",
|
||||||
|
"mcp.client.streamable_http",
|
||||||
|
]
|
||||||
|
|
||||||
|
session_filter = MCPSessionFilter()
|
||||||
|
for logger_name in mcp_loggers:
|
||||||
|
mcp_logger = logging.getLogger(logger_name)
|
||||||
|
mcp_logger.addFilter(session_filter)
|
||||||
|
|
||||||
|
|
||||||
|
# Configure MCP logging on module import
|
||||||
|
_configure_mcp_logging()
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CONFIGURATION CONSTANTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Default Arcade API base URL (production)
|
||||||
|
ARCADE_API_BASE_URL = "https://api.arcade.dev"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TOOL CACHE - Prevents redundant connections to the same MCP source
|
||||||
|
# Uses asyncio locks to prevent concurrent loads to the same MCP source
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Cache for loaded tools: key is (url, headers_hash), value is list of tools
|
||||||
|
_tools_cache: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
|
||||||
|
# Per-key asyncio locks to prevent concurrent loads to same source
|
||||||
|
_cache_locks: dict[str, asyncio.Lock] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_cache_key(url: str, headers: dict[str, str] | None) -> str:
|
||||||
|
"""Create a cache key from URL and headers."""
|
||||||
|
headers_str = str(sorted((headers or {}).items()))
|
||||||
|
return f"{url}|{headers_str}"
|
||||||
|
|
||||||
|
|
||||||
|
# Lock acquisition timeout (seconds) - prevents indefinite hangs
|
||||||
|
LOCK_TIMEOUT_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_cache_lock(cache_key: str) -> asyncio.Lock:
|
||||||
|
"""Get or create an asyncio lock for the given cache key."""
|
||||||
|
if cache_key not in _cache_locks:
|
||||||
|
_cache_locks[cache_key] = asyncio.Lock()
|
||||||
|
return _cache_locks[cache_key]
|
||||||
|
|
||||||
|
|
||||||
|
async def _acquire_lock_with_timeout(
|
||||||
|
lock: asyncio.Lock, timeout: float = LOCK_TIMEOUT_SECONDS
|
||||||
|
) -> bool:
|
||||||
|
"""Acquire a lock with timeout. Returns True if acquired, False on timeout."""
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(lock.acquire(), timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def clear_tools_cache() -> None:
|
||||||
|
"""Clear the tools cache. Useful for testing or forcing fresh connections."""
|
||||||
|
_tools_cache.clear()
|
||||||
|
_cache_locks.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_arcade_base_url() -> str:
|
||||||
|
"""Get the Arcade API base URL, checking env var at runtime."""
|
||||||
|
return os.environ.get("ARCADE_API_BASE_URL", ARCADE_API_BASE_URL)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MCP SDK IMPORT
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _require_mcp() -> tuple[Any, Any, Any, Any, Any]:
|
||||||
|
"""
|
||||||
|
Import MCP SDK with a helpful error message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(ClientSession, StdioServerParameters, stdio_client, sse_client, streamablehttp_client)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import mcp
|
||||||
|
import mcp.client.sse as mcp_client_sse
|
||||||
|
import mcp.client.stdio as mcp_client_stdio
|
||||||
|
import mcp.client.streamable_http as mcp_client_http
|
||||||
|
|
||||||
|
ClientSession = mcp.ClientSession
|
||||||
|
StdioServerParameters = mcp.StdioServerParameters
|
||||||
|
stdio_client = mcp_client_stdio.stdio_client
|
||||||
|
sse_client = mcp_client_sse.sse_client
|
||||||
|
streamablehttp_client = mcp_client_http.streamablehttp_client
|
||||||
|
|
||||||
|
except ImportError as e:
|
||||||
|
raise ImportError(
|
||||||
|
"MCP SDK is required for arcade-evals. "
|
||||||
|
"Install with: pip install 'arcade-mcp[evals]' or pip install mcp"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
return ClientSession, StdioServerParameters, stdio_client, sse_client, streamablehttp_client
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# UTILITIES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_to_dict(tool: Any) -> dict[str, Any]:
|
||||||
|
"""Convert an MCP Tool object to the MCP-style dict format used by EvalSuite."""
|
||||||
|
return {
|
||||||
|
"name": tool.name,
|
||||||
|
"description": tool.description or "",
|
||||||
|
"inputSchema": tool.inputSchema,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_mcp_path(url: str) -> str:
|
||||||
|
"""Ensure the URL path ends with '/mcp' (without duplicating).
|
||||||
|
|
||||||
|
Preserves query strings and fragments.
|
||||||
|
"""
|
||||||
|
parts = urlsplit(url)
|
||||||
|
path = (parts.path or "").rstrip("/")
|
||||||
|
|
||||||
|
# If any path segment is already "mcp" (e.g. "/mcp" or "/mcp/{slug}" or "/foo/mcp"),
|
||||||
|
# treat it as already pointing at an MCP endpoint.
|
||||||
|
segments = [seg for seg in path.split("/") if seg]
|
||||||
|
if "mcp" in segments:
|
||||||
|
normalized_path = "/" + "/".join(segments) if segments else ""
|
||||||
|
return urlunsplit((
|
||||||
|
parts.scheme,
|
||||||
|
parts.netloc,
|
||||||
|
normalized_path,
|
||||||
|
parts.query,
|
||||||
|
parts.fragment,
|
||||||
|
))
|
||||||
|
|
||||||
|
new_path = (f"{path}/mcp" if path else "/mcp") if path != "" else "/mcp"
|
||||||
|
return urlunsplit((parts.scheme, parts.netloc, new_path, parts.query, parts.fragment))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_arcade_mcp_url(gateway_slug: str | None, base_url: str) -> str:
|
||||||
|
"""Build the Arcade MCP gateway URL."""
|
||||||
|
base = base_url.rstrip("/")
|
||||||
|
if gateway_slug:
|
||||||
|
return f"{base}/mcp/{gateway_slug}"
|
||||||
|
return f"{base}/mcp"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PUBLIC API (async-only)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def load_from_stdio_async(
|
||||||
|
command: list[str],
|
||||||
|
*,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
timeout: int = 10,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Load tools from an MCP server via stdio.
|
||||||
|
|
||||||
|
Results are cached by command to avoid starting multiple subprocesses
|
||||||
|
for the same server. Concurrent requests for the same command will wait
|
||||||
|
for the first request to complete and share the result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: Command to run the MCP server (e.g., ["python", "server.py"]).
|
||||||
|
env: Additional environment variables to pass to the server.
|
||||||
|
timeout: Timeout in seconds (not used by MCP SDK, kept for API compatibility).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tool definitions in MCP format.
|
||||||
|
"""
|
||||||
|
if not command:
|
||||||
|
return []
|
||||||
|
|
||||||
|
del timeout # MCP SDK manages timeouts internally
|
||||||
|
|
||||||
|
cache_key = f"stdio|{' '.join(command)}|{sorted((env or {}).items())!s}"
|
||||||
|
|
||||||
|
# Fast path: check cache without lock (no locking overhead for cache hits)
|
||||||
|
if cache_key in _tools_cache:
|
||||||
|
logger.debug(f"Using cached tools for stdio: {command[0]}")
|
||||||
|
return _tools_cache[cache_key].copy()
|
||||||
|
|
||||||
|
# Cache miss - acquire lock and check again (double-checked locking)
|
||||||
|
lock = await _get_cache_lock(cache_key)
|
||||||
|
if not await _acquire_lock_with_timeout(lock):
|
||||||
|
raise TimeoutError(f"Timeout waiting for lock on stdio: {command[0]}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Re-check cache (another request may have populated it while we waited)
|
||||||
|
if cache_key in _tools_cache:
|
||||||
|
logger.debug(f"Using cached tools for stdio: {command[0]}")
|
||||||
|
return _tools_cache[cache_key].copy()
|
||||||
|
|
||||||
|
ClientSession, StdioServerParameters, stdio_client, _, _ = _require_mcp()
|
||||||
|
|
||||||
|
process_env = os.environ.copy()
|
||||||
|
if env:
|
||||||
|
process_env.update(env)
|
||||||
|
|
||||||
|
server_params = StdioServerParameters(
|
||||||
|
command=command[0],
|
||||||
|
args=command[1:] if len(command) > 1 else [],
|
||||||
|
env=process_env,
|
||||||
|
)
|
||||||
|
async with (
|
||||||
|
stdio_client(server_params) as (read, write),
|
||||||
|
ClientSession(read, write) as session,
|
||||||
|
):
|
||||||
|
await session.initialize()
|
||||||
|
result = await session.list_tools()
|
||||||
|
tools = [_tool_to_dict(tool) for tool in result.tools]
|
||||||
|
|
||||||
|
# Cache the result
|
||||||
|
_tools_cache[cache_key] = tools.copy()
|
||||||
|
return tools
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
|
||||||
|
async def load_mcp_remote_async(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
timeout: int = 10,
|
||||||
|
use_sse: bool = False,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Load tools from a remote MCP server via URL (HTTP or SSE transport).
|
||||||
|
|
||||||
|
Results are cached to avoid redundant connections when multiple models
|
||||||
|
load the same MCP source. Concurrent requests for the same URL will wait
|
||||||
|
for the first request to complete and share the result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL of the MCP server.
|
||||||
|
headers: Additional headers to send with the request.
|
||||||
|
timeout: Timeout in seconds (not used by MCP SDK, kept for API compatibility).
|
||||||
|
use_sse: Whether to use SSE transport. If False, uses streamable-http.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tool definitions in MCP format.
|
||||||
|
"""
|
||||||
|
del timeout # MCP SDK manages timeout internally
|
||||||
|
|
||||||
|
url = _ensure_mcp_path(url)
|
||||||
|
cache_key = _make_cache_key(url, headers)
|
||||||
|
|
||||||
|
# Fast path: check cache without lock (no locking overhead for cache hits)
|
||||||
|
if cache_key in _tools_cache:
|
||||||
|
logger.debug(f"Using cached tools for {url}")
|
||||||
|
return _tools_cache[cache_key].copy()
|
||||||
|
|
||||||
|
# Cache miss - acquire lock and check again (double-checked locking)
|
||||||
|
lock = await _get_cache_lock(cache_key)
|
||||||
|
if not await _acquire_lock_with_timeout(lock):
|
||||||
|
raise TimeoutError(f"Timeout waiting for lock on HTTP: {url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Re-check cache (another request may have populated it while we waited)
|
||||||
|
if cache_key in _tools_cache:
|
||||||
|
logger.debug(f"Using cached tools for {url}")
|
||||||
|
return _tools_cache[cache_key].copy()
|
||||||
|
|
||||||
|
# Load MCP SDK (deferred import)
|
||||||
|
ClientSession, _, _, sse_client, streamablehttp_client = _require_mcp()
|
||||||
|
|
||||||
|
# Load from MCP server
|
||||||
|
tools: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
if use_sse:
|
||||||
|
async with (
|
||||||
|
sse_client(url, headers=headers) as (read, write),
|
||||||
|
ClientSession(read, write) as session,
|
||||||
|
):
|
||||||
|
await session.initialize()
|
||||||
|
result = await session.list_tools()
|
||||||
|
tools = [_tool_to_dict(tool) for tool in result.tools]
|
||||||
|
else:
|
||||||
|
async with (
|
||||||
|
streamablehttp_client(url, headers=headers) as (read, write, _),
|
||||||
|
ClientSession(read, write) as session,
|
||||||
|
):
|
||||||
|
await session.initialize()
|
||||||
|
result = await session.list_tools()
|
||||||
|
tools = [_tool_to_dict(tool) for tool in result.tools]
|
||||||
|
|
||||||
|
# Cache the result
|
||||||
|
_tools_cache[cache_key] = tools.copy()
|
||||||
|
return tools
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
|
||||||
|
async def load_arcade_mcp_gateway_async(
|
||||||
|
gateway_slug: str | None = None,
|
||||||
|
*,
|
||||||
|
arcade_api_key: str | None = None,
|
||||||
|
arcade_user_id: str | None = None,
|
||||||
|
base_url: str | None = None,
|
||||||
|
timeout: int = 10,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Load tools from an Arcade MCP gateway.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
gateway_slug: Optional gateway slug (if None, connects to base MCP endpoint).
|
||||||
|
arcade_api_key: Arcade API key (defaults to ARCADE_API_KEY env var).
|
||||||
|
arcade_user_id: Arcade user ID (defaults to ARCADE_USER_ID env var).
|
||||||
|
base_url: Arcade API base URL (defaults to ARCADE_API_BASE_URL env var or production).
|
||||||
|
timeout: Timeout in seconds (not used by MCP SDK, kept for API compatibility).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tool definitions in MCP format (deduplicated by name).
|
||||||
|
"""
|
||||||
|
api_key = arcade_api_key or os.environ.get("ARCADE_API_KEY")
|
||||||
|
user_id = arcade_user_id or os.environ.get("ARCADE_USER_ID")
|
||||||
|
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if api_key:
|
||||||
|
# Arcade Gateway expects "Bearer <token>" format
|
||||||
|
if api_key.startswith("Bearer "):
|
||||||
|
headers["Authorization"] = api_key
|
||||||
|
else:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
if user_id:
|
||||||
|
# Note: Header is "Arcade-User-Id" (not "Arcade-User-ID")
|
||||||
|
headers["Arcade-User-Id"] = user_id
|
||||||
|
|
||||||
|
# Use provided base_url or check env var at runtime
|
||||||
|
effective_base_url = base_url or _get_arcade_base_url()
|
||||||
|
url = _build_arcade_mcp_url(gateway_slug, effective_base_url)
|
||||||
|
tools = await load_mcp_remote_async(url, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
# Deduplicate tools by name (gateway may return duplicates)
|
||||||
|
seen: dict[str, dict[str, Any]] = {}
|
||||||
|
for tool in tools:
|
||||||
|
name = tool.get("name")
|
||||||
|
if name and name not in seen:
|
||||||
|
seen[name] = tool
|
||||||
|
return list(seen.values())
|
||||||
|
|
||||||
|
|
||||||
|
async def load_stdio_arcade_async(
|
||||||
|
command: list[str],
|
||||||
|
*,
|
||||||
|
arcade_api_key: str | None = None,
|
||||||
|
arcade_user_id: str | None = None,
|
||||||
|
tool_secrets: dict[str, str] | None = None,
|
||||||
|
timeout: int = 10,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Load tools from an Arcade MCP server via stdio.
|
||||||
|
|
||||||
|
Convenience wrapper that sets Arcade env vars and delegates to `load_from_stdio_async`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: Command to run the MCP server (e.g., ["python", "server.py"]).
|
||||||
|
arcade_api_key: Arcade API key (defaults to ARCADE_API_KEY env var).
|
||||||
|
arcade_user_id: Arcade user ID (defaults to ARCADE_USER_ID env var).
|
||||||
|
tool_secrets: Additional secrets to pass as environment variables.
|
||||||
|
timeout: Timeout in seconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tool definitions in MCP format.
|
||||||
|
"""
|
||||||
|
env: dict[str, str] = {}
|
||||||
|
|
||||||
|
if arcade_api_key:
|
||||||
|
env["ARCADE_API_KEY"] = arcade_api_key
|
||||||
|
elif "ARCADE_API_KEY" in os.environ:
|
||||||
|
env["ARCADE_API_KEY"] = os.environ["ARCADE_API_KEY"]
|
||||||
|
|
||||||
|
if arcade_user_id:
|
||||||
|
env["ARCADE_USER_ID"] = arcade_user_id
|
||||||
|
elif "ARCADE_USER_ID" in os.environ:
|
||||||
|
env["ARCADE_USER_ID"] = os.environ["ARCADE_USER_ID"]
|
||||||
|
|
||||||
|
if tool_secrets:
|
||||||
|
env.update(tool_secrets)
|
||||||
|
|
||||||
|
return await load_from_stdio_async(command, timeout=timeout, env=env if env else None)
|
||||||
221
libs/arcade-evals/arcade_evals/weights.py
Normal file
221
libs/arcade-evals/arcade_evals/weights.py
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
"""
|
||||||
|
Weight definitions and normalization for arcade-evals.
|
||||||
|
|
||||||
|
This module contains:
|
||||||
|
- FuzzyWeight enum for qualitative weight assignment
|
||||||
|
- Weight type alias (float | FuzzyWeight)
|
||||||
|
- Normalization functions for critic weights
|
||||||
|
- Validation utilities for weight constraints
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from arcade_evals.errors import WeightError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from arcade_evals.critic import Critic
|
||||||
|
|
||||||
|
|
||||||
|
def _is_placeholder_critic(critic: "Critic") -> bool:
|
||||||
|
"""
|
||||||
|
Check if a critic is a placeholder (like NoneCritic).
|
||||||
|
|
||||||
|
Uses duck typing via the _is_placeholder class attribute to avoid
|
||||||
|
circular imports between weights.py and critic.py.
|
||||||
|
"""
|
||||||
|
return getattr(critic, "_is_placeholder", False)
|
||||||
|
|
||||||
|
|
||||||
|
class FuzzyWeight(Enum):
|
||||||
|
"""
|
||||||
|
Qualitative weight buckets for critic importance.
|
||||||
|
|
||||||
|
Instead of manually calculating float weights, use these qualitative
|
||||||
|
buckets to express relative importance. Weights are auto-normalized
|
||||||
|
using Softmax-inspired scaling.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> critics = [
|
||||||
|
... BinaryCritic(critic_field="owner", weight=FuzzyWeight.HIGH),
|
||||||
|
... BinaryCritic(critic_field="state", weight=FuzzyWeight.LOW),
|
||||||
|
... ]
|
||||||
|
# HIGH (5) gets 62.5% weight, LOW (3) gets 37.5% weight
|
||||||
|
|
||||||
|
Weight Buckets (linear scale, uniform increment of 1):
|
||||||
|
- MINIMAL: 1 - Almost negligible, rarely affects outcome
|
||||||
|
- VERY_LOW: 2 - Rarely important, edge case checking
|
||||||
|
- LOW: 3 - Minor importance
|
||||||
|
- MEDIUM: 4 - Standard importance (default)
|
||||||
|
- HIGH: 5 - Important parameter
|
||||||
|
- VERY_HIGH: 6 - Critical, must-match parameter
|
||||||
|
- CRITICAL: 7 - Absolutely essential, highest priority
|
||||||
|
"""
|
||||||
|
|
||||||
|
MINIMAL = 1
|
||||||
|
VERY_LOW = 2
|
||||||
|
LOW = 3
|
||||||
|
MEDIUM = 4
|
||||||
|
HIGH = 5
|
||||||
|
VERY_HIGH = 6
|
||||||
|
CRITICAL = 7
|
||||||
|
|
||||||
|
|
||||||
|
# Type alias for weight parameter
|
||||||
|
Weight = float | FuzzyWeight
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_fuzzy_weights(critics: list["Critic"]) -> list[float]:
|
||||||
|
"""
|
||||||
|
Normalize a list of critic weights to sum to 1.0.
|
||||||
|
|
||||||
|
Uses Softmax-inspired normalization: each weight is divided by the
|
||||||
|
sum of all weights, ensuring:
|
||||||
|
1. All weights sum to exactly 1.0
|
||||||
|
2. Relative proportions are preserved
|
||||||
|
|
||||||
|
Args:
|
||||||
|
critics: List of critics with weight attributes.
|
||||||
|
Weights can be float or FuzzyWeight.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of normalized float weights in the same order as input critics.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> from arcade_evals.critic import BinaryCritic
|
||||||
|
>>> critics = [
|
||||||
|
... BinaryCritic("a", FuzzyWeight.HIGH),
|
||||||
|
... BinaryCritic("b", FuzzyWeight.LOW),
|
||||||
|
... ]
|
||||||
|
>>> normalize_fuzzy_weights(critics)
|
||||||
|
[0.625, 0.375] # HIGH (5) / (5 + 3), LOW (3) / (5 + 3)
|
||||||
|
"""
|
||||||
|
if not critics:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Extract raw weight values (convert FuzzyWeight to float)
|
||||||
|
raw_weights: list[float] = []
|
||||||
|
for critic in critics:
|
||||||
|
if isinstance(critic.weight, FuzzyWeight):
|
||||||
|
raw_weights.append(float(critic.weight.value))
|
||||||
|
else:
|
||||||
|
raw_weights.append(float(critic.weight))
|
||||||
|
|
||||||
|
# Calculate total for normalization
|
||||||
|
total = sum(raw_weights)
|
||||||
|
if total <= 0:
|
||||||
|
# Edge case: all weights are zero or negative
|
||||||
|
# Return zeros to indicate no scoring should occur
|
||||||
|
return [0.0] * len(critics)
|
||||||
|
|
||||||
|
# Normalize weights (simple division by sum)
|
||||||
|
return [w / total for w in raw_weights]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_weight(weight: Weight) -> float:
|
||||||
|
"""
|
||||||
|
Resolve a Weight value to a float.
|
||||||
|
|
||||||
|
Used when a single weight needs to be resolved without full normalization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
weight: Either a float or FuzzyWeight enum.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Float weight value.
|
||||||
|
"""
|
||||||
|
if isinstance(weight, FuzzyWeight):
|
||||||
|
return weight.value
|
||||||
|
return float(weight)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Critic Weight Validation and Normalization
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def validate_and_normalize_critic_weights(critics: list["Critic"]) -> None:
|
||||||
|
"""
|
||||||
|
Validate and normalize critic weights in-place.
|
||||||
|
|
||||||
|
If any critic uses FuzzyWeight, all weights are normalized using
|
||||||
|
Softmax-inspired scaling to sum to 1.0. Otherwise, validates that
|
||||||
|
all float weights are non-negative.
|
||||||
|
|
||||||
|
This function modifies critics in-place, setting their `weight` attribute
|
||||||
|
to the normalized float value. The original weight is preserved in
|
||||||
|
`_original_weight` for FuzzyWeight critics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
critics: List of critics to validate and normalize.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WeightError: If any float weight is negative.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> critics = [
|
||||||
|
... BinaryCritic(critic_field="a", weight=FuzzyWeight.HIGH),
|
||||||
|
... BinaryCritic(critic_field="b", weight=FuzzyWeight.LOW),
|
||||||
|
... ]
|
||||||
|
>>> validate_and_normalize_critic_weights(critics)
|
||||||
|
>>> critics[0].weight # Now normalized float
|
||||||
|
0.625
|
||||||
|
"""
|
||||||
|
if not critics:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if any critic uses FuzzyWeight
|
||||||
|
has_fuzzy = any(isinstance(c.weight, FuzzyWeight) for c in critics)
|
||||||
|
|
||||||
|
if has_fuzzy:
|
||||||
|
_normalize_fuzzy_critic_weights(critics)
|
||||||
|
else:
|
||||||
|
_validate_float_critic_weights(critics)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_fuzzy_critic_weights(critics: list["Critic"]) -> None:
|
||||||
|
"""
|
||||||
|
Normalize critic weights when FuzzyWeight is used.
|
||||||
|
|
||||||
|
Filters out placeholder critics (like NoneCritic, which always has weight=0)
|
||||||
|
and normalizes the remaining critics' weights to sum to 1.0.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
critics: List of critics to normalize (modified in-place).
|
||||||
|
"""
|
||||||
|
# Filter out placeholder critics for normalization (they keep weight=0)
|
||||||
|
non_placeholder_critics = [c for c in critics if not _is_placeholder_critic(c)]
|
||||||
|
|
||||||
|
if not non_placeholder_critics:
|
||||||
|
return
|
||||||
|
|
||||||
|
normalized = normalize_fuzzy_weights(non_placeholder_critics)
|
||||||
|
|
||||||
|
for critic, norm_weight in zip(non_placeholder_critics, normalized):
|
||||||
|
# Store original weight for reference
|
||||||
|
critic._original_weight = critic.weight # type: ignore[attr-defined]
|
||||||
|
# Set normalized weight for evaluation
|
||||||
|
critic.weight = norm_weight
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_float_critic_weights(critics: list["Critic"]) -> None:
|
||||||
|
"""
|
||||||
|
Validate that all float critic weights are non-negative.
|
||||||
|
|
||||||
|
This is the legacy validation path used when no FuzzyWeight is present.
|
||||||
|
Float weights are allowed to be any non-negative value; normalization
|
||||||
|
happens implicitly through the scoring calculation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
critics: List of critics to validate.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WeightError: If any weight is negative.
|
||||||
|
"""
|
||||||
|
for critic in critics:
|
||||||
|
if _is_placeholder_critic(critic):
|
||||||
|
continue
|
||||||
|
|
||||||
|
weight = resolve_weight(critic.weight)
|
||||||
|
if weight < 0:
|
||||||
|
raise WeightError(f"Critic weight must be non-negative, got {weight}")
|
||||||
1
libs/tests/arcade_evals/__init__.py
Normal file
1
libs/tests/arcade_evals/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Make arcade_evals tests a package to avoid pytest module name collisions."""
|
||||||
105
libs/tests/arcade_evals/test_capture_execution.py
Normal file
105
libs/tests/arcade_evals/test_capture_execution.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
"""Tests for capture mode execution."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_evals import EvalSuite
|
||||||
|
|
||||||
|
# Mark all tests in this module as requiring evals dependencies
|
||||||
|
pytestmark = pytest.mark.evals
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptureMode:
|
||||||
|
"""Tests for EvalSuite.capture() method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_records_tool_calls_without_scoring(self) -> None:
|
||||||
|
"""Test that capture mode records tool calls without evaluation."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
suite.add_tool_definitions([
|
||||||
|
{"name": "search", "description": "Search", "inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"query": {"type": "string"}},
|
||||||
|
"required": ["query"]
|
||||||
|
}}
|
||||||
|
])
|
||||||
|
suite.add_case(name="test case", user_message="search for cats", expected_tool_calls=[])
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_tool_call = MagicMock()
|
||||||
|
mock_tool_call.id = "call_123"
|
||||||
|
mock_tool_call.function.name = "search"
|
||||||
|
mock_tool_call.function.arguments = '{"query": "cats"}'
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.choices = [MagicMock()]
|
||||||
|
mock_response.choices[0].message.tool_calls = [mock_tool_call]
|
||||||
|
mock_client.chat.completions.create.return_value = mock_response
|
||||||
|
|
||||||
|
result = await suite.capture(mock_client, "gpt-4o", provider="openai")
|
||||||
|
|
||||||
|
# Should return CaptureResult with captured_cases
|
||||||
|
assert result.suite_name == "test"
|
||||||
|
assert result.model == "gpt-4o"
|
||||||
|
assert result.provider == "openai"
|
||||||
|
assert len(result.captured_cases) == 1
|
||||||
|
|
||||||
|
captured = result.captured_cases[0]
|
||||||
|
# Should have recorded the tool call
|
||||||
|
assert len(captured.tool_calls) == 1
|
||||||
|
assert captured.tool_calls[0].name == "search"
|
||||||
|
assert captured.tool_calls[0].args == {"query": "cats"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_raises_without_tools(self) -> None:
|
||||||
|
"""Test that capture mode raises error when no tools registered."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
suite.add_case(name="test", user_message="test", expected_tool_calls=[])
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="No tools registered"):
|
||||||
|
await suite.capture(mock_client, "gpt-4o", provider="openai")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_works_with_anthropic_provider(self) -> None:
|
||||||
|
"""Test capture mode works with Anthropic provider."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
suite.add_tool_definitions([{"name": "search", "description": "Search", "inputSchema": {}}])
|
||||||
|
suite.add_case(name="test", user_message="test", expected_tool_calls=[])
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_tool_block = MagicMock()
|
||||||
|
mock_tool_block.type = "tool_use"
|
||||||
|
mock_tool_block.name = "search"
|
||||||
|
mock_tool_block.input = {"query": "test"}
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.content = [mock_tool_block]
|
||||||
|
mock_client.messages.create.return_value = mock_response
|
||||||
|
|
||||||
|
result = await suite.capture(mock_client, "claude-3", provider="anthropic")
|
||||||
|
|
||||||
|
assert len(result.captured_cases) == 1
|
||||||
|
assert len(result.captured_cases[0].tool_calls) == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_respects_max_concurrent(self) -> None:
|
||||||
|
"""Test that capture mode respects max_concurrent setting."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test", max_concurrent=2)
|
||||||
|
suite.add_tool_definitions([{"name": "tool1", "description": "Test", "inputSchema": {}}])
|
||||||
|
|
||||||
|
# Add 3 cases
|
||||||
|
for i in range(3):
|
||||||
|
suite.add_case(name=f"case{i}", user_message=f"test{i}", expected_tool_calls=[])
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.choices = [MagicMock()]
|
||||||
|
mock_response.choices[0].message.tool_calls = None
|
||||||
|
mock_client.chat.completions.create.return_value = mock_response
|
||||||
|
|
||||||
|
result = await suite.capture(mock_client, "gpt-4o", provider="openai")
|
||||||
|
|
||||||
|
# All 3 cases should be captured
|
||||||
|
assert len(result.captured_cases) == 3
|
||||||
594
libs/tests/arcade_evals/test_comparative.py
Normal file
594
libs/tests/arcade_evals/test_comparative.py
Normal file
|
|
@ -0,0 +1,594 @@
|
||||||
|
"""Tests for comparative evaluation cases."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_evals import EvalSuite, ExpectedMCPToolCall
|
||||||
|
from arcade_evals._evalsuite._comparative import ComparativeCaseBuilder
|
||||||
|
from arcade_evals._evalsuite._types import (
|
||||||
|
ComparativeCase,
|
||||||
|
ExpectedToolCall,
|
||||||
|
TrackConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark all tests in this module as requiring evals dependencies
|
||||||
|
pytestmark = pytest.mark.evals
|
||||||
|
|
||||||
|
|
||||||
|
class TestTrackConfig:
|
||||||
|
"""Tests for TrackConfig dataclass."""
|
||||||
|
|
||||||
|
def test_create_track_config(self) -> None:
|
||||||
|
"""Test creating a TrackConfig."""
|
||||||
|
expected: list[ExpectedToolCall | ExpectedMCPToolCall] = [
|
||||||
|
ExpectedMCPToolCall("TestTool", args={"arg1": "value1"})
|
||||||
|
]
|
||||||
|
config = TrackConfig(expected_tool_calls=expected)
|
||||||
|
|
||||||
|
assert config.expected_tool_calls == expected
|
||||||
|
assert config.critics == []
|
||||||
|
|
||||||
|
def test_create_track_config_with_critics(self) -> None:
|
||||||
|
"""Test creating a TrackConfig with critics."""
|
||||||
|
from arcade_evals.critic import Critic, SimilarityCritic
|
||||||
|
|
||||||
|
expected: list[ExpectedToolCall | ExpectedMCPToolCall] = [
|
||||||
|
ExpectedMCPToolCall("TestTool", args={"arg1": "value1"})
|
||||||
|
]
|
||||||
|
critics: list[Critic] = [SimilarityCritic(critic_field="arg1", weight=1.0)]
|
||||||
|
config = TrackConfig(expected_tool_calls=expected, critics=critics)
|
||||||
|
|
||||||
|
assert config.expected_tool_calls == expected
|
||||||
|
assert config.critics == critics
|
||||||
|
|
||||||
|
|
||||||
|
class TestComparativeCase:
|
||||||
|
"""Tests for ComparativeCase dataclass."""
|
||||||
|
|
||||||
|
def test_create_comparative_case(self) -> None:
|
||||||
|
"""Test creating a ComparativeCase."""
|
||||||
|
case = ComparativeCase(
|
||||||
|
name="test_case",
|
||||||
|
user_message="What's the weather?",
|
||||||
|
system_message="You are helpful.",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert case.name == "test_case"
|
||||||
|
assert case.user_message == "What's the weather?"
|
||||||
|
assert case.system_message == "You are helpful."
|
||||||
|
assert case.additional_messages == []
|
||||||
|
assert case.track_configs == {}
|
||||||
|
|
||||||
|
def test_add_track_config(self) -> None:
|
||||||
|
"""Test adding track configuration."""
|
||||||
|
case = ComparativeCase(
|
||||||
|
name="test_case",
|
||||||
|
user_message="What's the weather?",
|
||||||
|
)
|
||||||
|
expected: list[ExpectedToolCall | ExpectedMCPToolCall] = [
|
||||||
|
ExpectedMCPToolCall("GetWeather", args={"city": "NYC"})
|
||||||
|
]
|
||||||
|
|
||||||
|
case.add_track_config("Track1", expected)
|
||||||
|
|
||||||
|
assert "Track1" in case.track_configs
|
||||||
|
assert case.track_configs["Track1"].expected_tool_calls == expected
|
||||||
|
|
||||||
|
def test_add_duplicate_track_config_raises(self) -> None:
|
||||||
|
"""Test that adding duplicate track config raises."""
|
||||||
|
case = ComparativeCase(
|
||||||
|
name="test_case",
|
||||||
|
user_message="What's the weather?",
|
||||||
|
)
|
||||||
|
expected1: list[ExpectedToolCall | ExpectedMCPToolCall] = [
|
||||||
|
ExpectedMCPToolCall("Tool1", args={"arg": "v1"})
|
||||||
|
]
|
||||||
|
expected2: list[ExpectedToolCall | ExpectedMCPToolCall] = [
|
||||||
|
ExpectedMCPToolCall("Tool2", args={"arg": "v2"})
|
||||||
|
]
|
||||||
|
|
||||||
|
case.add_track_config("Track1", expected1)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="already configured"):
|
||||||
|
case.add_track_config("Track1", expected2)
|
||||||
|
|
||||||
|
def test_get_configured_tracks(self) -> None:
|
||||||
|
"""Test getting list of configured tracks."""
|
||||||
|
case = ComparativeCase(
|
||||||
|
name="test_case",
|
||||||
|
user_message="What's the weather?",
|
||||||
|
)
|
||||||
|
track1: list[ExpectedToolCall | ExpectedMCPToolCall] = [ExpectedMCPToolCall("Tool1")]
|
||||||
|
track2: list[ExpectedToolCall | ExpectedMCPToolCall] = [ExpectedMCPToolCall("Tool2")]
|
||||||
|
case.add_track_config("Track1", track1)
|
||||||
|
case.add_track_config("Track2", track2)
|
||||||
|
|
||||||
|
tracks = case.get_configured_tracks()
|
||||||
|
|
||||||
|
assert tracks == ["Track1", "Track2"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestComparativeCaseBuilder:
|
||||||
|
"""Tests for ComparativeCaseBuilder fluent API."""
|
||||||
|
|
||||||
|
def test_builder_creates_case(self) -> None:
|
||||||
|
"""Test builder creates a comparative case."""
|
||||||
|
suite = EvalSuite(name="Test Suite", system_message="Test")
|
||||||
|
# Register a track first
|
||||||
|
suite.add_tool_definitions([{"name": "Tool1"}], track="Track1")
|
||||||
|
|
||||||
|
builder = ComparativeCaseBuilder(
|
||||||
|
suite=suite,
|
||||||
|
name="test_case",
|
||||||
|
user_message="Test message",
|
||||||
|
system_message="System message",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert builder.case.name == "test_case"
|
||||||
|
assert builder.case.user_message == "Test message"
|
||||||
|
assert builder.case.system_message == "System message"
|
||||||
|
|
||||||
|
def test_builder_for_track(self) -> None:
|
||||||
|
"""Test builder for_track method."""
|
||||||
|
suite = EvalSuite(name="Test Suite", system_message="Test")
|
||||||
|
suite.add_tool_definitions([{"name": "Tool1"}], track="Track1")
|
||||||
|
|
||||||
|
builder = ComparativeCaseBuilder(
|
||||||
|
suite=suite,
|
||||||
|
name="test_case",
|
||||||
|
user_message="Test message",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = builder.for_track(
|
||||||
|
"Track1",
|
||||||
|
expected_tool_calls=[ExpectedMCPToolCall("Tool1", args={"arg": "value"})],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is builder # Returns self for chaining
|
||||||
|
assert "Track1" in builder.case.track_configs
|
||||||
|
|
||||||
|
def test_builder_for_track_nonexistent_raises(self) -> None:
|
||||||
|
"""Test for_track raises for nonexistent track."""
|
||||||
|
suite = EvalSuite(name="Test Suite", system_message="Test")
|
||||||
|
|
||||||
|
builder = ComparativeCaseBuilder(
|
||||||
|
suite=suite,
|
||||||
|
name="test_case",
|
||||||
|
user_message="Test message",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="not found"):
|
||||||
|
builder.for_track(
|
||||||
|
"NonexistentTrack",
|
||||||
|
expected_tool_calls=[ExpectedMCPToolCall("Tool1")],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_builder_chaining(self) -> None:
|
||||||
|
"""Test builder supports method chaining."""
|
||||||
|
suite = EvalSuite(name="Test Suite", system_message="Test")
|
||||||
|
suite.add_tool_definitions([{"name": "Tool1"}], track="Track1")
|
||||||
|
suite.add_tool_definitions([{"name": "Tool2"}], track="Track2")
|
||||||
|
|
||||||
|
builder = ComparativeCaseBuilder(
|
||||||
|
suite=suite,
|
||||||
|
name="test_case",
|
||||||
|
user_message="Test message",
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.for_track(
|
||||||
|
"Track1",
|
||||||
|
expected_tool_calls=[ExpectedMCPToolCall("Tool1")],
|
||||||
|
).for_track(
|
||||||
|
"Track2",
|
||||||
|
expected_tool_calls=[ExpectedMCPToolCall("Tool2")],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(builder.case.track_configs) == 2
|
||||||
|
assert "Track1" in builder.case.track_configs
|
||||||
|
assert "Track2" in builder.case.track_configs
|
||||||
|
|
||||||
|
def test_builder_build_empty_raises(self) -> None:
|
||||||
|
"""Test build raises when no tracks configured."""
|
||||||
|
suite = EvalSuite(name="Test Suite", system_message="Test")
|
||||||
|
|
||||||
|
builder = ComparativeCaseBuilder(
|
||||||
|
suite=suite,
|
||||||
|
name="test_case",
|
||||||
|
user_message="Test message",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="No tracks configured"):
|
||||||
|
builder.build()
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvalSuiteTrackIntegration:
|
||||||
|
"""Tests for EvalSuite track integration."""
|
||||||
|
|
||||||
|
def test_add_tool_definitions_with_track(self) -> None:
|
||||||
|
"""Test adding tool definitions to a specific track."""
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
|
||||||
|
suite.add_tool_definitions(
|
||||||
|
[{"name": "TestTool", "description": "A test"}],
|
||||||
|
track="MyTrack",
|
||||||
|
)
|
||||||
|
|
||||||
|
tracks = suite.get_tracks()
|
||||||
|
assert "MyTrack" in tracks
|
||||||
|
assert suite.get_tool_count(track="MyTrack") == 1
|
||||||
|
assert suite.list_tool_names(track="MyTrack") == ["TestTool"]
|
||||||
|
|
||||||
|
def test_add_tool_definitions_multiple_tracks(self) -> None:
|
||||||
|
"""Test adding tools to multiple tracks."""
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
|
||||||
|
suite.add_tool_definitions([{"name": "Tool1"}], track="Track1")
|
||||||
|
suite.add_tool_definitions([{"name": "Tool2"}], track="Track2")
|
||||||
|
|
||||||
|
assert len(suite.get_tracks()) == 2
|
||||||
|
assert suite.list_tool_names(track="Track1") == ["Tool1"]
|
||||||
|
assert suite.list_tool_names(track="Track2") == ["Tool2"]
|
||||||
|
|
||||||
|
def test_tracks_are_isolated(self) -> None:
|
||||||
|
"""Test that tracks have isolated tool registries."""
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
|
||||||
|
suite.add_tool_definitions([{"name": "Tool1"}], track="Track1")
|
||||||
|
suite.add_tool_definitions([{"name": "Tool2"}], track="Track2")
|
||||||
|
|
||||||
|
# Each track only sees its own tools
|
||||||
|
track1_tools = suite.list_tool_names(track="Track1")
|
||||||
|
track2_tools = suite.list_tool_names(track="Track2")
|
||||||
|
|
||||||
|
assert "Tool1" in track1_tools
|
||||||
|
assert "Tool2" not in track1_tools
|
||||||
|
assert "Tool2" in track2_tools
|
||||||
|
assert "Tool1" not in track2_tools
|
||||||
|
|
||||||
|
def test_default_registry_separate_from_tracks(self) -> None:
|
||||||
|
"""Test that default registry is separate from tracks."""
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
|
||||||
|
# Add to default registry
|
||||||
|
suite.add_tool_definitions([{"name": "DefaultTool"}])
|
||||||
|
# Add to track
|
||||||
|
suite.add_tool_definitions([{"name": "TrackTool"}], track="MyTrack")
|
||||||
|
|
||||||
|
# Default registry
|
||||||
|
assert suite.get_tool_count() == 1
|
||||||
|
assert suite.list_tool_names() == ["DefaultTool"]
|
||||||
|
|
||||||
|
# Track registry
|
||||||
|
assert suite.get_tool_count(track="MyTrack") == 1
|
||||||
|
assert suite.list_tool_names(track="MyTrack") == ["TrackTool"]
|
||||||
|
|
||||||
|
def test_add_comparative_case(self) -> None:
|
||||||
|
"""Test add_comparative_case method."""
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
suite.add_tool_definitions([{"name": "Tool1"}], track="Track1")
|
||||||
|
|
||||||
|
builder = suite.add_comparative_case(
|
||||||
|
name="weather_query",
|
||||||
|
user_message="What's the weather in NYC?",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert builder is not None
|
||||||
|
assert builder.case.name == "weather_query"
|
||||||
|
|
||||||
|
# Configure track and verify
|
||||||
|
builder.for_track(
|
||||||
|
"Track1",
|
||||||
|
expected_tool_calls=[ExpectedMCPToolCall("Tool1", args={"city": "NYC"})],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Track1" in builder.case.track_configs
|
||||||
|
|
||||||
|
def test_add_comparative_case_uses_suite_defaults(self) -> None:
|
||||||
|
"""Test add_comparative_case uses suite defaults."""
|
||||||
|
from arcade_evals import EvalRubric
|
||||||
|
|
||||||
|
rubric = EvalRubric(fail_threshold=0.9)
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="Test",
|
||||||
|
system_message="Default system message",
|
||||||
|
rubric=rubric,
|
||||||
|
)
|
||||||
|
|
||||||
|
builder = suite.add_comparative_case(
|
||||||
|
name="test",
|
||||||
|
user_message="Test message",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert builder.case.system_message == "Default system message"
|
||||||
|
assert builder.case.rubric == rubric
|
||||||
|
|
||||||
|
def test_get_tracks_empty(self) -> None:
|
||||||
|
"""Test get_tracks when no tracks registered."""
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
|
||||||
|
assert suite.get_tracks() == []
|
||||||
|
|
||||||
|
def test_method_chaining_still_works(self) -> None:
|
||||||
|
"""Test that method chaining still works with track parameter."""
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
|
||||||
|
# Chaining should still work
|
||||||
|
result = suite.add_tool_definitions(
|
||||||
|
[{"name": "Tool1"}],
|
||||||
|
track="Track1",
|
||||||
|
).add_tool_definitions(
|
||||||
|
[{"name": "Tool2"}],
|
||||||
|
track="Track2",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is suite
|
||||||
|
assert len(suite.get_tracks()) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunComparative:
|
||||||
|
"""Tests for EvalSuite.run_comparative method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_comparative_no_cases_raises(self) -> None:
|
||||||
|
"""Test run_comparative raises when no cases defined."""
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
client = AsyncMock()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="No comparative cases defined"):
|
||||||
|
await suite.run_comparative(client, "gpt-4o")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_comparative_missing_track_raises(self) -> None:
|
||||||
|
"""Test builder raises when track doesn't exist (fail-fast validation)."""
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
suite.add_tool_definitions([{"name": "Tool1"}], track="Track1")
|
||||||
|
|
||||||
|
# Builder validates tracks exist at configuration time (fail-fast)
|
||||||
|
builder = suite.add_comparative_case(
|
||||||
|
name="test_case",
|
||||||
|
user_message="Test",
|
||||||
|
).for_track(
|
||||||
|
"Track1",
|
||||||
|
expected_tool_calls=[ExpectedMCPToolCall("Tool1")],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attempting to add non-existent track should raise immediately
|
||||||
|
with pytest.raises(ValueError, match="Track 'NonExistentTrack' not found"):
|
||||||
|
builder.for_track(
|
||||||
|
"NonExistentTrack",
|
||||||
|
expected_tool_calls=[ExpectedMCPToolCall("Tool2")],
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_comparative_no_tracks_configured_raises(self) -> None:
|
||||||
|
"""Test run_comparative raises when builder has no tracks."""
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
# Add case but don't configure any tracks
|
||||||
|
suite.add_comparative_case(
|
||||||
|
name="test_case",
|
||||||
|
user_message="Test",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
with pytest.raises(ValueError, match="No tracks configured"):
|
||||||
|
await suite.run_comparative(client, "gpt-4o")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_comparative_basic_execution(self) -> None:
|
||||||
|
"""Test run_comparative executes cases across tracks."""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
suite = EvalSuite(name="Test Suite", system_message="You are helpful")
|
||||||
|
|
||||||
|
# Register tools for two tracks
|
||||||
|
suite.add_tool_definitions(
|
||||||
|
[{"name": "GetWeather", "description": "Get weather"}],
|
||||||
|
track="Track1",
|
||||||
|
)
|
||||||
|
suite.add_tool_definitions(
|
||||||
|
[{"name": "FetchWeather", "description": "Fetch weather"}],
|
||||||
|
track="Track2",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add comparative case
|
||||||
|
suite.add_comparative_case(
|
||||||
|
name="weather_query",
|
||||||
|
user_message="What's the weather?",
|
||||||
|
).for_track(
|
||||||
|
"Track1",
|
||||||
|
expected_tool_calls=[ExpectedMCPToolCall("GetWeather", args={"city": "NYC"})],
|
||||||
|
).for_track(
|
||||||
|
"Track2",
|
||||||
|
expected_tool_calls=[ExpectedMCPToolCall("FetchWeather", args={"city": "NYC"})],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock OpenAI client
|
||||||
|
client = AsyncMock()
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_message = MagicMock()
|
||||||
|
mock_tool_call = MagicMock()
|
||||||
|
mock_tool_call.function.name = "GetWeather"
|
||||||
|
mock_tool_call.function.arguments = '{"city": "NYC"}'
|
||||||
|
mock_message.tool_calls = [mock_tool_call]
|
||||||
|
mock_response.choices = [MagicMock(message=mock_message)]
|
||||||
|
client.chat.completions.create.return_value = mock_response
|
||||||
|
|
||||||
|
# Run comparative evaluation
|
||||||
|
results = await suite.run_comparative(client, "gpt-4o", provider="openai")
|
||||||
|
|
||||||
|
# Verify structure
|
||||||
|
assert "Track1" in results
|
||||||
|
assert "Track2" in results
|
||||||
|
assert results["Track1"]["model"] == "gpt-4o"
|
||||||
|
assert results["Track1"]["suite_name"] == "Test Suite"
|
||||||
|
assert results["Track1"]["track_name"] == "Track1"
|
||||||
|
assert len(results["Track1"]["cases"]) == 1
|
||||||
|
assert len(results["Track2"]["cases"]) == 1
|
||||||
|
|
||||||
|
# Verify case results
|
||||||
|
track1_case = results["Track1"]["cases"][0]
|
||||||
|
assert track1_case["name"] == "weather_query"
|
||||||
|
assert track1_case["track"] == "Track1"
|
||||||
|
assert track1_case["input"] == "What's the weather?"
|
||||||
|
assert "evaluation" in track1_case
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_comparative_multiple_cases(self) -> None:
|
||||||
|
"""Test run_comparative with multiple comparative cases."""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
suite.add_tool_definitions([{"name": "Tool1"}], track="Track1")
|
||||||
|
suite.add_tool_definitions([{"name": "Tool2"}], track="Track2")
|
||||||
|
|
||||||
|
# Add two comparative cases
|
||||||
|
suite.add_comparative_case(
|
||||||
|
name="case1",
|
||||||
|
user_message="Query 1",
|
||||||
|
).for_track(
|
||||||
|
"Track1",
|
||||||
|
expected_tool_calls=[ExpectedMCPToolCall("Tool1")],
|
||||||
|
).for_track(
|
||||||
|
"Track2",
|
||||||
|
expected_tool_calls=[ExpectedMCPToolCall("Tool2")],
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_comparative_case(
|
||||||
|
name="case2",
|
||||||
|
user_message="Query 2",
|
||||||
|
).for_track(
|
||||||
|
"Track1",
|
||||||
|
expected_tool_calls=[ExpectedMCPToolCall("Tool1")],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock client
|
||||||
|
client = AsyncMock()
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_message = MagicMock()
|
||||||
|
mock_message.tool_calls = []
|
||||||
|
mock_response.choices = [MagicMock(message=mock_message)]
|
||||||
|
client.chat.completions.create.return_value = mock_response
|
||||||
|
|
||||||
|
results = await suite.run_comparative(client, "gpt-4o")
|
||||||
|
|
||||||
|
# Verify both tracks present
|
||||||
|
assert "Track1" in results
|
||||||
|
assert "Track2" in results
|
||||||
|
|
||||||
|
# Track1 should have 2 cases, Track2 should have 1 case
|
||||||
|
assert len(results["Track1"]["cases"]) == 2
|
||||||
|
assert len(results["Track2"]["cases"]) == 1
|
||||||
|
|
||||||
|
# Verify case names
|
||||||
|
track1_names = {case["name"] for case in results["Track1"]["cases"]}
|
||||||
|
assert track1_names == {"case1", "case2"}
|
||||||
|
track2_names = {case["name"] for case in results["Track2"]["cases"]}
|
||||||
|
assert track2_names == {"case1"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_comparative_anthropic_provider(self) -> None:
|
||||||
|
"""Test run_comparative with Anthropic provider."""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
suite.add_tool_definitions([{"name": "TestTool"}], track="Track1")
|
||||||
|
|
||||||
|
suite.add_comparative_case(
|
||||||
|
name="test",
|
||||||
|
user_message="Test query",
|
||||||
|
).for_track(
|
||||||
|
"Track1",
|
||||||
|
expected_tool_calls=[ExpectedMCPToolCall("TestTool")],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock Anthropic client
|
||||||
|
client = AsyncMock()
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.content = []
|
||||||
|
client.messages.create.return_value = mock_response
|
||||||
|
|
||||||
|
results = await suite.run_comparative(client, "claude-3-5-sonnet", provider="anthropic")
|
||||||
|
|
||||||
|
assert "Track1" in results
|
||||||
|
assert len(results["Track1"]["cases"]) == 1
|
||||||
|
# Verify Anthropic client was called
|
||||||
|
assert client.messages.create.called
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_comparative_track_deleted_after_config(self) -> None:
|
||||||
|
"""Test run_comparative when track is deleted after case configuration.
|
||||||
|
|
||||||
|
This tests the execution-time validation that ensures tracks still exist
|
||||||
|
when run_comparative is called (edge case for programmatic track deletion).
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
|
||||||
|
# Register track and configure case
|
||||||
|
suite.add_tool_definitions([{"name": "Tool1"}], track="Track1")
|
||||||
|
suite.add_comparative_case(
|
||||||
|
name="test_case",
|
||||||
|
user_message="Test",
|
||||||
|
).for_track(
|
||||||
|
"Track1",
|
||||||
|
expected_tool_calls=[ExpectedMCPToolCall("Tool1")],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simulate track being removed (edge case - programmatic deletion)
|
||||||
|
# This bypasses builder validation but triggers run_comparative validation
|
||||||
|
suite._track_manager._tracks.clear()
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
|
||||||
|
# Should raise at execution time with helpful error
|
||||||
|
with pytest.raises(ValueError, match="Missing track registries.*Track1"):
|
||||||
|
await suite.run_comparative(client, "gpt-4o")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_comparative_registry_none_defensive_check(self) -> None:
|
||||||
|
"""Test the defensive RuntimeError if registry is None after validation.
|
||||||
|
|
||||||
|
This tests the defensive programming check that should never trigger
|
||||||
|
in normal operation but protects against race conditions or bugs.
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
suite.add_tool_definitions([{"name": "Tool1"}], track="Track1")
|
||||||
|
|
||||||
|
suite.add_comparative_case(
|
||||||
|
name="test_case",
|
||||||
|
user_message="Test",
|
||||||
|
).for_track(
|
||||||
|
"Track1",
|
||||||
|
expected_tool_calls=[ExpectedMCPToolCall("Tool1")],
|
||||||
|
)
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
|
||||||
|
# Patch get_registry to return None during execution loop
|
||||||
|
# has_track() will pass validation, but get_registry() will return None
|
||||||
|
# This simulates a race condition where track is deleted between validation and execution
|
||||||
|
original_has_track = suite._track_manager.has_track
|
||||||
|
|
||||||
|
def patched_get_registry(track_name: str) -> None:
|
||||||
|
# Return None to trigger the defensive check
|
||||||
|
return None
|
||||||
|
|
||||||
|
def patched_has_track(track_name: str) -> bool:
|
||||||
|
# Return True to pass validation
|
||||||
|
return original_has_track(track_name)
|
||||||
|
|
||||||
|
# Apply patches using patch.object to satisfy mypy
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(suite._track_manager, "get_registry", patched_get_registry),
|
||||||
|
patch.object(suite._track_manager, "has_track", patched_has_track),
|
||||||
|
):
|
||||||
|
# Should raise RuntimeError (defensive check)
|
||||||
|
with pytest.raises(RuntimeError, match="Registry.*unexpectedly None after validation"):
|
||||||
|
await suite.run_comparative(client, "gpt-4o")
|
||||||
249
libs/tests/arcade_evals/test_comparative_execution.py
Normal file
249
libs/tests/arcade_evals/test_comparative_execution.py
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
"""Tests for comparative evaluation execution logic."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_evals import (
|
||||||
|
BinaryCritic,
|
||||||
|
EvalRubric,
|
||||||
|
EvalSuite,
|
||||||
|
ExpectedMCPToolCall,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark all tests in this module as requiring evals dependencies
|
||||||
|
pytestmark = pytest.mark.evals
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunComparative:
|
||||||
|
"""Tests for EvalSuite.run_comparative() method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_for_track_validates_track_exists(self) -> None:
|
||||||
|
"""Test that for_track raises error if track doesn't exist."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
|
||||||
|
# Add tools to track1 only
|
||||||
|
suite.add_tool_definitions([{"name": "tool1", "description": "Test", "inputSchema": {}}], track="track1")
|
||||||
|
|
||||||
|
# Try to add comparative case with track2 (doesn't exist)
|
||||||
|
case = suite.add_comparative_case(name="test", user_message="test")
|
||||||
|
case.for_track("track1", expected_tool_calls=[ExpectedMCPToolCall("tool1", args={})])
|
||||||
|
|
||||||
|
# for_track validates immediately
|
||||||
|
with pytest.raises(ValueError, match="Track.*not found"):
|
||||||
|
case.for_track("track2", expected_tool_calls=[ExpectedMCPToolCall("tool2", args={})])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_comparative_returns_track_results(self) -> None:
|
||||||
|
"""Test that run_comparative returns dict with track results."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
|
||||||
|
# Add tools to two tracks
|
||||||
|
suite.add_tool_definitions([{"name": "tool1", "description": "Test", "inputSchema": {}}], track="track1")
|
||||||
|
suite.add_tool_definitions([{"name": "tool2", "description": "Test", "inputSchema": {}}], track="track2")
|
||||||
|
|
||||||
|
# Add comparative case
|
||||||
|
case = suite.add_comparative_case(name="case1", user_message="test")
|
||||||
|
case.for_track("track1", expected_tool_calls=[ExpectedMCPToolCall("tool1", args={})])
|
||||||
|
case.for_track("track2", expected_tool_calls=[ExpectedMCPToolCall("tool2", args={})])
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.choices = [MagicMock()]
|
||||||
|
mock_response.choices[0].message.tool_calls = None
|
||||||
|
mock_client.chat.completions.create.return_value = mock_response
|
||||||
|
|
||||||
|
result = await suite.run_comparative(mock_client, "gpt-4o", provider="openai")
|
||||||
|
|
||||||
|
# Should return dict with track names as keys
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert "track1" in result
|
||||||
|
assert "track2" in result
|
||||||
|
|
||||||
|
# Each track should have model, suite_name, track_name, cases
|
||||||
|
assert result["track1"]["model"] == "gpt-4o"
|
||||||
|
assert result["track1"]["suite_name"] == "test"
|
||||||
|
assert result["track1"]["track_name"] == "track1"
|
||||||
|
assert "cases" in result["track1"]
|
||||||
|
assert len(result["track1"]["cases"]) == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_comparative_raises_without_comparative_cases(self) -> None:
|
||||||
|
"""Test that run_comparative raises error when no comparative cases defined."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
suite.add_tool_definitions([{"name": "tool1", "description": "Test", "inputSchema": {}}])
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="No comparative cases defined"):
|
||||||
|
await suite.run_comparative(mock_client, "gpt-4o", provider="openai")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_comparative_respects_max_concurrent(self) -> None:
|
||||||
|
"""Test that run_comparative respects max_concurrent setting."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test", max_concurrent=2)
|
||||||
|
|
||||||
|
# Add tools
|
||||||
|
suite.add_tool_definitions([{"name": "tool1", "description": "Test", "inputSchema": {}}], track="track1")
|
||||||
|
|
||||||
|
# Add 3 cases
|
||||||
|
for i in range(3):
|
||||||
|
case = suite.add_comparative_case(name=f"case{i}", user_message=f"test{i}")
|
||||||
|
case.for_track("track1", expected_tool_calls=[])
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.choices = [MagicMock()]
|
||||||
|
mock_response.choices[0].message.tool_calls = None
|
||||||
|
mock_client.chat.completions.create.return_value = mock_response
|
||||||
|
|
||||||
|
# Semaphore with max_concurrent=2 will be used
|
||||||
|
result = await suite.run_comparative(mock_client, "gpt-4o", provider="openai")
|
||||||
|
|
||||||
|
# All cases should complete
|
||||||
|
assert len(result["track1"]["cases"]) == 3
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_comparative_with_anthropic_provider(self) -> None:
|
||||||
|
"""Test run_comparative works with Anthropic provider."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
|
||||||
|
suite.add_tool_definitions([{"name": "search", "description": "Search", "inputSchema": {}}], track="track1")
|
||||||
|
|
||||||
|
case = suite.add_comparative_case(name="test", user_message="search for cats")
|
||||||
|
case.for_track("track1", expected_tool_calls=[ExpectedMCPToolCall("search", args={"query": "cats"})])
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.content = []
|
||||||
|
mock_client.messages.create.return_value = mock_response
|
||||||
|
|
||||||
|
result = await suite.run_comparative(mock_client, "claude-3", provider="anthropic")
|
||||||
|
|
||||||
|
assert "track1" in result
|
||||||
|
assert result["track1"]["model"] == "claude-3"
|
||||||
|
|
||||||
|
|
||||||
|
class TestComparativeCaseBuilder:
|
||||||
|
"""Tests for ComparativeCaseBuilder fluent API."""
|
||||||
|
|
||||||
|
def test_for_track_returns_builder_for_chaining(self) -> None:
|
||||||
|
"""Test that for_track returns builder for method chaining."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
suite.add_tool_definitions([{"name": "t1", "description": "Test", "inputSchema": {}}], track="track1")
|
||||||
|
suite.add_tool_definitions([{"name": "t2", "description": "Test", "inputSchema": {}}], track="track2")
|
||||||
|
|
||||||
|
builder = suite.add_comparative_case(name="test", user_message="test")
|
||||||
|
result1 = builder.for_track("track1", expected_tool_calls=[])
|
||||||
|
result2 = result1.for_track("track2", expected_tool_calls=[])
|
||||||
|
|
||||||
|
# Should return same builder for chaining
|
||||||
|
assert result1 is builder
|
||||||
|
assert result2 is builder
|
||||||
|
|
||||||
|
def test_comparative_case_with_custom_rubric(self) -> None:
|
||||||
|
"""Test that comparative cases can have custom rubrics."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
suite.add_tool_definitions([{"name": "t1", "description": "Test", "inputSchema": {}}], track="track1")
|
||||||
|
|
||||||
|
strict_rubric = EvalRubric(fail_threshold=0.7, warn_threshold=0.9)
|
||||||
|
|
||||||
|
# Rubric is set on the case, not per track
|
||||||
|
builder = suite.add_comparative_case(name="test", user_message="test", rubric=strict_rubric)
|
||||||
|
builder.for_track("track1", expected_tool_calls=[])
|
||||||
|
|
||||||
|
# Build and verify rubric is stored on the case
|
||||||
|
comp_case = builder.build()
|
||||||
|
assert comp_case.rubric == strict_rubric
|
||||||
|
|
||||||
|
def test_for_track_with_track_specific_critics(self) -> None:
|
||||||
|
"""Test that tracks can have specific critics."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
suite.add_tool_definitions([{"name": "t1", "description": "Test", "inputSchema": {}}], track="track1")
|
||||||
|
|
||||||
|
critics = [BinaryCritic(critic_field="query", weight=1.0)]
|
||||||
|
|
||||||
|
builder = suite.add_comparative_case(name="test", user_message="test")
|
||||||
|
builder.for_track("track1", expected_tool_calls=[], critics=critics)
|
||||||
|
|
||||||
|
comp_case = builder.build()
|
||||||
|
assert comp_case.track_configs["track1"].critics == critics
|
||||||
|
|
||||||
|
def test_build_raises_if_no_tracks_configured(self) -> None:
|
||||||
|
"""Test that build() raises error if no tracks are configured."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
builder = suite.add_comparative_case(name="test", user_message="test")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="No tracks configured"):
|
||||||
|
builder.build()
|
||||||
|
|
||||||
|
|
||||||
|
class TestComparativeTrackValidation:
|
||||||
|
"""Tests for track validation in comparative evaluations."""
|
||||||
|
|
||||||
|
def test_for_track_validates_track_exists(self) -> None:
|
||||||
|
"""Test that for_track validates track exists immediately."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
|
||||||
|
# Register only track1
|
||||||
|
suite.add_tool_definitions([{"name": "t1", "description": "Test", "inputSchema": {}}], track="track1")
|
||||||
|
|
||||||
|
# Try to use nonexistent_track
|
||||||
|
case = suite.add_comparative_case(name="test", user_message="test")
|
||||||
|
case.for_track("track1", expected_tool_calls=[])
|
||||||
|
|
||||||
|
# for_track validates immediately
|
||||||
|
with pytest.raises(ValueError, match="Track.*not found"):
|
||||||
|
case.for_track("nonexistent_track", expected_tool_calls=[])
|
||||||
|
|
||||||
|
def test_for_track_error_lists_available_tracks(self) -> None:
|
||||||
|
"""Test that error message lists available tracks."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
|
||||||
|
suite.add_tool_definitions([{"name": "t1", "description": "Test", "inputSchema": {}}], track="available_track")
|
||||||
|
|
||||||
|
case = suite.add_comparative_case(name="test", user_message="test")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
case.for_track("missing_track", expected_tool_calls=[])
|
||||||
|
|
||||||
|
error_msg = str(exc_info.value)
|
||||||
|
assert "missing_track" in error_msg
|
||||||
|
assert "available_track" in error_msg
|
||||||
|
|
||||||
|
|
||||||
|
class TestComparativeConcurrencyControl:
|
||||||
|
"""Tests for concurrency control in comparative execution."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_semaphore_limits_concurrent_tasks(self) -> None:
|
||||||
|
"""Test that semaphore properly limits concurrent API calls."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test", max_concurrent=1)
|
||||||
|
|
||||||
|
suite.add_tool_definitions([{"name": "t1", "description": "Test", "inputSchema": {}}], track="track1")
|
||||||
|
|
||||||
|
# Add 3 cases - with max_concurrent=1, they should run sequentially
|
||||||
|
for i in range(3):
|
||||||
|
case = suite.add_comparative_case(name=f"case{i}", user_message="test")
|
||||||
|
case.for_track("track1", expected_tool_calls=[])
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def mock_create(**kwargs):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
# Simulate some delay
|
||||||
|
import asyncio
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.choices = [MagicMock()]
|
||||||
|
mock_response.choices[0].message.tool_calls = None
|
||||||
|
return mock_response
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.chat.completions.create = mock_create
|
||||||
|
|
||||||
|
await suite.run_comparative(mock_client, "gpt-4o", provider="openai")
|
||||||
|
|
||||||
|
# All 3 cases should have been called
|
||||||
|
assert call_count == 3
|
||||||
293
libs/tests/arcade_evals/test_convenience_async.py
Normal file
293
libs/tests/arcade_evals/test_convenience_async.py
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
"""Tests for async MCP convenience methods in EvalSuite."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_evals import EvalSuite
|
||||||
|
|
||||||
|
# Mark all tests in this module as requiring evals dependencies
|
||||||
|
pytestmark = pytest.mark.evals
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddMcpServer:
|
||||||
|
"""Tests for add_mcp_server async convenience method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_mcp_server_loads_and_registers_tools(self) -> None:
|
||||||
|
"""Test that add_mcp_server loads tools and adds them to registry."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
|
||||||
|
mock_tools = [
|
||||||
|
{"name": "tool1", "description": "Test tool 1", "inputSchema": {}},
|
||||||
|
{"name": "tool2", "description": "Test tool 2", "inputSchema": {}},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("arcade_evals._evalsuite._convenience.load_mcp_remote_async") as mock_load:
|
||||||
|
mock_load.return_value = mock_tools
|
||||||
|
|
||||||
|
result = await suite.add_mcp_server(
|
||||||
|
"http://localhost:8000",
|
||||||
|
headers={"Authorization": "Bearer token"},
|
||||||
|
timeout=15,
|
||||||
|
use_sse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify loader was called with correct args
|
||||||
|
mock_load.assert_called_once_with(
|
||||||
|
"http://localhost:8000",
|
||||||
|
timeout=15,
|
||||||
|
headers={"Authorization": "Bearer token"},
|
||||||
|
use_sse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify tools were registered
|
||||||
|
tools = suite._internal_registry.list_tools_for_model("openai")
|
||||||
|
assert len(tools) == 2
|
||||||
|
|
||||||
|
# Verify returns self for chaining
|
||||||
|
assert result is suite
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_mcp_server_with_track(self) -> None:
|
||||||
|
"""Test add_mcp_server with track parameter."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
|
||||||
|
mock_tools = [{"name": "tool1", "description": "Test", "inputSchema": {}}]
|
||||||
|
|
||||||
|
with patch("arcade_evals._evalsuite._convenience.load_mcp_remote_async") as mock_load:
|
||||||
|
mock_load.return_value = mock_tools
|
||||||
|
|
||||||
|
await suite.add_mcp_server("http://localhost:8000", track="github")
|
||||||
|
|
||||||
|
# Verify track was created
|
||||||
|
assert suite._track_manager.has_track("github")
|
||||||
|
|
||||||
|
# Verify tool is in track registry
|
||||||
|
track_registry = suite._track_manager.get_registry("github")
|
||||||
|
assert track_registry is not None
|
||||||
|
track_tools = track_registry.list_tools_for_model("openai")
|
||||||
|
assert len(track_tools) == 1
|
||||||
|
assert track_tools[0]["function"]["name"] == "tool1"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_mcp_server_warns_on_empty_tools(self) -> None:
|
||||||
|
"""Test that add_mcp_server warns when no tools are loaded."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
|
||||||
|
with patch("arcade_evals._evalsuite._convenience.load_mcp_remote_async") as mock_load:
|
||||||
|
mock_load.return_value = [] # Empty tools
|
||||||
|
|
||||||
|
with pytest.warns(UserWarning, match="No tools loaded from"):
|
||||||
|
await suite.add_mcp_server("http://localhost:8000")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_mcp_server_handles_loader_exception(self) -> None:
|
||||||
|
"""Test that add_mcp_server propagates loader exceptions."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
|
||||||
|
with patch("arcade_evals._evalsuite._convenience.load_mcp_remote_async") as mock_load:
|
||||||
|
mock_load.side_effect = TimeoutError("Connection timeout")
|
||||||
|
|
||||||
|
with pytest.raises(TimeoutError, match="Connection timeout"):
|
||||||
|
await suite.add_mcp_server("http://localhost:8000")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddMcpStdioServer:
|
||||||
|
"""Tests for add_mcp_stdio_server async convenience method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_mcp_stdio_server_loads_and_registers_tools(self) -> None:
|
||||||
|
"""Test that add_mcp_stdio_server loads tools and adds them to registry."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
|
||||||
|
mock_tools = [
|
||||||
|
{"name": "linear_search", "description": "Search", "inputSchema": {}},
|
||||||
|
{"name": "linear_create", "description": "Create", "inputSchema": {}},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("arcade_evals._evalsuite._convenience.load_from_stdio_async") as mock_load:
|
||||||
|
mock_load.return_value = mock_tools
|
||||||
|
|
||||||
|
command = ["python", "-m", "arcade_mcp_server", "stdio"]
|
||||||
|
env = {"ARCADE_API_KEY": "test_key"}
|
||||||
|
|
||||||
|
result = await suite.add_mcp_stdio_server(command, env=env, timeout=20)
|
||||||
|
|
||||||
|
# Verify loader was called with correct args
|
||||||
|
mock_load.assert_called_once_with(command, timeout=20, env=env)
|
||||||
|
|
||||||
|
# Verify tools were registered
|
||||||
|
tools = suite._internal_registry.list_tools_for_model("openai")
|
||||||
|
assert len(tools) == 2
|
||||||
|
|
||||||
|
# Verify returns self for chaining
|
||||||
|
assert result is suite
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_mcp_stdio_server_with_track(self) -> None:
|
||||||
|
"""Test add_mcp_stdio_server with track parameter."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
|
||||||
|
mock_tools = [{"name": "tool1", "description": "Test", "inputSchema": {}}]
|
||||||
|
|
||||||
|
with patch("arcade_evals._evalsuite._convenience.load_from_stdio_async") as mock_load:
|
||||||
|
mock_load.return_value = mock_tools
|
||||||
|
|
||||||
|
await suite.add_mcp_stdio_server(["python", "server.py"], track="linear")
|
||||||
|
|
||||||
|
# Verify track was created
|
||||||
|
assert suite._track_manager.has_track("linear")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_mcp_stdio_server_warns_on_empty_tools(self) -> None:
|
||||||
|
"""Test that add_mcp_stdio_server warns when no tools are loaded."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
|
||||||
|
with patch("arcade_evals._evalsuite._convenience.load_from_stdio_async") as mock_load:
|
||||||
|
mock_load.return_value = []
|
||||||
|
|
||||||
|
with pytest.warns(UserWarning, match="No tools loaded from stdio"):
|
||||||
|
await suite.add_mcp_stdio_server(["python", "server.py"])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_mcp_stdio_server_handles_loader_exception(self) -> None:
|
||||||
|
"""Test that add_mcp_stdio_server propagates loader exceptions."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
|
||||||
|
with patch("arcade_evals._evalsuite._convenience.load_from_stdio_async") as mock_load:
|
||||||
|
mock_load.side_effect = TimeoutError("Stdio timeout")
|
||||||
|
|
||||||
|
with pytest.raises(TimeoutError, match="Stdio timeout"):
|
||||||
|
await suite.add_mcp_stdio_server(["python", "server.py"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddArcadeGateway:
|
||||||
|
"""Tests for add_arcade_gateway async convenience method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_arcade_gateway_loads_and_registers_tools(self) -> None:
|
||||||
|
"""Test that add_arcade_gateway loads tools and adds them to registry."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
|
||||||
|
mock_tools = [
|
||||||
|
{"name": "Github_CreateIssue", "description": "Create issue", "inputSchema": {}},
|
||||||
|
{"name": "Github_GetIssue", "description": "Get issue", "inputSchema": {}},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"arcade_evals._evalsuite._convenience.load_arcade_mcp_gateway_async"
|
||||||
|
) as mock_load:
|
||||||
|
mock_load.return_value = mock_tools
|
||||||
|
|
||||||
|
result = await suite.add_arcade_gateway(
|
||||||
|
"my-gateway",
|
||||||
|
arcade_api_key="test_key",
|
||||||
|
arcade_user_id="test@example.com",
|
||||||
|
base_url="https://api.arcade.dev",
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify loader was called with correct args
|
||||||
|
mock_load.assert_called_once_with(
|
||||||
|
"my-gateway",
|
||||||
|
arcade_api_key="test_key",
|
||||||
|
arcade_user_id="test@example.com",
|
||||||
|
base_url="https://api.arcade.dev",
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify tools were registered
|
||||||
|
tools = suite._internal_registry.list_tools_for_model("openai")
|
||||||
|
assert len(tools) == 2
|
||||||
|
|
||||||
|
# Verify returns self for chaining
|
||||||
|
assert result is suite
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_arcade_gateway_with_track(self) -> None:
|
||||||
|
"""Test add_arcade_gateway with track parameter."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
|
||||||
|
mock_tools = [{"name": "tool1", "description": "Test", "inputSchema": {}}]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"arcade_evals._evalsuite._convenience.load_arcade_mcp_gateway_async"
|
||||||
|
) as mock_load:
|
||||||
|
mock_load.return_value = mock_tools
|
||||||
|
|
||||||
|
await suite.add_arcade_gateway("my-gateway", track="arcade")
|
||||||
|
|
||||||
|
# Verify track was created
|
||||||
|
assert suite._track_manager.has_track("arcade")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_arcade_gateway_warns_on_empty_tools(self) -> None:
|
||||||
|
"""Test that add_arcade_gateway warns when no tools are loaded."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"arcade_evals._evalsuite._convenience.load_arcade_mcp_gateway_async"
|
||||||
|
) as mock_load:
|
||||||
|
mock_load.return_value = []
|
||||||
|
|
||||||
|
with pytest.warns(UserWarning, match="No tools loaded from Arcade gateway"):
|
||||||
|
await suite.add_arcade_gateway("my-gateway")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_arcade_gateway_handles_loader_exception(self) -> None:
|
||||||
|
"""Test that add_arcade_gateway propagates loader exceptions."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"arcade_evals._evalsuite._convenience.load_arcade_mcp_gateway_async"
|
||||||
|
) as mock_load:
|
||||||
|
mock_load.side_effect = Exception("Gateway connection failed")
|
||||||
|
|
||||||
|
with pytest.raises(Exception, match="Gateway connection failed"):
|
||||||
|
await suite.add_arcade_gateway("my-gateway")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncConvenienceMethodChaining:
|
||||||
|
"""Tests for method chaining with async MCP methods."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chaining_multiple_mcp_sources(self) -> None:
|
||||||
|
"""Test that async methods can be chained together."""
|
||||||
|
suite = EvalSuite(name="test", system_message="test")
|
||||||
|
|
||||||
|
mock_http_tools = [{"name": "http_tool", "description": "HTTP", "inputSchema": {}}]
|
||||||
|
mock_stdio_tools = [{"name": "stdio_tool", "description": "Stdio", "inputSchema": {}}]
|
||||||
|
mock_gateway_tools = [
|
||||||
|
{"name": "gateway_tool", "description": "Gateway", "inputSchema": {}}
|
||||||
|
]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"arcade_evals._evalsuite._convenience.load_mcp_remote_async"
|
||||||
|
) as mock_http,
|
||||||
|
patch(
|
||||||
|
"arcade_evals._evalsuite._convenience.load_from_stdio_async"
|
||||||
|
) as mock_stdio,
|
||||||
|
patch(
|
||||||
|
"arcade_evals._evalsuite._convenience.load_arcade_mcp_gateway_async"
|
||||||
|
) as mock_gateway,
|
||||||
|
):
|
||||||
|
mock_http.return_value = mock_http_tools
|
||||||
|
mock_stdio.return_value = mock_stdio_tools
|
||||||
|
mock_gateway.return_value = mock_gateway_tools
|
||||||
|
|
||||||
|
# Chain all three methods
|
||||||
|
result = await suite.add_mcp_server("http://localhost:8000")
|
||||||
|
result = await result.add_mcp_stdio_server(["python", "server.py"])
|
||||||
|
result = await result.add_arcade_gateway("my-gateway")
|
||||||
|
|
||||||
|
# Verify all tools were registered
|
||||||
|
tools = suite._internal_registry.list_tools_for_model("openai")
|
||||||
|
assert len(tools) == 3
|
||||||
|
tool_names = [t["function"]["name"] for t in tools]
|
||||||
|
assert "http_tool" in tool_names
|
||||||
|
assert "stdio_tool" in tool_names
|
||||||
|
assert "gateway_tool" in tool_names
|
||||||
|
|
||||||
|
# Verify final result is still the suite
|
||||||
|
assert result is suite
|
||||||
386
libs/tests/arcade_evals/test_critics.py
Normal file
386
libs/tests/arcade_evals/test_critics.py
Normal file
|
|
@ -0,0 +1,386 @@
|
||||||
|
"""Tests for critic evaluation logic."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_evals.critic import (
|
||||||
|
BinaryCritic,
|
||||||
|
NoneCritic,
|
||||||
|
NumericCritic,
|
||||||
|
SimilarityCritic,
|
||||||
|
)
|
||||||
|
from arcade_evals.errors import WeightError
|
||||||
|
from arcade_evals.weights import FuzzyWeight
|
||||||
|
|
||||||
|
# Mark all tests in this module as requiring evals dependencies
|
||||||
|
pytestmark = pytest.mark.evals
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoneCritic:
|
||||||
|
"""Tests for NoneCritic placeholder."""
|
||||||
|
|
||||||
|
def test_none_critic_always_returns_zero_score(self) -> None:
|
||||||
|
"""Test that NoneCritic always returns score 0."""
|
||||||
|
critic = NoneCritic(critic_field="test", weight=0.0)
|
||||||
|
result = critic.evaluate("expected", "actual")
|
||||||
|
|
||||||
|
assert result["score"] == 0.0
|
||||||
|
assert result["match"] is None
|
||||||
|
assert result["is_criticized"] is False
|
||||||
|
|
||||||
|
def test_none_critic_has_marker_attribute(self) -> None:
|
||||||
|
"""Test that NoneCritic has _is_placeholder marker."""
|
||||||
|
critic = NoneCritic(critic_field="test", weight=0.0)
|
||||||
|
assert hasattr(critic, "_is_placeholder")
|
||||||
|
assert critic._is_placeholder is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestBinaryCritic:
|
||||||
|
"""Tests for BinaryCritic exact equality comparisons."""
|
||||||
|
|
||||||
|
def test_binary_critic_exact_match_returns_full_weight(self) -> None:
|
||||||
|
"""Test that exact match returns full weight as score."""
|
||||||
|
critic = BinaryCritic(critic_field="name", weight=1.0)
|
||||||
|
result = critic.evaluate("Alice", "Alice")
|
||||||
|
|
||||||
|
assert result["match"] is True
|
||||||
|
assert result["score"] == 1.0
|
||||||
|
|
||||||
|
def test_binary_critic_mismatch_returns_zero_score(self) -> None:
|
||||||
|
"""Test that mismatch returns score 0."""
|
||||||
|
critic = BinaryCritic(critic_field="name", weight=1.0)
|
||||||
|
result = critic.evaluate("Alice", "Bob")
|
||||||
|
|
||||||
|
assert result["match"] is False
|
||||||
|
assert result["score"] == 0.0
|
||||||
|
|
||||||
|
def test_binary_critic_partial_weight(self) -> None:
|
||||||
|
"""Test that partial weight is respected."""
|
||||||
|
critic = BinaryCritic(critic_field="name", weight=0.5)
|
||||||
|
result = critic.evaluate("Alice", "Alice")
|
||||||
|
|
||||||
|
assert result["match"] is True
|
||||||
|
assert result["score"] == 0.5
|
||||||
|
|
||||||
|
def test_binary_critic_cast_actual_to_expected_type(self) -> None:
|
||||||
|
"""Test that actual value is cast to expected type."""
|
||||||
|
critic = BinaryCritic(critic_field="count", weight=1.0)
|
||||||
|
# Expect int, get string
|
||||||
|
result = critic.evaluate(42, "42")
|
||||||
|
|
||||||
|
assert result["match"] is True
|
||||||
|
assert result["score"] == 1.0
|
||||||
|
|
||||||
|
def test_binary_critic_none_handling(self) -> None:
|
||||||
|
"""Test None value handling."""
|
||||||
|
critic = BinaryCritic(critic_field="optional", weight=1.0)
|
||||||
|
|
||||||
|
# None == None
|
||||||
|
result = critic.evaluate(None, None)
|
||||||
|
assert result["match"] is True
|
||||||
|
|
||||||
|
# None != value
|
||||||
|
result = critic.evaluate(None, "value")
|
||||||
|
assert result["match"] is False
|
||||||
|
|
||||||
|
# String "None" is cast to None
|
||||||
|
result = critic.evaluate(None, "None")
|
||||||
|
assert result["match"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestNumericCritic:
|
||||||
|
"""Tests for NumericCritic fuzzy numeric comparisons."""
|
||||||
|
|
||||||
|
def test_numeric_critic_exact_match_returns_full_score(self) -> None:
|
||||||
|
"""Test that exact match returns full weight as score."""
|
||||||
|
critic = NumericCritic(
|
||||||
|
critic_field="temperature", weight=1.0, value_range=(0.0, 100.0)
|
||||||
|
)
|
||||||
|
result = critic.evaluate(50.0, 50.0)
|
||||||
|
|
||||||
|
assert result["match"] is True
|
||||||
|
assert result["score"] == 1.0
|
||||||
|
|
||||||
|
def test_numeric_critic_close_values_high_score(self) -> None:
|
||||||
|
"""Test that close values get high scores."""
|
||||||
|
critic = NumericCritic(
|
||||||
|
critic_field="temperature",
|
||||||
|
weight=1.0,
|
||||||
|
value_range=(0.0, 100.0),
|
||||||
|
match_threshold=0.9,
|
||||||
|
)
|
||||||
|
# Within 10% of range
|
||||||
|
result = critic.evaluate(50.0, 55.0)
|
||||||
|
|
||||||
|
assert result["score"] >= 0.9
|
||||||
|
assert result["match"] is True
|
||||||
|
|
||||||
|
def test_numeric_critic_far_values_low_score(self) -> None:
|
||||||
|
"""Test that far values get low scores."""
|
||||||
|
critic = NumericCritic(
|
||||||
|
critic_field="temperature", weight=1.0, value_range=(0.0, 100.0)
|
||||||
|
)
|
||||||
|
# Far apart
|
||||||
|
result = critic.evaluate(10.0, 90.0)
|
||||||
|
|
||||||
|
assert result["score"] < 0.3
|
||||||
|
assert result["match"] is False
|
||||||
|
|
||||||
|
def test_numeric_critic_respects_match_threshold(self) -> None:
|
||||||
|
"""Test that match_threshold correctly determines match status."""
|
||||||
|
critic = NumericCritic(
|
||||||
|
critic_field="value",
|
||||||
|
weight=1.0,
|
||||||
|
value_range=(0.0, 100.0),
|
||||||
|
match_threshold=0.95,
|
||||||
|
)
|
||||||
|
# Score is 0.9 (within 10% of range) - below 0.95 threshold
|
||||||
|
result = critic.evaluate(50.0, 60.0)
|
||||||
|
|
||||||
|
assert result["score"] == 0.9
|
||||||
|
assert result["match"] is False # Below threshold
|
||||||
|
|
||||||
|
def test_numeric_critic_at_range_boundaries(self) -> None:
|
||||||
|
"""Test evaluation at range boundaries."""
|
||||||
|
critic = NumericCritic(critic_field="value", weight=1.0, value_range=(0.0, 100.0))
|
||||||
|
|
||||||
|
# At min boundary
|
||||||
|
result = critic.evaluate(0.0, 0.0)
|
||||||
|
assert result["match"] is True
|
||||||
|
assert result["score"] == 1.0
|
||||||
|
|
||||||
|
# At max boundary
|
||||||
|
result = critic.evaluate(100.0, 100.0)
|
||||||
|
assert result["match"] is True
|
||||||
|
assert result["score"] == 1.0
|
||||||
|
|
||||||
|
def test_numeric_critic_outside_range_handled(self) -> None:
|
||||||
|
"""Test that values outside range are handled (extrapolation)."""
|
||||||
|
critic = NumericCritic(critic_field="value", weight=1.0, value_range=(0.0, 100.0))
|
||||||
|
|
||||||
|
# Actual is outside range
|
||||||
|
result = critic.evaluate(50.0, 150.0)
|
||||||
|
# Normalized difference will be large, score will be low or negative
|
||||||
|
assert result["score"] <= 0.0
|
||||||
|
|
||||||
|
def test_numeric_critic_partial_weight(self) -> None:
|
||||||
|
"""Test that partial weight is respected."""
|
||||||
|
critic = NumericCritic(critic_field="value", weight=0.5, value_range=(0.0, 100.0))
|
||||||
|
result = critic.evaluate(50.0, 50.0)
|
||||||
|
|
||||||
|
assert result["score"] == 0.5 # Perfect match * 0.5 weight
|
||||||
|
|
||||||
|
def test_numeric_critic_invalid_range_raises_error(self) -> None:
|
||||||
|
"""Test that invalid range (min >= max) raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Invalid value_range"):
|
||||||
|
NumericCritic(critic_field="value", weight=1.0, value_range=(100.0, 0.0))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Invalid value_range"):
|
||||||
|
NumericCritic(critic_field="value", weight=1.0, value_range=(50.0, 50.0))
|
||||||
|
|
||||||
|
|
||||||
|
class TestSimilarityCritic:
|
||||||
|
"""Tests for SimilarityCritic text similarity comparisons."""
|
||||||
|
|
||||||
|
def test_similarity_critic_exact_match_returns_full_score(self) -> None:
|
||||||
|
"""Test that exact string match returns full weight as score."""
|
||||||
|
critic = SimilarityCritic(critic_field="query", weight=1.0)
|
||||||
|
result = critic.evaluate("search for cats", "search for cats")
|
||||||
|
|
||||||
|
assert result["match"] is True
|
||||||
|
assert result["score"] == 1.0
|
||||||
|
|
||||||
|
def test_similarity_critic_very_similar_strings_high_score(self) -> None:
|
||||||
|
"""Test that very similar strings get high scores."""
|
||||||
|
critic = SimilarityCritic(
|
||||||
|
critic_field="query", weight=1.0, similarity_threshold=0.5
|
||||||
|
)
|
||||||
|
result = critic.evaluate("search for cats", "search for cat")
|
||||||
|
|
||||||
|
# Very similar (just plural difference)
|
||||||
|
assert result["score"] >= 0.5
|
||||||
|
assert result["match"] is True
|
||||||
|
|
||||||
|
def test_similarity_critic_different_strings_low_score(self) -> None:
|
||||||
|
"""Test that different strings get low scores."""
|
||||||
|
critic = SimilarityCritic(critic_field="query", weight=1.0)
|
||||||
|
result = critic.evaluate("search for cats", "weather in Paris")
|
||||||
|
|
||||||
|
assert result["score"] < 0.3
|
||||||
|
assert result["match"] is False
|
||||||
|
|
||||||
|
def test_similarity_critic_respects_threshold(self) -> None:
|
||||||
|
"""Test that similarity_threshold correctly determines match status."""
|
||||||
|
critic = SimilarityCritic(
|
||||||
|
critic_field="query", weight=1.0, similarity_threshold=0.9
|
||||||
|
)
|
||||||
|
result = critic.evaluate("hello world", "hello there")
|
||||||
|
|
||||||
|
# Similarity might be ~0.6-0.7 - below 0.9 threshold
|
||||||
|
assert result["match"] is False
|
||||||
|
|
||||||
|
def test_similarity_critic_partial_weight(self) -> None:
|
||||||
|
"""Test that partial weight is respected."""
|
||||||
|
critic = SimilarityCritic(critic_field="query", weight=0.5)
|
||||||
|
result = critic.evaluate("test", "test")
|
||||||
|
|
||||||
|
assert result["score"] == 0.5 # Perfect match * 0.5 weight
|
||||||
|
|
||||||
|
def test_similarity_critic_handles_empty_strings(self) -> None:
|
||||||
|
"""Test handling of empty strings."""
|
||||||
|
critic = SimilarityCritic(critic_field="query", weight=1.0)
|
||||||
|
|
||||||
|
# Empty == Empty
|
||||||
|
result = critic.evaluate("", "")
|
||||||
|
# TF-IDF can't compute similarity for empty strings - should handle gracefully
|
||||||
|
assert "score" in result
|
||||||
|
assert "match" in result
|
||||||
|
|
||||||
|
def test_similarity_critic_converts_lists_to_strings(self) -> None:
|
||||||
|
"""Test that lists are converted to space-separated strings."""
|
||||||
|
critic = SimilarityCritic(critic_field="tags", weight=1.0)
|
||||||
|
|
||||||
|
# Lists should be joined with spaces
|
||||||
|
result = critic.evaluate(
|
||||||
|
["python", "security"], ["python", "security", "best-practices"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should be comparing "python security" vs "python security best-practices"
|
||||||
|
assert "score" in result
|
||||||
|
assert result["score"] > 0.5 # Should have some similarity
|
||||||
|
|
||||||
|
def test_similarity_critic_converts_non_strings(self) -> None:
|
||||||
|
"""Test that non-string values are converted to strings."""
|
||||||
|
critic = SimilarityCritic(critic_field="value", weight=1.0)
|
||||||
|
|
||||||
|
# Numbers to strings
|
||||||
|
result = critic.evaluate(12345, 12345)
|
||||||
|
assert result["match"] is True
|
||||||
|
assert result["score"] == 1.0
|
||||||
|
|
||||||
|
# Dict to string
|
||||||
|
result = critic.evaluate({"key": "value"}, {"key": "value"})
|
||||||
|
assert result["score"] > 0.8 # Should match after stringification
|
||||||
|
|
||||||
|
def test_similarity_critic_unsupported_metric_raises_error(self) -> None:
|
||||||
|
"""Test that unsupported metric raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Unsupported similarity metric"):
|
||||||
|
SimilarityCritic(critic_field="query", weight=1.0, metric="hamming")
|
||||||
|
|
||||||
|
def test_similarity_critic_requires_sklearn(self) -> None:
|
||||||
|
"""Test that SimilarityCritic raises ImportError without sklearn."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
critic = SimilarityCritic(critic_field="query", weight=1.0)
|
||||||
|
|
||||||
|
# Patch the import inside evaluate() to simulate missing sklearn
|
||||||
|
with patch.dict("sys.modules", {"sklearn.feature_extraction.text": None}):
|
||||||
|
with pytest.raises(ImportError, match="pip install.*arcade-evals"):
|
||||||
|
critic.evaluate("test", "test2")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCriticWeights:
|
||||||
|
"""Tests for critic weight validation and FuzzyWeight support."""
|
||||||
|
|
||||||
|
def test_negative_weight_raises_error(self) -> None:
|
||||||
|
"""Test that negative weights raise WeightError."""
|
||||||
|
with pytest.raises(WeightError, match="non-negative"):
|
||||||
|
BinaryCritic(critic_field="test", weight=-0.5)
|
||||||
|
|
||||||
|
def test_fuzzy_weight_skips_validation(self) -> None:
|
||||||
|
"""Test that FuzzyWeight skips validation (normalized later)."""
|
||||||
|
# Should not raise even though FuzzyWeight.CRITICAL might be > 1
|
||||||
|
critic = BinaryCritic(critic_field="test", weight=FuzzyWeight.CRITICAL)
|
||||||
|
assert critic.weight == FuzzyWeight.CRITICAL
|
||||||
|
|
||||||
|
def test_zero_weight_allowed(self) -> None:
|
||||||
|
"""Test that zero weight is allowed."""
|
||||||
|
critic = BinaryCritic(critic_field="test", weight=0.0)
|
||||||
|
assert critic.weight == 0.0
|
||||||
|
|
||||||
|
def test_large_weight_allowed(self) -> None:
|
||||||
|
"""Test that weights > 1.0 are allowed (softmax normalization handles)."""
|
||||||
|
critic = BinaryCritic(critic_field="test", weight=5.0)
|
||||||
|
assert critic.weight == 5.0
|
||||||
|
|
||||||
|
def test_resolved_weight_returns_float(self) -> None:
|
||||||
|
"""Test that resolved_weight property returns float."""
|
||||||
|
critic = BinaryCritic(critic_field="test", weight=0.8)
|
||||||
|
assert isinstance(critic.resolved_weight, float)
|
||||||
|
assert critic.resolved_weight == 0.8
|
||||||
|
|
||||||
|
def test_resolved_weight_with_fuzzy_weight(self) -> None:
|
||||||
|
"""Test resolved_weight with FuzzyWeight enum."""
|
||||||
|
critic = BinaryCritic(critic_field="test", weight=FuzzyWeight.HIGH)
|
||||||
|
# FuzzyWeight.HIGH has value 5 (int)
|
||||||
|
assert isinstance(critic.resolved_weight, (int, float))
|
||||||
|
assert critic.resolved_weight > 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestCriticEdgeCases:
|
||||||
|
"""Tests for edge cases in critic evaluation."""
|
||||||
|
|
||||||
|
def test_binary_critic_with_complex_types(self) -> None:
|
||||||
|
"""Test BinaryCritic with dicts and lists."""
|
||||||
|
critic = BinaryCritic(critic_field="config", weight=1.0)
|
||||||
|
|
||||||
|
# Dict comparison
|
||||||
|
result = critic.evaluate({"a": 1, "b": 2}, {"a": 1, "b": 2})
|
||||||
|
assert result["match"] is True
|
||||||
|
|
||||||
|
# List comparison
|
||||||
|
result = critic.evaluate([1, 2, 3], [1, 2, 3])
|
||||||
|
assert result["match"] is True
|
||||||
|
|
||||||
|
# Nested structures
|
||||||
|
result = critic.evaluate({"list": [1, 2]}, {"list": [1, 2]})
|
||||||
|
assert result["match"] is True
|
||||||
|
|
||||||
|
def test_numeric_critic_with_string_numbers(self) -> None:
|
||||||
|
"""Test NumericCritic casts string numbers to float."""
|
||||||
|
critic = NumericCritic(critic_field="value", weight=1.0, value_range=(0.0, 100.0))
|
||||||
|
result = critic.evaluate("50.0", "50.0")
|
||||||
|
|
||||||
|
assert result["match"] is True
|
||||||
|
assert result["score"] == 1.0
|
||||||
|
|
||||||
|
def test_similarity_critic_case_insensitive(self) -> None:
|
||||||
|
"""Test that SimilarityCritic handles case differences."""
|
||||||
|
critic = SimilarityCritic(critic_field="query", weight=1.0)
|
||||||
|
result = critic.evaluate("Hello World", "hello world")
|
||||||
|
|
||||||
|
# Should still have high similarity (lowercase conversion happens in TF-IDF)
|
||||||
|
assert result["score"] > 0.9
|
||||||
|
assert result["match"] is True
|
||||||
|
|
||||||
|
def test_similarity_critic_punctuation_differences(self) -> None:
|
||||||
|
"""Test SimilarityCritic with punctuation variations."""
|
||||||
|
critic = SimilarityCritic(
|
||||||
|
critic_field="query", weight=1.0, similarity_threshold=0.8
|
||||||
|
)
|
||||||
|
result = critic.evaluate("search for cats!", "search for cats")
|
||||||
|
|
||||||
|
# Should have very high similarity despite punctuation
|
||||||
|
assert result["score"] >= 0.8
|
||||||
|
assert result["match"] is True
|
||||||
|
|
||||||
|
def test_numeric_critic_with_negative_ranges(self) -> None:
|
||||||
|
"""Test NumericCritic with negative value ranges."""
|
||||||
|
critic = NumericCritic(
|
||||||
|
critic_field="temperature", weight=1.0, value_range=(-50.0, 50.0)
|
||||||
|
)
|
||||||
|
result = critic.evaluate(-10.0, -10.0)
|
||||||
|
|
||||||
|
assert result["match"] is True
|
||||||
|
assert result["score"] == 1.0
|
||||||
|
|
||||||
|
# Test scoring across negative range
|
||||||
|
result = critic.evaluate(-50.0, 50.0)
|
||||||
|
assert result["score"] == 0.0 # Maximum difference
|
||||||
|
|
||||||
|
def test_numeric_critic_floating_point_precision(self) -> None:
|
||||||
|
"""Test NumericCritic handles floating point precision correctly."""
|
||||||
|
critic = NumericCritic(critic_field="value", weight=1.0, value_range=(0.0, 1.0))
|
||||||
|
result = critic.evaluate(0.333333, 0.333334)
|
||||||
|
|
||||||
|
# Very close values should have very high score
|
||||||
|
assert result["score"] > 0.999
|
||||||
|
assert result["match"] is True
|
||||||
781
libs/tests/arcade_evals/test_loaders.py
Normal file
781
libs/tests/arcade_evals/test_loaders.py
Normal file
|
|
@ -0,0 +1,781 @@
|
||||||
|
"""Tests for MCP server loaders (official MCP SDK wrappers)."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Mark all tests in this module as requiring evals dependencies
|
||||||
|
pytestmark = pytest.mark.evals
|
||||||
|
|
||||||
|
# Import the loaders module directly by file path to avoid arcade_core dependency
|
||||||
|
_LOADERS_PATH = Path(__file__).parent.parent.parent / "arcade-evals" / "arcade_evals" / "loaders.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("loaders", _LOADERS_PATH)
|
||||||
|
loaders = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules["arcade_evals.loaders"] = loaders
|
||||||
|
spec.loader.exec_module(loaders)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadFromStdio:
|
||||||
|
"""Tests for load_from_stdio function."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Clear cache before each test."""
|
||||||
|
loaders.clear_tools_cache()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
"""Clear cache after each test."""
|
||||||
|
loaders.clear_tools_cache()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_command_returns_empty_list(self):
|
||||||
|
"""Empty command should return empty list without importing MCP."""
|
||||||
|
result = await loaders.load_from_stdio_async([])
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_env_vars_are_merged_into_stdio_server_parameters(self):
|
||||||
|
"""Env vars should be merged with current env and passed to StdioServerParameters."""
|
||||||
|
mock_tool = MagicMock()
|
||||||
|
mock_tool.name = "t"
|
||||||
|
mock_tool.description = "d"
|
||||||
|
mock_tool.inputSchema = {"type": "object", "properties": {}}
|
||||||
|
|
||||||
|
mock_list_result = MagicMock()
|
||||||
|
mock_list_result.tools = [mock_tool]
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.initialize = AsyncMock()
|
||||||
|
mock_session.list_tools = AsyncMock(return_value=mock_list_result)
|
||||||
|
|
||||||
|
mock_client_session_cls = MagicMock()
|
||||||
|
mock_client_session_cls.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
|
mock_client_session_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_stdio_client = MagicMock()
|
||||||
|
mock_stdio_client.return_value.__aenter__ = AsyncMock(return_value=("read", "write"))
|
||||||
|
mock_stdio_client.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_sse_client = MagicMock()
|
||||||
|
mock_stdio_params_cls = MagicMock()
|
||||||
|
|
||||||
|
with patch.object(loaders, "_require_mcp") as mock_require:
|
||||||
|
mock_require.return_value = (
|
||||||
|
mock_client_session_cls,
|
||||||
|
mock_stdio_params_cls,
|
||||||
|
mock_stdio_client,
|
||||||
|
mock_sse_client,
|
||||||
|
MagicMock(), # streamablehttp_client
|
||||||
|
)
|
||||||
|
|
||||||
|
await loaders.load_from_stdio_async(["echo"], env={"TEST_VAR": "test_value"})
|
||||||
|
|
||||||
|
# Ensure env merged and passed into server params
|
||||||
|
_, call_kwargs = mock_stdio_params_cls.call_args
|
||||||
|
assert "env" in call_kwargs
|
||||||
|
assert call_kwargs["env"]["TEST_VAR"] == "test_value"
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadFromHttp:
|
||||||
|
"""Tests for load_from_http function."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Clear cache before each test."""
|
||||||
|
loaders.clear_tools_cache()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
"""Clear cache after each test."""
|
||||||
|
loaders.clear_tools_cache()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_url_gets_mcp_appended(self):
|
||||||
|
"""URL without /mcp should get it appended before calling sse_client."""
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.initialize = AsyncMock()
|
||||||
|
mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[]))
|
||||||
|
|
||||||
|
mock_client_session_cls = MagicMock()
|
||||||
|
mock_client_session_cls.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
|
mock_client_session_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_sse_client = MagicMock()
|
||||||
|
mock_sse_client.return_value.__aenter__ = AsyncMock(return_value=("read", "write"))
|
||||||
|
mock_sse_client.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(loaders, "_require_mcp") as mock_require:
|
||||||
|
mock_require.return_value = (
|
||||||
|
mock_client_session_cls,
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(),
|
||||||
|
mock_sse_client,
|
||||||
|
MagicMock(), # streamablehttp_client
|
||||||
|
)
|
||||||
|
|
||||||
|
await loaders.load_mcp_remote_async("http://localhost:8000", use_sse=True)
|
||||||
|
called_url = mock_sse_client.call_args[0][0]
|
||||||
|
assert called_url.endswith("/mcp")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_url_with_mcp_not_duplicated(self):
|
||||||
|
"""URL with /mcp should not get duplicated."""
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.initialize = AsyncMock()
|
||||||
|
mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[]))
|
||||||
|
|
||||||
|
mock_client_session_cls = MagicMock()
|
||||||
|
mock_client_session_cls.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
|
mock_client_session_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_sse_client = MagicMock()
|
||||||
|
mock_sse_client.return_value.__aenter__ = AsyncMock(return_value=("read", "write"))
|
||||||
|
mock_sse_client.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(loaders, "_require_mcp") as mock_require:
|
||||||
|
mock_require.return_value = (
|
||||||
|
mock_client_session_cls,
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(),
|
||||||
|
mock_sse_client,
|
||||||
|
MagicMock(), # streamablehttp_client
|
||||||
|
)
|
||||||
|
|
||||||
|
await loaders.load_mcp_remote_async("http://localhost:8000/mcp", use_sse=True)
|
||||||
|
called_url = mock_sse_client.call_args[0][0]
|
||||||
|
assert "/mcp/mcp" not in called_url
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_headers_are_passed(self):
|
||||||
|
"""Custom headers should be passed to sse_client."""
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.initialize = AsyncMock()
|
||||||
|
mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[]))
|
||||||
|
|
||||||
|
mock_client_session_cls = MagicMock()
|
||||||
|
mock_client_session_cls.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
|
mock_client_session_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_sse_client = MagicMock()
|
||||||
|
mock_sse_client.return_value.__aenter__ = AsyncMock(return_value=("read", "write"))
|
||||||
|
mock_sse_client.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(loaders, "_require_mcp") as mock_require:
|
||||||
|
mock_require.return_value = (
|
||||||
|
mock_client_session_cls,
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(),
|
||||||
|
mock_sse_client,
|
||||||
|
MagicMock(), # streamablehttp_client
|
||||||
|
)
|
||||||
|
|
||||||
|
await loaders.load_mcp_remote_async(
|
||||||
|
"http://localhost:8000",
|
||||||
|
headers={"Authorization": "Bearer token123"},
|
||||||
|
use_sse=True,
|
||||||
|
)
|
||||||
|
_, call_kwargs = mock_sse_client.call_args
|
||||||
|
assert call_kwargs["headers"]["Authorization"] == "Bearer token123"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_tools_from_response(self):
|
||||||
|
"""Should convert SDK Tool objects into dicts."""
|
||||||
|
mock_tool1 = MagicMock()
|
||||||
|
mock_tool1.name = "tool1"
|
||||||
|
mock_tool1.description = "Test tool 1"
|
||||||
|
mock_tool1.inputSchema = {"type": "object", "properties": {}}
|
||||||
|
|
||||||
|
mock_tool2 = MagicMock()
|
||||||
|
mock_tool2.name = "tool2"
|
||||||
|
mock_tool2.description = None
|
||||||
|
mock_tool2.inputSchema = {"type": "object", "properties": {}}
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.initialize = AsyncMock()
|
||||||
|
mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[mock_tool1, mock_tool2]))
|
||||||
|
|
||||||
|
mock_client_session_cls = MagicMock()
|
||||||
|
mock_client_session_cls.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
|
mock_client_session_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_sse_client = MagicMock()
|
||||||
|
mock_sse_client.return_value.__aenter__ = AsyncMock(return_value=("read", "write"))
|
||||||
|
mock_sse_client.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(loaders, "_require_mcp") as mock_require:
|
||||||
|
mock_require.return_value = (
|
||||||
|
mock_client_session_cls,
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(),
|
||||||
|
mock_sse_client,
|
||||||
|
MagicMock(), # streamablehttp_client
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await loaders.load_mcp_remote_async("http://localhost:8000", use_sse=True)
|
||||||
|
assert result == [
|
||||||
|
{
|
||||||
|
"name": "tool1",
|
||||||
|
"description": "Test tool 1",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tool2",
|
||||||
|
"description": "",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadArcadeMcpGateway:
|
||||||
|
"""Tests for load_arcade_mcp_gateway function."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Clear cache before each test."""
|
||||||
|
loaders.clear_tools_cache()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
"""Clear cache after each test."""
|
||||||
|
loaders.clear_tools_cache()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_builds_correct_url_and_headers_with_slug(self):
|
||||||
|
"""Should build correct Arcade MCP URL and pass auth headers."""
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.initialize = AsyncMock()
|
||||||
|
mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[]))
|
||||||
|
|
||||||
|
mock_client_session_cls = MagicMock()
|
||||||
|
mock_client_session_cls.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
|
mock_client_session_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
# Arcade gateway uses streamable-http (returns 3 values)
|
||||||
|
mock_streamable_client = MagicMock()
|
||||||
|
mock_streamable_client.return_value.__aenter__ = AsyncMock(
|
||||||
|
return_value=("read", "write", "session_id")
|
||||||
|
)
|
||||||
|
mock_streamable_client.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(loaders, "_require_mcp") as mock_require:
|
||||||
|
mock_require.return_value = (
|
||||||
|
mock_client_session_cls,
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(), # sse_client
|
||||||
|
mock_streamable_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
await loaders.load_arcade_mcp_gateway_async(
|
||||||
|
"my-gateway",
|
||||||
|
arcade_api_key="key",
|
||||||
|
arcade_user_id="user",
|
||||||
|
)
|
||||||
|
|
||||||
|
called_url = mock_streamable_client.call_args[0][0]
|
||||||
|
called_headers = mock_streamable_client.call_args[1]["headers"]
|
||||||
|
assert called_url == "https://api.arcade.dev/mcp/my-gateway"
|
||||||
|
# Code adds "Bearer " prefix to key
|
||||||
|
assert called_headers["Authorization"] == "Bearer key"
|
||||||
|
assert called_headers["Arcade-User-Id"] == "user"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_builds_correct_url_without_slug(self):
|
||||||
|
"""Should build correct Arcade MCP URL without gateway slug."""
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.initialize = AsyncMock()
|
||||||
|
mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[]))
|
||||||
|
|
||||||
|
mock_client_session_cls = MagicMock()
|
||||||
|
mock_client_session_cls.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
|
mock_client_session_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_streamable_client = MagicMock()
|
||||||
|
mock_streamable_client.return_value.__aenter__ = AsyncMock(
|
||||||
|
return_value=("read", "write", "session_id")
|
||||||
|
)
|
||||||
|
mock_streamable_client.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(loaders, "_require_mcp") as mock_require:
|
||||||
|
mock_require.return_value = (
|
||||||
|
mock_client_session_cls,
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(), # sse_client
|
||||||
|
mock_streamable_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
await loaders.load_arcade_mcp_gateway_async(arcade_api_key="key")
|
||||||
|
|
||||||
|
called_url = mock_streamable_client.call_args[0][0]
|
||||||
|
assert called_url == "https://api.arcade.dev/mcp"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_custom_base_url(self):
|
||||||
|
"""Should use custom base URL when provided."""
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.initialize = AsyncMock()
|
||||||
|
mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[]))
|
||||||
|
|
||||||
|
mock_client_session_cls = MagicMock()
|
||||||
|
mock_client_session_cls.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
|
mock_client_session_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_streamable_client = MagicMock()
|
||||||
|
mock_streamable_client.return_value.__aenter__ = AsyncMock(
|
||||||
|
return_value=("read", "write", "session_id")
|
||||||
|
)
|
||||||
|
mock_streamable_client.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(loaders, "_require_mcp") as mock_require:
|
||||||
|
mock_require.return_value = (
|
||||||
|
mock_client_session_cls,
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(), # sse_client
|
||||||
|
mock_streamable_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
await loaders.load_arcade_mcp_gateway_async(
|
||||||
|
"my-gateway",
|
||||||
|
base_url="https://staging.arcade.dev",
|
||||||
|
)
|
||||||
|
|
||||||
|
called_url = mock_streamable_client.call_args[0][0]
|
||||||
|
assert called_url == "https://staging.arcade.dev/mcp/my-gateway"
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadStdioArcade:
|
||||||
|
"""Tests for load_stdio_arcade function."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_passes_env_vars_to_stdio(self):
|
||||||
|
"""Should pass Arcade env vars to stdio loader."""
|
||||||
|
with patch.object(loaders, "load_from_stdio_async", new_callable=AsyncMock) as mock_stdio:
|
||||||
|
mock_stdio.return_value = []
|
||||||
|
|
||||||
|
await loaders.load_stdio_arcade_async(
|
||||||
|
["python", "server.py"],
|
||||||
|
arcade_api_key="test_key",
|
||||||
|
arcade_user_id="test_user",
|
||||||
|
)
|
||||||
|
|
||||||
|
call_kwargs = mock_stdio.call_args[1]
|
||||||
|
assert call_kwargs["env"]["ARCADE_API_KEY"] == "test_key"
|
||||||
|
assert call_kwargs["env"]["ARCADE_USER_ID"] == "test_user"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_includes_tool_secrets(self):
|
||||||
|
"""Should include tool secrets in environment."""
|
||||||
|
with patch.object(loaders, "load_from_stdio_async", new_callable=AsyncMock) as mock_stdio:
|
||||||
|
mock_stdio.return_value = []
|
||||||
|
|
||||||
|
await loaders.load_stdio_arcade_async(
|
||||||
|
["python", "server.py"],
|
||||||
|
tool_secrets={"GITHUB_TOKEN": "gh_token", "SLACK_TOKEN": "slack_token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
call_kwargs = mock_stdio.call_args[1]
|
||||||
|
assert call_kwargs["env"]["GITHUB_TOKEN"] == "gh_token"
|
||||||
|
assert call_kwargs["env"]["SLACK_TOKEN"] == "slack_token"
|
||||||
|
|
||||||
|
|
||||||
|
class TestLazyImport:
|
||||||
|
"""Tests for lazy MCP import behavior."""
|
||||||
|
|
||||||
|
def test_require_mcp_error_message(self):
|
||||||
|
"""Should raise helpful ImportError when MCP SDK is not installed."""
|
||||||
|
# If MCP is installed in the environment, this test isn't meaningful.
|
||||||
|
# Force an import failure by masking the module.
|
||||||
|
with patch.dict(sys.modules, {"mcp": None}):
|
||||||
|
with pytest.raises(ImportError) as exc:
|
||||||
|
loaders._require_mcp()
|
||||||
|
assert "pip install" in str(exc.value)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_loader_raises_import_error_without_mcp(self):
|
||||||
|
"""Test that HTTP loader raises ImportError when MCP SDK missing."""
|
||||||
|
with patch.dict(sys.modules, {"mcp": None}):
|
||||||
|
with pytest.raises(ImportError, match="pip install"):
|
||||||
|
await loaders.load_mcp_remote_async("http://localhost:8000")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stdio_loader_raises_import_error_without_mcp(self):
|
||||||
|
"""Test that stdio loader raises ImportError when MCP SDK missing."""
|
||||||
|
with patch.dict(sys.modules, {"mcp": None}):
|
||||||
|
with pytest.raises(ImportError, match="pip install"):
|
||||||
|
await loaders.load_from_stdio_async(["python", "server.py"])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_arcade_gateway_loader_raises_import_error_without_mcp(self):
|
||||||
|
"""Test that Arcade gateway loader raises ImportError when MCP SDK missing."""
|
||||||
|
with patch.dict(sys.modules, {"mcp": None}):
|
||||||
|
with pytest.raises(ImportError, match="pip install"):
|
||||||
|
await loaders.load_arcade_mcp_gateway_async("my-gateway")
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnsureMcpPath:
|
||||||
|
"""Tests for _ensure_mcp_path utility function."""
|
||||||
|
|
||||||
|
def test_appends_mcp_to_bare_url(self):
|
||||||
|
"""Should append /mcp to URL without path."""
|
||||||
|
result = loaders._ensure_mcp_path("http://localhost:8000")
|
||||||
|
assert result == "http://localhost:8000/mcp"
|
||||||
|
|
||||||
|
def test_appends_mcp_to_url_with_path(self):
|
||||||
|
"""Should append /mcp to URL with existing path."""
|
||||||
|
result = loaders._ensure_mcp_path("http://localhost:8000/api")
|
||||||
|
assert result == "http://localhost:8000/api/mcp"
|
||||||
|
|
||||||
|
def test_does_not_duplicate_mcp(self):
|
||||||
|
"""Should not duplicate /mcp if already present."""
|
||||||
|
result = loaders._ensure_mcp_path("http://localhost:8000/mcp")
|
||||||
|
assert result == "http://localhost:8000/mcp"
|
||||||
|
|
||||||
|
def test_handles_mcp_in_path(self):
|
||||||
|
"""Should not add /mcp if 'mcp' is anywhere in path segments."""
|
||||||
|
result = loaders._ensure_mcp_path("http://localhost:8000/mcp/my-slug")
|
||||||
|
assert result == "http://localhost:8000/mcp/my-slug"
|
||||||
|
|
||||||
|
def test_preserves_query_string(self):
|
||||||
|
"""Should preserve query string in URL."""
|
||||||
|
result = loaders._ensure_mcp_path("http://localhost:8000?foo=bar")
|
||||||
|
assert result == "http://localhost:8000/mcp?foo=bar"
|
||||||
|
|
||||||
|
def test_preserves_fragment(self):
|
||||||
|
"""Should preserve fragment in URL."""
|
||||||
|
result = loaders._ensure_mcp_path("http://localhost:8000#section")
|
||||||
|
assert result == "http://localhost:8000/mcp#section"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildArcadeMcpUrl:
|
||||||
|
"""Tests for _build_arcade_mcp_url utility function."""
|
||||||
|
|
||||||
|
def test_builds_url_with_slug(self):
|
||||||
|
"""Should build correct URL with gateway slug."""
|
||||||
|
result = loaders._build_arcade_mcp_url("my-gateway", "https://api.arcade.dev")
|
||||||
|
assert result == "https://api.arcade.dev/mcp/my-gateway"
|
||||||
|
|
||||||
|
def test_builds_url_without_slug(self):
|
||||||
|
"""Should build correct URL without gateway slug."""
|
||||||
|
result = loaders._build_arcade_mcp_url(None, "https://api.arcade.dev")
|
||||||
|
assert result == "https://api.arcade.dev/mcp"
|
||||||
|
|
||||||
|
def test_strips_trailing_slash(self):
|
||||||
|
"""Should strip trailing slash from base URL."""
|
||||||
|
result = loaders._build_arcade_mcp_url("my-gateway", "https://api.arcade.dev/")
|
||||||
|
assert result == "https://api.arcade.dev/mcp/my-gateway"
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolToDict:
|
||||||
|
"""Tests for _tool_to_dict utility function."""
|
||||||
|
|
||||||
|
def test_converts_tool_to_dict(self):
|
||||||
|
"""Should convert MCP Tool object to dictionary."""
|
||||||
|
mock_tool = MagicMock()
|
||||||
|
mock_tool.name = "my_tool"
|
||||||
|
mock_tool.description = "A description"
|
||||||
|
mock_tool.inputSchema = {"type": "object", "properties": {"x": {"type": "string"}}}
|
||||||
|
|
||||||
|
result = loaders._tool_to_dict(mock_tool)
|
||||||
|
assert result == {
|
||||||
|
"name": "my_tool",
|
||||||
|
"description": "A description",
|
||||||
|
"inputSchema": {"type": "object", "properties": {"x": {"type": "string"}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_handles_none_description(self):
|
||||||
|
"""Should handle None description."""
|
||||||
|
mock_tool = MagicMock()
|
||||||
|
mock_tool.name = "my_tool"
|
||||||
|
mock_tool.description = None
|
||||||
|
mock_tool.inputSchema = {}
|
||||||
|
|
||||||
|
result = loaders._tool_to_dict(mock_tool)
|
||||||
|
assert result["description"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolsCache:
|
||||||
|
"""Tests for tools caching functionality."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Clear cache before each test."""
|
||||||
|
loaders.clear_tools_cache()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
"""Clear cache after each test."""
|
||||||
|
loaders.clear_tools_cache()
|
||||||
|
|
||||||
|
def test_clear_tools_cache(self):
|
||||||
|
"""Should clear the tools cache and locks."""
|
||||||
|
# Add something to cache directly
|
||||||
|
loaders._tools_cache["test_key"] = [{"name": "tool1"}]
|
||||||
|
loaders._cache_locks["test_key"] = MagicMock()
|
||||||
|
assert len(loaders._tools_cache) == 1
|
||||||
|
assert len(loaders._cache_locks) == 1
|
||||||
|
|
||||||
|
loaders.clear_tools_cache()
|
||||||
|
assert len(loaders._tools_cache) == 0
|
||||||
|
assert len(loaders._cache_locks) == 0
|
||||||
|
|
||||||
|
def test_make_cache_key_different_urls(self):
|
||||||
|
"""Should create different keys for different URLs."""
|
||||||
|
key1 = loaders._make_cache_key("http://localhost:8000", None)
|
||||||
|
key2 = loaders._make_cache_key("http://localhost:9000", None)
|
||||||
|
assert key1 != key2
|
||||||
|
|
||||||
|
def test_make_cache_key_different_headers(self):
|
||||||
|
"""Should create different keys for different headers."""
|
||||||
|
key1 = loaders._make_cache_key("http://localhost:8000", {"Auth": "token1"})
|
||||||
|
key2 = loaders._make_cache_key("http://localhost:8000", {"Auth": "token2"})
|
||||||
|
assert key1 != key2
|
||||||
|
|
||||||
|
def test_make_cache_key_same_inputs(self):
|
||||||
|
"""Should create same key for same inputs."""
|
||||||
|
key1 = loaders._make_cache_key("http://localhost:8000", {"Auth": "token"})
|
||||||
|
key2 = loaders._make_cache_key("http://localhost:8000", {"Auth": "token"})
|
||||||
|
assert key1 == key2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_cache_lock_creates_lock(self):
|
||||||
|
"""Should create a lock for a new key."""
|
||||||
|
lock = await loaders._get_cache_lock("new_key")
|
||||||
|
assert isinstance(lock, type(loaders.asyncio.Lock()))
|
||||||
|
assert "new_key" in loaders._cache_locks
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_cache_lock_returns_same_lock(self):
|
||||||
|
"""Should return same lock for same key."""
|
||||||
|
lock1 = await loaders._get_cache_lock("same_key")
|
||||||
|
lock2 = await loaders._get_cache_lock("same_key")
|
||||||
|
assert lock1 is lock2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_acquire_lock_with_timeout_succeeds(self):
|
||||||
|
"""Should acquire lock successfully when available."""
|
||||||
|
lock = loaders.asyncio.Lock()
|
||||||
|
acquired = await loaders._acquire_lock_with_timeout(lock, timeout=1.0)
|
||||||
|
assert acquired is True
|
||||||
|
assert lock.locked()
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_acquire_lock_with_timeout_fails(self):
|
||||||
|
"""Should return False when lock acquisition times out."""
|
||||||
|
lock = loaders.asyncio.Lock()
|
||||||
|
await lock.acquire() # Hold the lock
|
||||||
|
|
||||||
|
# Try to acquire with short timeout - should fail
|
||||||
|
acquired = await loaders._acquire_lock_with_timeout(lock, timeout=0.1)
|
||||||
|
assert acquired is False
|
||||||
|
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_loader_caches_results(self):
|
||||||
|
"""Should cache results and return cached on second call."""
|
||||||
|
mock_tool = MagicMock()
|
||||||
|
mock_tool.name = "tool1"
|
||||||
|
mock_tool.description = "Test"
|
||||||
|
mock_tool.inputSchema = {}
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.initialize = AsyncMock()
|
||||||
|
mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[mock_tool]))
|
||||||
|
|
||||||
|
mock_client_session_cls = MagicMock()
|
||||||
|
mock_client_session_cls.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
|
mock_client_session_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_sse_client = MagicMock()
|
||||||
|
mock_sse_client.return_value.__aenter__ = AsyncMock(return_value=("read", "write"))
|
||||||
|
mock_sse_client.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(loaders, "_require_mcp") as mock_require:
|
||||||
|
mock_require.return_value = (
|
||||||
|
mock_client_session_cls,
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(),
|
||||||
|
mock_sse_client,
|
||||||
|
MagicMock(), # streamablehttp_client
|
||||||
|
)
|
||||||
|
|
||||||
|
# First call - should connect
|
||||||
|
result1 = await loaders.load_mcp_remote_async("http://localhost:8000", use_sse=True)
|
||||||
|
assert len(result1) == 1
|
||||||
|
assert mock_sse_client.call_count == 1
|
||||||
|
|
||||||
|
# Second call - should use cache
|
||||||
|
result2 = await loaders.load_mcp_remote_async("http://localhost:8000", use_sse=True)
|
||||||
|
assert len(result2) == 1
|
||||||
|
# sse_client should NOT be called again
|
||||||
|
assert mock_sse_client.call_count == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_loader_different_urls_not_cached(self):
|
||||||
|
"""Should not use cache for different URLs."""
|
||||||
|
mock_tool = MagicMock()
|
||||||
|
mock_tool.name = "tool1"
|
||||||
|
mock_tool.description = "Test"
|
||||||
|
mock_tool.inputSchema = {}
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.initialize = AsyncMock()
|
||||||
|
mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[mock_tool]))
|
||||||
|
|
||||||
|
mock_client_session_cls = MagicMock()
|
||||||
|
mock_client_session_cls.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
|
mock_client_session_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_sse_client = MagicMock()
|
||||||
|
mock_sse_client.return_value.__aenter__ = AsyncMock(return_value=("read", "write"))
|
||||||
|
mock_sse_client.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(loaders, "_require_mcp") as mock_require:
|
||||||
|
mock_require.return_value = (
|
||||||
|
mock_client_session_cls,
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(),
|
||||||
|
mock_sse_client,
|
||||||
|
MagicMock(), # streamablehttp_client
|
||||||
|
)
|
||||||
|
|
||||||
|
# First URL
|
||||||
|
await loaders.load_mcp_remote_async("http://localhost:8000", use_sse=True)
|
||||||
|
assert mock_sse_client.call_count == 1
|
||||||
|
|
||||||
|
# Different URL - should connect again
|
||||||
|
await loaders.load_mcp_remote_async("http://localhost:9000", use_sse=True)
|
||||||
|
assert mock_sse_client.call_count == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_loader_lock_timeout_raises_error(self):
|
||||||
|
"""Should raise TimeoutError when lock acquisition times out."""
|
||||||
|
# Create a lock and hold it
|
||||||
|
loaders._cache_locks["test_key"] = loaders.asyncio.Lock()
|
||||||
|
lock = loaders._cache_locks["test_key"]
|
||||||
|
await lock.acquire()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to load with a key that will wait for the held lock
|
||||||
|
with (
|
||||||
|
patch.object(loaders, "_make_cache_key", return_value="test_key"),
|
||||||
|
patch.object(loaders, "LOCK_TIMEOUT_SECONDS", 0.1),
|
||||||
|
):
|
||||||
|
with pytest.raises(TimeoutError, match="Timeout waiting for lock"):
|
||||||
|
await loaders.load_mcp_remote_async("http://localhost:8000")
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
|
loaders.clear_tools_cache()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stdio_loader_lock_timeout_raises_error(self):
|
||||||
|
"""Should raise TimeoutError when stdio lock acquisition times out."""
|
||||||
|
# Create a specific cache key and hold its lock
|
||||||
|
cache_key = "stdio|python server.py|[]"
|
||||||
|
loaders._cache_locks[cache_key] = loaders.asyncio.Lock()
|
||||||
|
lock = loaders._cache_locks[cache_key]
|
||||||
|
await lock.acquire()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(loaders, "LOCK_TIMEOUT_SECONDS", 0.1):
|
||||||
|
with pytest.raises(TimeoutError, match="Timeout waiting for lock on stdio"):
|
||||||
|
await loaders.load_from_stdio_async(["python", "server.py"])
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
|
loaders.clear_tools_cache()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lock_released_after_connection_error(self):
|
||||||
|
"""Should release lock even when MCP connection fails."""
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.initialize = AsyncMock(side_effect=ConnectionError("Connection failed"))
|
||||||
|
|
||||||
|
mock_client_session_cls = MagicMock()
|
||||||
|
mock_client_session_cls.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
|
mock_client_session_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_sse_client = MagicMock()
|
||||||
|
mock_sse_client.return_value.__aenter__ = AsyncMock(return_value=("read", "write"))
|
||||||
|
mock_sse_client.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(loaders, "_require_mcp") as mock_require:
|
||||||
|
mock_require.return_value = (
|
||||||
|
mock_client_session_cls,
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(),
|
||||||
|
mock_sse_client,
|
||||||
|
MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# First call should fail
|
||||||
|
with pytest.raises(ConnectionError):
|
||||||
|
await loaders.load_mcp_remote_async("http://localhost:8000", use_sse=True)
|
||||||
|
|
||||||
|
# Lock should be released - second call should not timeout
|
||||||
|
cache_key = loaders._make_cache_key("http://localhost:8000/mcp", None)
|
||||||
|
lock = loaders._cache_locks.get(cache_key)
|
||||||
|
if lock:
|
||||||
|
assert not lock.locked(), "Lock should be released after error"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMCPLoggingFilter:
|
||||||
|
"""Tests for MCP SDK logging filter."""
|
||||||
|
|
||||||
|
def test_filter_suppresses_session_termination_202(self):
|
||||||
|
"""Should suppress 'Session termination failed: 202' messages."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
log_filter = loaders.MCPSessionFilter()
|
||||||
|
|
||||||
|
# Create a mock log record with the misleading message
|
||||||
|
record = logging.LogRecord(
|
||||||
|
name="mcp.client.session",
|
||||||
|
level=logging.WARNING,
|
||||||
|
pathname="",
|
||||||
|
lineno=0,
|
||||||
|
msg="Session termination failed: 202",
|
||||||
|
args=(),
|
||||||
|
exc_info=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should be filtered out (return False)
|
||||||
|
assert log_filter.filter(record) is False
|
||||||
|
|
||||||
|
def test_filter_allows_other_messages(self):
|
||||||
|
"""Should allow other log messages through."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
log_filter = loaders.MCPSessionFilter()
|
||||||
|
|
||||||
|
# Create a log record with a normal message
|
||||||
|
record = logging.LogRecord(
|
||||||
|
name="mcp.client",
|
||||||
|
level=logging.INFO,
|
||||||
|
pathname="",
|
||||||
|
lineno=0,
|
||||||
|
msg="Connected to MCP server",
|
||||||
|
args=(),
|
||||||
|
exc_info=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should pass through (return True)
|
||||||
|
assert log_filter.filter(record) is True
|
||||||
|
|
||||||
|
def test_filter_allows_real_errors(self):
|
||||||
|
"""Should allow real error messages through."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
log_filter = loaders.MCPSessionFilter()
|
||||||
|
|
||||||
|
# Create a log record with an actual error
|
||||||
|
record = logging.LogRecord(
|
||||||
|
name="mcp.client",
|
||||||
|
level=logging.ERROR,
|
||||||
|
pathname="",
|
||||||
|
lineno=0,
|
||||||
|
msg="Connection failed: Timeout",
|
||||||
|
args=(),
|
||||||
|
exc_info=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should pass through (return True)
|
||||||
|
assert log_filter.filter(record) is True
|
||||||
1028
libs/tests/arcade_evals/test_schema_converters.py
Normal file
1028
libs/tests/arcade_evals/test_schema_converters.py
Normal file
File diff suppressed because it is too large
Load diff
126
libs/tests/arcade_evals/test_tracks.py
Normal file
126
libs/tests/arcade_evals/test_tracks.py
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
"""Tests for track management in comparative evaluations."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_evals._evalsuite._tool_registry import EvalSuiteToolRegistry
|
||||||
|
from arcade_evals._evalsuite._tracks import TrackManager
|
||||||
|
|
||||||
|
# Mark all tests in this module as requiring evals dependencies
|
||||||
|
pytestmark = pytest.mark.evals
|
||||||
|
|
||||||
|
|
||||||
|
class TestTrackManager:
|
||||||
|
"""Tests for TrackManager class."""
|
||||||
|
|
||||||
|
def test_create_track(self) -> None:
|
||||||
|
"""Test creating a new track."""
|
||||||
|
manager = TrackManager()
|
||||||
|
registry = EvalSuiteToolRegistry()
|
||||||
|
|
||||||
|
track_name = manager.create_track("Test Track", registry)
|
||||||
|
|
||||||
|
assert track_name == "Test Track"
|
||||||
|
assert manager.has_track("Test Track")
|
||||||
|
assert manager.track_count() == 1
|
||||||
|
|
||||||
|
def test_create_duplicate_track_raises(self) -> None:
|
||||||
|
"""Test that creating a duplicate track raises ValueError."""
|
||||||
|
manager = TrackManager()
|
||||||
|
registry1 = EvalSuiteToolRegistry()
|
||||||
|
registry2 = EvalSuiteToolRegistry()
|
||||||
|
|
||||||
|
manager.create_track("Track1", registry1)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="already exists"):
|
||||||
|
manager.create_track("Track1", registry2)
|
||||||
|
|
||||||
|
def test_get_registry(self) -> None:
|
||||||
|
"""Test getting a registry by track name."""
|
||||||
|
manager = TrackManager()
|
||||||
|
registry = EvalSuiteToolRegistry()
|
||||||
|
registry.add_tool({"name": "TestTool", "description": "Test"})
|
||||||
|
|
||||||
|
manager.create_track("MyTrack", registry)
|
||||||
|
retrieved = manager.get_registry("MyTrack")
|
||||||
|
|
||||||
|
assert retrieved is registry
|
||||||
|
assert retrieved.has_tool("TestTool")
|
||||||
|
|
||||||
|
def test_get_registry_nonexistent(self) -> None:
|
||||||
|
"""Test getting a nonexistent registry returns None."""
|
||||||
|
manager = TrackManager()
|
||||||
|
|
||||||
|
result = manager.get_registry("NonexistentTrack")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_get_track_names(self) -> None:
|
||||||
|
"""Test getting all track names."""
|
||||||
|
manager = TrackManager()
|
||||||
|
manager.create_track("Track1", EvalSuiteToolRegistry())
|
||||||
|
manager.create_track("Track2", EvalSuiteToolRegistry())
|
||||||
|
manager.create_track("Track3", EvalSuiteToolRegistry())
|
||||||
|
|
||||||
|
names = manager.get_track_names()
|
||||||
|
|
||||||
|
assert names == ["Track1", "Track2", "Track3"]
|
||||||
|
|
||||||
|
def test_get_track_names_empty(self) -> None:
|
||||||
|
"""Test getting track names when empty."""
|
||||||
|
manager = TrackManager()
|
||||||
|
|
||||||
|
names = manager.get_track_names()
|
||||||
|
|
||||||
|
assert names == []
|
||||||
|
|
||||||
|
def test_has_track(self) -> None:
|
||||||
|
"""Test checking if track exists."""
|
||||||
|
manager = TrackManager()
|
||||||
|
manager.create_track("Exists", EvalSuiteToolRegistry())
|
||||||
|
|
||||||
|
assert manager.has_track("Exists") is True
|
||||||
|
assert manager.has_track("DoesNotExist") is False
|
||||||
|
|
||||||
|
def test_track_count(self) -> None:
|
||||||
|
"""Test counting tracks."""
|
||||||
|
manager = TrackManager()
|
||||||
|
|
||||||
|
assert manager.track_count() == 0
|
||||||
|
|
||||||
|
manager.create_track("Track1", EvalSuiteToolRegistry())
|
||||||
|
assert manager.track_count() == 1
|
||||||
|
|
||||||
|
manager.create_track("Track2", EvalSuiteToolRegistry())
|
||||||
|
assert manager.track_count() == 2
|
||||||
|
|
||||||
|
def test_get_all_registries(self) -> None:
|
||||||
|
"""Test getting all registries."""
|
||||||
|
manager = TrackManager()
|
||||||
|
reg1 = EvalSuiteToolRegistry()
|
||||||
|
reg2 = EvalSuiteToolRegistry()
|
||||||
|
|
||||||
|
manager.create_track("Track1", reg1)
|
||||||
|
manager.create_track("Track2", reg2)
|
||||||
|
|
||||||
|
all_regs = manager.get_all_registries()
|
||||||
|
|
||||||
|
assert len(all_regs) == 2
|
||||||
|
assert all_regs["Track1"] is reg1
|
||||||
|
assert all_regs["Track2"] is reg2
|
||||||
|
|
||||||
|
def test_registries_are_isolated(self) -> None:
|
||||||
|
"""Test that each track has its own isolated registry."""
|
||||||
|
manager = TrackManager()
|
||||||
|
reg1 = EvalSuiteToolRegistry()
|
||||||
|
reg2 = EvalSuiteToolRegistry()
|
||||||
|
|
||||||
|
reg1.add_tool({"name": "Tool1", "description": "Tool for track 1"})
|
||||||
|
reg2.add_tool({"name": "Tool2", "description": "Tool for track 2"})
|
||||||
|
|
||||||
|
manager.create_track("Track1", reg1)
|
||||||
|
manager.create_track("Track2", reg2)
|
||||||
|
|
||||||
|
# Each registry only has its own tool
|
||||||
|
assert manager.get_registry("Track1").has_tool("Tool1")
|
||||||
|
assert not manager.get_registry("Track1").has_tool("Tool2")
|
||||||
|
assert manager.get_registry("Track2").has_tool("Tool2")
|
||||||
|
assert not manager.get_registry("Track2").has_tool("Tool1")
|
||||||
391
libs/tests/arcade_evals/test_types.py
Normal file
391
libs/tests/arcade_evals/test_types.py
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
"""Tests for shared types in _types.py module."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_evals._evalsuite._types import (
|
||||||
|
AnyExpectedToolCall,
|
||||||
|
ComparativeCase,
|
||||||
|
EvalRubric,
|
||||||
|
ExpectedMCPToolCall,
|
||||||
|
ExpectedToolCall,
|
||||||
|
NamedExpectedToolCall,
|
||||||
|
TrackConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark all tests in this module as requiring evals dependencies
|
||||||
|
pytestmark = pytest.mark.evals
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpectedToolCall:
|
||||||
|
"""Tests for ExpectedToolCall dataclass."""
|
||||||
|
|
||||||
|
def test_create_with_func_and_args(self) -> None:
|
||||||
|
"""Test creating ExpectedToolCall with function and args."""
|
||||||
|
|
||||||
|
def my_tool(x: int, y: int) -> int:
|
||||||
|
return x + y
|
||||||
|
|
||||||
|
tc = ExpectedToolCall(func=my_tool, args={"x": 1, "y": 2})
|
||||||
|
|
||||||
|
assert tc.func is my_tool
|
||||||
|
assert tc.args == {"x": 1, "y": 2}
|
||||||
|
|
||||||
|
def test_create_with_empty_args(self) -> None:
|
||||||
|
"""Test creating ExpectedToolCall with default empty args."""
|
||||||
|
|
||||||
|
def my_tool() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
tc = ExpectedToolCall(func=my_tool)
|
||||||
|
|
||||||
|
assert tc.func is my_tool
|
||||||
|
assert tc.args == {}
|
||||||
|
|
||||||
|
def test_create_positional_args(self) -> None:
|
||||||
|
"""Test creating ExpectedToolCall with positional args."""
|
||||||
|
|
||||||
|
def my_tool(x: int) -> int:
|
||||||
|
return x
|
||||||
|
|
||||||
|
tc = ExpectedToolCall(my_tool, {"x": 5})
|
||||||
|
|
||||||
|
assert tc.func is my_tool
|
||||||
|
assert tc.args == {"x": 5}
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpectedMCPToolCall:
|
||||||
|
"""Tests for ExpectedMCPToolCall dataclass."""
|
||||||
|
|
||||||
|
def test_create_with_name_and_args(self) -> None:
|
||||||
|
"""Test creating ExpectedMCPToolCall with name and args."""
|
||||||
|
tc = ExpectedMCPToolCall(tool_name="Calculator_Add", args={"a": 5, "b": 3})
|
||||||
|
|
||||||
|
assert tc.tool_name == "Calculator_Add"
|
||||||
|
assert tc.args == {"a": 5, "b": 3}
|
||||||
|
|
||||||
|
def test_create_with_empty_args(self) -> None:
|
||||||
|
"""Test creating ExpectedMCPToolCall with default empty args."""
|
||||||
|
tc = ExpectedMCPToolCall(tool_name="GetTime")
|
||||||
|
|
||||||
|
assert tc.tool_name == "GetTime"
|
||||||
|
assert tc.args == {}
|
||||||
|
|
||||||
|
def test_create_positional_args(self) -> None:
|
||||||
|
"""Test creating ExpectedMCPToolCall with positional args."""
|
||||||
|
tc = ExpectedMCPToolCall("Weather_Get", {"city": "NYC"})
|
||||||
|
|
||||||
|
assert tc.tool_name == "Weather_Get"
|
||||||
|
assert tc.args == {"city": "NYC"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestNamedExpectedToolCall:
|
||||||
|
"""Tests for NamedExpectedToolCall dataclass."""
|
||||||
|
|
||||||
|
def test_create(self) -> None:
|
||||||
|
"""Test creating NamedExpectedToolCall."""
|
||||||
|
tc = NamedExpectedToolCall(name="MyTool", args={"param": "value"})
|
||||||
|
|
||||||
|
assert tc.name == "MyTool"
|
||||||
|
assert tc.args == {"param": "value"}
|
||||||
|
|
||||||
|
def test_create_empty_args(self) -> None:
|
||||||
|
"""Test creating NamedExpectedToolCall with empty args."""
|
||||||
|
tc = NamedExpectedToolCall(name="SimpleTool", args={})
|
||||||
|
|
||||||
|
assert tc.name == "SimpleTool"
|
||||||
|
assert tc.args == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnyExpectedToolCallTypeAlias:
|
||||||
|
"""Tests for AnyExpectedToolCall type alias."""
|
||||||
|
|
||||||
|
def test_type_alias_accepts_expected_tool_call(self) -> None:
|
||||||
|
"""Test that ExpectedToolCall is valid for AnyExpectedToolCall."""
|
||||||
|
|
||||||
|
def my_func() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
tc: AnyExpectedToolCall = ExpectedToolCall(func=my_func)
|
||||||
|
assert isinstance(tc, ExpectedToolCall)
|
||||||
|
|
||||||
|
def test_type_alias_accepts_expected_mcp_tool_call(self) -> None:
|
||||||
|
"""Test that ExpectedMCPToolCall is valid for AnyExpectedToolCall."""
|
||||||
|
tc: AnyExpectedToolCall = ExpectedMCPToolCall(tool_name="Test")
|
||||||
|
assert isinstance(tc, ExpectedMCPToolCall)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvalRubric:
|
||||||
|
"""Tests for EvalRubric dataclass."""
|
||||||
|
|
||||||
|
def test_default_values(self) -> None:
|
||||||
|
"""Test EvalRubric has correct default values."""
|
||||||
|
rubric = EvalRubric()
|
||||||
|
|
||||||
|
assert rubric.fail_threshold == 0.8
|
||||||
|
assert rubric.warn_threshold == 0.9
|
||||||
|
assert rubric.fail_on_tool_selection is True
|
||||||
|
assert rubric.fail_on_tool_call_quantity is True
|
||||||
|
assert rubric.tool_selection_weight == 1.0
|
||||||
|
|
||||||
|
def test_custom_values(self) -> None:
|
||||||
|
"""Test EvalRubric with custom values."""
|
||||||
|
rubric = EvalRubric(
|
||||||
|
fail_threshold=0.7,
|
||||||
|
warn_threshold=0.85,
|
||||||
|
fail_on_tool_selection=False,
|
||||||
|
fail_on_tool_call_quantity=False,
|
||||||
|
tool_selection_weight=0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rubric.fail_threshold == 0.7
|
||||||
|
assert rubric.warn_threshold == 0.85
|
||||||
|
assert rubric.fail_on_tool_selection is False
|
||||||
|
assert rubric.fail_on_tool_call_quantity is False
|
||||||
|
assert rubric.tool_selection_weight == 0.5
|
||||||
|
|
||||||
|
def test_str_representation(self) -> None:
|
||||||
|
"""Test EvalRubric __str__ method."""
|
||||||
|
rubric = EvalRubric()
|
||||||
|
|
||||||
|
result = str(rubric)
|
||||||
|
|
||||||
|
assert "EvalRubric(" in result
|
||||||
|
assert "fail_threshold=0.8" in result
|
||||||
|
assert "warn_threshold=0.9" in result
|
||||||
|
assert "fail_on_tool_selection=True" in result
|
||||||
|
assert "fail_on_tool_call_quantity=True" in result
|
||||||
|
assert "tool_selection_weight=1.0" in result
|
||||||
|
|
||||||
|
def test_repr_representation(self) -> None:
|
||||||
|
"""Test EvalRubric __repr__ method returns same as __str__."""
|
||||||
|
rubric = EvalRubric(fail_threshold=0.75)
|
||||||
|
|
||||||
|
assert repr(rubric) == str(rubric)
|
||||||
|
|
||||||
|
def test_str_with_custom_values(self) -> None:
|
||||||
|
"""Test __str__ reflects custom values."""
|
||||||
|
rubric = EvalRubric(
|
||||||
|
fail_threshold=0.5,
|
||||||
|
warn_threshold=0.6,
|
||||||
|
fail_on_tool_selection=False,
|
||||||
|
fail_on_tool_call_quantity=False,
|
||||||
|
tool_selection_weight=2.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = str(rubric)
|
||||||
|
|
||||||
|
assert "fail_threshold=0.5" in result
|
||||||
|
assert "warn_threshold=0.6" in result
|
||||||
|
assert "fail_on_tool_selection=False" in result
|
||||||
|
assert "fail_on_tool_call_quantity=False" in result
|
||||||
|
assert "tool_selection_weight=2.0" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestTrackConfigFromTypes:
|
||||||
|
"""Tests for TrackConfig dataclass from _types module."""
|
||||||
|
|
||||||
|
def test_create_with_expected_tool_calls(self) -> None:
|
||||||
|
"""Test creating TrackConfig with expected tool calls."""
|
||||||
|
|
||||||
|
expected: list[ExpectedToolCall | ExpectedMCPToolCall] = [
|
||||||
|
ExpectedMCPToolCall("Tool1", {"arg": "val"})
|
||||||
|
]
|
||||||
|
config = TrackConfig(expected_tool_calls=expected)
|
||||||
|
|
||||||
|
assert config.expected_tool_calls == expected
|
||||||
|
assert config.critics == []
|
||||||
|
|
||||||
|
def test_create_with_critics(self) -> None:
|
||||||
|
"""Test creating TrackConfig with critics."""
|
||||||
|
from arcade_evals.critic import Critic, NoneCritic
|
||||||
|
|
||||||
|
expected: list[ExpectedToolCall | ExpectedMCPToolCall] = [ExpectedMCPToolCall("Tool1")]
|
||||||
|
critics: list[Critic] = [NoneCritic(critic_field="field1")]
|
||||||
|
config = TrackConfig(expected_tool_calls=expected, critics=critics)
|
||||||
|
|
||||||
|
assert config.expected_tool_calls == expected
|
||||||
|
assert config.critics == critics
|
||||||
|
|
||||||
|
def test_mixed_expected_tool_calls(self) -> None:
|
||||||
|
"""Test TrackConfig with mixed ExpectedToolCall and ExpectedMCPToolCall."""
|
||||||
|
|
||||||
|
def my_func() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
expected: list[ExpectedToolCall | ExpectedMCPToolCall] = [
|
||||||
|
ExpectedToolCall(func=my_func, args={"x": 1}),
|
||||||
|
ExpectedMCPToolCall(tool_name="MCPTool", args={"y": 2}),
|
||||||
|
]
|
||||||
|
config = TrackConfig(expected_tool_calls=expected)
|
||||||
|
|
||||||
|
assert len(config.expected_tool_calls) == 2
|
||||||
|
assert isinstance(config.expected_tool_calls[0], ExpectedToolCall)
|
||||||
|
assert isinstance(config.expected_tool_calls[1], ExpectedMCPToolCall)
|
||||||
|
|
||||||
|
|
||||||
|
class TestComparativeCaseFromTypes:
|
||||||
|
"""Tests for ComparativeCase dataclass from _types module."""
|
||||||
|
|
||||||
|
def test_default_values(self) -> None:
|
||||||
|
"""Test ComparativeCase default values."""
|
||||||
|
case = ComparativeCase(
|
||||||
|
name="test",
|
||||||
|
user_message="Hello",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert case.name == "test"
|
||||||
|
assert case.user_message == "Hello"
|
||||||
|
assert case.system_message == ""
|
||||||
|
assert case.additional_messages == []
|
||||||
|
assert case.rubric is None
|
||||||
|
assert case.track_configs == {}
|
||||||
|
|
||||||
|
def test_with_rubric(self) -> None:
|
||||||
|
"""Test ComparativeCase with custom rubric."""
|
||||||
|
rubric = EvalRubric(fail_threshold=0.9)
|
||||||
|
case = ComparativeCase(
|
||||||
|
name="test",
|
||||||
|
user_message="Hello",
|
||||||
|
rubric=rubric,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert case.rubric is rubric
|
||||||
|
|
||||||
|
def test_add_track_config(self) -> None:
|
||||||
|
"""Test adding track configuration."""
|
||||||
|
case = ComparativeCase(name="test", user_message="Hello")
|
||||||
|
expected: list[ExpectedToolCall | ExpectedMCPToolCall] = [
|
||||||
|
ExpectedMCPToolCall("Tool1", {"arg": "val"})
|
||||||
|
]
|
||||||
|
|
||||||
|
case.add_track_config("Track1", expected)
|
||||||
|
|
||||||
|
assert "Track1" in case.track_configs
|
||||||
|
assert case.track_configs["Track1"].expected_tool_calls == expected
|
||||||
|
|
||||||
|
def test_add_track_config_with_critics(self) -> None:
|
||||||
|
"""Test adding track config with critics."""
|
||||||
|
from arcade_evals.critic import Critic, NoneCritic
|
||||||
|
|
||||||
|
case = ComparativeCase(name="test", user_message="Hello")
|
||||||
|
expected: list[ExpectedToolCall | ExpectedMCPToolCall] = [ExpectedMCPToolCall("Tool1")]
|
||||||
|
critics: list[Critic] = [NoneCritic(critic_field="field")]
|
||||||
|
|
||||||
|
case.add_track_config("Track1", expected, critics=critics)
|
||||||
|
|
||||||
|
assert case.track_configs["Track1"].critics == critics
|
||||||
|
|
||||||
|
def test_add_duplicate_track_raises(self) -> None:
|
||||||
|
"""Test adding duplicate track config raises ValueError."""
|
||||||
|
case = ComparativeCase(name="test", user_message="Hello")
|
||||||
|
expected: list[ExpectedToolCall | ExpectedMCPToolCall] = [ExpectedMCPToolCall("Tool1")]
|
||||||
|
|
||||||
|
case.add_track_config("Track1", expected)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="already configured"):
|
||||||
|
case.add_track_config("Track1", expected)
|
||||||
|
|
||||||
|
def test_get_configured_tracks(self) -> None:
|
||||||
|
"""Test getting list of configured tracks."""
|
||||||
|
case = ComparativeCase(name="test", user_message="Hello")
|
||||||
|
|
||||||
|
assert case.get_configured_tracks() == []
|
||||||
|
|
||||||
|
track1_calls: list[ExpectedToolCall | ExpectedMCPToolCall] = [ExpectedMCPToolCall("T1")]
|
||||||
|
track2_calls: list[ExpectedToolCall | ExpectedMCPToolCall] = [ExpectedMCPToolCall("T2")]
|
||||||
|
case.add_track_config("Track1", track1_calls)
|
||||||
|
case.add_track_config("Track2", track2_calls)
|
||||||
|
|
||||||
|
tracks = case.get_configured_tracks()
|
||||||
|
|
||||||
|
assert "Track1" in tracks
|
||||||
|
assert "Track2" in tracks
|
||||||
|
assert len(tracks) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvalSuiteCreateEvalCase:
|
||||||
|
"""Tests for EvalSuite._create_eval_case factory method."""
|
||||||
|
|
||||||
|
def test_create_eval_case_basic(self) -> None:
|
||||||
|
"""Test creating EvalCase via factory method."""
|
||||||
|
from arcade_evals import EvalSuite
|
||||||
|
from arcade_evals._evalsuite._types import EvalRubric, NamedExpectedToolCall
|
||||||
|
|
||||||
|
suite = EvalSuite(name="Test", system_message="System")
|
||||||
|
|
||||||
|
case = suite._create_eval_case(
|
||||||
|
name="test_case",
|
||||||
|
system_message="Custom system",
|
||||||
|
user_message="Hello",
|
||||||
|
expected_tool_calls=[NamedExpectedToolCall(name="Tool1", args={"x": 1})],
|
||||||
|
rubric=EvalRubric(),
|
||||||
|
critics=[],
|
||||||
|
additional_messages=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert case.name == "test_case"
|
||||||
|
assert case.system_message == "Custom system"
|
||||||
|
assert case.user_message == "Hello"
|
||||||
|
assert len(case.expected_tool_calls) == 1
|
||||||
|
assert case.expected_tool_calls[0].name == "Tool1"
|
||||||
|
|
||||||
|
def test_create_eval_case_with_critics(self) -> None:
|
||||||
|
"""Test creating EvalCase with critics."""
|
||||||
|
from arcade_evals import EvalSuite
|
||||||
|
from arcade_evals._evalsuite._types import EvalRubric, NamedExpectedToolCall
|
||||||
|
from arcade_evals.critic import Critic, SimilarityCritic
|
||||||
|
|
||||||
|
suite = EvalSuite(name="Test", system_message="System")
|
||||||
|
critics: list[Critic] = [SimilarityCritic(critic_field="query", weight=1.0)]
|
||||||
|
|
||||||
|
case = suite._create_eval_case(
|
||||||
|
name="test_case",
|
||||||
|
system_message="System",
|
||||||
|
user_message="Query",
|
||||||
|
expected_tool_calls=[NamedExpectedToolCall(name="Search", args={"query": "test"})],
|
||||||
|
rubric=EvalRubric(),
|
||||||
|
critics=critics,
|
||||||
|
additional_messages=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert case.critics == critics
|
||||||
|
|
||||||
|
def test_create_eval_case_with_additional_messages(self) -> None:
|
||||||
|
"""Test creating EvalCase with additional messages."""
|
||||||
|
from arcade_evals import EvalSuite
|
||||||
|
from arcade_evals._evalsuite._types import EvalRubric
|
||||||
|
|
||||||
|
suite = EvalSuite(name="Test", system_message="System")
|
||||||
|
additional = [{"role": "assistant", "content": "Previous response"}]
|
||||||
|
|
||||||
|
case = suite._create_eval_case(
|
||||||
|
name="test_case",
|
||||||
|
system_message="System",
|
||||||
|
user_message="Follow-up",
|
||||||
|
expected_tool_calls=[],
|
||||||
|
rubric=EvalRubric(),
|
||||||
|
critics=[],
|
||||||
|
additional_messages=additional,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert case.additional_messages == additional
|
||||||
|
|
||||||
|
def test_create_eval_case_with_custom_rubric(self) -> None:
|
||||||
|
"""Test creating EvalCase with custom rubric."""
|
||||||
|
from arcade_evals import EvalSuite
|
||||||
|
from arcade_evals._evalsuite._types import EvalRubric
|
||||||
|
|
||||||
|
suite = EvalSuite(name="Test", system_message="System")
|
||||||
|
rubric = EvalRubric(fail_threshold=0.95, warn_threshold=0.98)
|
||||||
|
|
||||||
|
case = suite._create_eval_case(
|
||||||
|
name="test_case",
|
||||||
|
system_message="System",
|
||||||
|
user_message="Test",
|
||||||
|
expected_tool_calls=[],
|
||||||
|
rubric=rubric,
|
||||||
|
critics=[],
|
||||||
|
additional_messages=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert case.rubric.fail_threshold == 0.95
|
||||||
|
assert case.rubric.warn_threshold == 0.98
|
||||||
|
|
@ -8,7 +8,6 @@ from arcade_mcp_server.context import Context
|
||||||
from arcade_mcp_server.context import get_current_model_context as get_current_context
|
from arcade_mcp_server.context import get_current_model_context as get_current_context
|
||||||
from arcade_mcp_server.context import set_current_model_context as set_current_context
|
from arcade_mcp_server.context import set_current_model_context as set_current_context
|
||||||
from arcade_mcp_server.types import (
|
from arcade_mcp_server.types import (
|
||||||
MCPTool,
|
|
||||||
ModelHint,
|
ModelHint,
|
||||||
ModelPreferences,
|
ModelPreferences,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from unittest.mock import MagicMock, Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from arcade_core.catalog import MaterializedTool
|
from arcade_core.catalog import MaterializedTool
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"""Test that MCP routes appear in OpenAPI documentation."""
|
"""Test that MCP routes appear in OpenAPI documentation."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
from arcade_core import ToolCatalog
|
from arcade_core import ToolCatalog
|
||||||
from arcade_core.toolkit import Toolkit
|
from arcade_core.toolkit import Toolkit
|
||||||
from arcade_mcp_server.settings import MCPSettings
|
from arcade_mcp_server.settings import MCPSettings
|
||||||
|
|
@ -73,7 +72,6 @@ def test_mcp_routes_in_openapi(monkeypatch):
|
||||||
|
|
||||||
# Verify the actual proxy is mounted (not routes)
|
# Verify the actual proxy is mounted (not routes)
|
||||||
# The OpenAPI docs should exist but not interfere with the mount
|
# The OpenAPI docs should exist but not interfere with the mount
|
||||||
import inspect
|
|
||||||
|
|
||||||
mounts = [route for route in app.routes if hasattr(route, "app") and hasattr(route, "path")]
|
mounts = [route for route in app.routes if hasattr(route, "app") and hasattr(route, "path")]
|
||||||
mcp_mounts = [m for m in mounts if m.path == "/mcp"]
|
mcp_mounts = [m for m in mounts if m.path == "/mcp"]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"""Tests for MCP Settings."""
|
"""Tests for MCP Settings."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
from arcade_mcp_server.settings import MCPSettings, ServerSettings
|
from arcade_mcp_server.settings import MCPSettings, ServerSettings
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
from http import HTTPStatus
|
from unittest.mock import AsyncMock, patch
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from arcade_mcp_server.transports.http_session_manager import (
|
from arcade_mcp_server.transports.http_session_manager import (
|
||||||
MCP_SESSION_ID_HEADER,
|
MCP_SESSION_ID_HEADER,
|
||||||
HTTPSessionManager,
|
HTTPSessionManager,
|
||||||
)
|
)
|
||||||
from arcade_mcp_server.transports.http_streamable import HTTPStreamableTransport
|
|
||||||
|
|
||||||
|
|
||||||
class TestHTTPSessionManager:
|
class TestHTTPSessionManager:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import json
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import base64
|
||||||
import io
|
import io
|
||||||
import subprocess
|
import subprocess
|
||||||
import tarfile
|
import tarfile
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
|
||||||
926
libs/tests/cli/test_capture_formatters.py
Normal file
926
libs/tests/cli/test_capture_formatters.py
Normal file
|
|
@ -0,0 +1,926 @@
|
||||||
|
"""Tests for capture mode formatters."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_cli.formatters import (
|
||||||
|
CAPTURE_FORMATTERS,
|
||||||
|
CaptureHtmlFormatter,
|
||||||
|
CaptureJsonFormatter,
|
||||||
|
CaptureMarkdownFormatter,
|
||||||
|
CaptureTextFormatter,
|
||||||
|
get_capture_formatter,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from arcade_evals import CaptureResult
|
||||||
|
|
||||||
|
|
||||||
|
def _create_mock_capture_result(
|
||||||
|
suite_name: str = "TestSuite",
|
||||||
|
model: str = "gpt-4o",
|
||||||
|
provider: str = "openai",
|
||||||
|
cases: list[dict] | None = None,
|
||||||
|
) -> CaptureResult:
|
||||||
|
"""Create a mock CaptureResult for testing."""
|
||||||
|
if cases is None:
|
||||||
|
cases = [
|
||||||
|
{
|
||||||
|
"case_name": "test_case_1",
|
||||||
|
"user_message": "What's the weather?",
|
||||||
|
"tool_calls": [
|
||||||
|
{"name": "GetWeather", "args": {"city": "NYC", "units": "celsius"}},
|
||||||
|
],
|
||||||
|
"system_message": "You are helpful",
|
||||||
|
"additional_messages": [{"role": "user", "content": "Hi"}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Create mock capture result
|
||||||
|
capture = MagicMock()
|
||||||
|
capture.suite_name = suite_name
|
||||||
|
capture.model = model
|
||||||
|
capture.provider = provider
|
||||||
|
|
||||||
|
# Create mock captured cases
|
||||||
|
captured_cases = []
|
||||||
|
for case_data in cases:
|
||||||
|
case = MagicMock()
|
||||||
|
case.case_name = case_data["case_name"]
|
||||||
|
case.user_message = case_data["user_message"]
|
||||||
|
case.system_message = case_data.get("system_message")
|
||||||
|
case.additional_messages = case_data.get("additional_messages", [])
|
||||||
|
# Explicitly set track_name to None unless specified (avoids MagicMock)
|
||||||
|
case.track_name = case_data.get("track_name")
|
||||||
|
|
||||||
|
# Create mock tool calls
|
||||||
|
tool_calls = []
|
||||||
|
for tc_data in case_data.get("tool_calls", []):
|
||||||
|
tc = MagicMock()
|
||||||
|
tc.name = tc_data["name"]
|
||||||
|
tc.args = tc_data.get("args", {})
|
||||||
|
tool_calls.append(tc)
|
||||||
|
case.tool_calls = tool_calls
|
||||||
|
|
||||||
|
captured_cases.append(case)
|
||||||
|
|
||||||
|
capture.captured_cases = captured_cases
|
||||||
|
|
||||||
|
# Mock to_dict method
|
||||||
|
def to_dict(include_context: bool = False) -> dict:
|
||||||
|
result = {
|
||||||
|
"suite_name": capture.suite_name,
|
||||||
|
"model": capture.model,
|
||||||
|
"provider": capture.provider,
|
||||||
|
"captured_cases": [],
|
||||||
|
}
|
||||||
|
for case in captured_cases:
|
||||||
|
case_dict = {
|
||||||
|
"case_name": case.case_name,
|
||||||
|
"user_message": case.user_message,
|
||||||
|
"tool_calls": [{"name": tc.name, "args": tc.args} for tc in case.tool_calls],
|
||||||
|
}
|
||||||
|
if include_context:
|
||||||
|
case_dict["system_message"] = case.system_message
|
||||||
|
case_dict["additional_messages"] = case.additional_messages
|
||||||
|
result["captured_cases"].append(case_dict)
|
||||||
|
return result
|
||||||
|
|
||||||
|
capture.to_dict = to_dict
|
||||||
|
|
||||||
|
return capture
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCaptureFormatter:
|
||||||
|
"""Tests for get_capture_formatter function."""
|
||||||
|
|
||||||
|
def test_get_json_formatter(self) -> None:
|
||||||
|
"""Test getting JSON formatter."""
|
||||||
|
formatter = get_capture_formatter("json")
|
||||||
|
assert isinstance(formatter, CaptureJsonFormatter)
|
||||||
|
|
||||||
|
def test_get_txt_formatter(self) -> None:
|
||||||
|
"""Test getting text formatter."""
|
||||||
|
formatter = get_capture_formatter("txt")
|
||||||
|
assert isinstance(formatter, CaptureTextFormatter)
|
||||||
|
|
||||||
|
def test_get_md_formatter(self) -> None:
|
||||||
|
"""Test getting markdown formatter."""
|
||||||
|
formatter = get_capture_formatter("md")
|
||||||
|
assert isinstance(formatter, CaptureMarkdownFormatter)
|
||||||
|
|
||||||
|
def test_get_html_formatter(self) -> None:
|
||||||
|
"""Test getting HTML formatter."""
|
||||||
|
formatter = get_capture_formatter("html")
|
||||||
|
assert isinstance(formatter, CaptureHtmlFormatter)
|
||||||
|
|
||||||
|
def test_case_insensitive(self) -> None:
|
||||||
|
"""Test that format names are case insensitive."""
|
||||||
|
assert isinstance(get_capture_formatter("JSON"), CaptureJsonFormatter)
|
||||||
|
assert isinstance(get_capture_formatter("TXT"), CaptureTextFormatter)
|
||||||
|
assert isinstance(get_capture_formatter("MD"), CaptureMarkdownFormatter)
|
||||||
|
assert isinstance(get_capture_formatter("HTML"), CaptureHtmlFormatter)
|
||||||
|
|
||||||
|
def test_unsupported_format_raises(self) -> None:
|
||||||
|
"""Test that unsupported formats raise ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Unsupported capture format 'xlsx'"):
|
||||||
|
get_capture_formatter("xlsx")
|
||||||
|
|
||||||
|
def test_close_match_suggestion(self) -> None:
|
||||||
|
"""Test that close matches are suggested."""
|
||||||
|
with pytest.raises(ValueError, match="Did you mean 'json'"):
|
||||||
|
get_capture_formatter("jsn")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptureJsonFormatter:
|
||||||
|
"""Tests for CaptureJsonFormatter."""
|
||||||
|
|
||||||
|
def test_file_extension(self) -> None:
|
||||||
|
"""Test file extension is json."""
|
||||||
|
formatter = CaptureJsonFormatter()
|
||||||
|
assert formatter.file_extension == "json"
|
||||||
|
|
||||||
|
def test_format_basic(self) -> None:
|
||||||
|
"""Test basic JSON formatting."""
|
||||||
|
formatter = CaptureJsonFormatter()
|
||||||
|
capture = _create_mock_capture_result()
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
parsed = json.loads(output)
|
||||||
|
|
||||||
|
assert "captures" in parsed
|
||||||
|
assert len(parsed["captures"]) == 1
|
||||||
|
assert parsed["captures"][0]["suite_name"] == "TestSuite"
|
||||||
|
assert parsed["captures"][0]["model"] == "gpt-4o"
|
||||||
|
|
||||||
|
def test_format_includes_tool_calls(self) -> None:
|
||||||
|
"""Test that tool calls are included."""
|
||||||
|
formatter = CaptureJsonFormatter()
|
||||||
|
capture = _create_mock_capture_result()
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
parsed = json.loads(output)
|
||||||
|
|
||||||
|
case = parsed["captures"][0]["captured_cases"][0]
|
||||||
|
assert len(case["tool_calls"]) == 1
|
||||||
|
assert case["tool_calls"][0]["name"] == "GetWeather"
|
||||||
|
assert case["tool_calls"][0]["args"]["city"] == "NYC"
|
||||||
|
|
||||||
|
def test_format_with_context(self) -> None:
|
||||||
|
"""Test formatting with context included."""
|
||||||
|
formatter = CaptureJsonFormatter()
|
||||||
|
capture = _create_mock_capture_result()
|
||||||
|
|
||||||
|
output = formatter.format([capture], include_context=True)
|
||||||
|
parsed = json.loads(output)
|
||||||
|
|
||||||
|
case = parsed["captures"][0]["captured_cases"][0]
|
||||||
|
assert "system_message" in case
|
||||||
|
assert case["system_message"] == "You are helpful"
|
||||||
|
|
||||||
|
def test_format_without_context(self) -> None:
|
||||||
|
"""Test formatting without context (default)."""
|
||||||
|
formatter = CaptureJsonFormatter()
|
||||||
|
capture = _create_mock_capture_result()
|
||||||
|
|
||||||
|
output = formatter.format([capture], include_context=False)
|
||||||
|
parsed = json.loads(output)
|
||||||
|
|
||||||
|
case = parsed["captures"][0]["captured_cases"][0]
|
||||||
|
assert "system_message" not in case
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptureTextFormatter:
|
||||||
|
"""Tests for CaptureTextFormatter."""
|
||||||
|
|
||||||
|
def test_file_extension(self) -> None:
|
||||||
|
"""Test file extension is txt."""
|
||||||
|
formatter = CaptureTextFormatter()
|
||||||
|
assert formatter.file_extension == "txt"
|
||||||
|
|
||||||
|
def test_format_contains_suite_info(self) -> None:
|
||||||
|
"""Test that suite info is in output."""
|
||||||
|
formatter = CaptureTextFormatter()
|
||||||
|
capture = _create_mock_capture_result()
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
assert "Suite: TestSuite" in output
|
||||||
|
assert "Model: gpt-4o" in output
|
||||||
|
assert "Provider: openai" in output
|
||||||
|
|
||||||
|
def test_format_contains_case_info(self) -> None:
|
||||||
|
"""Test that case info is in output."""
|
||||||
|
formatter = CaptureTextFormatter()
|
||||||
|
capture = _create_mock_capture_result()
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
assert "Case: test_case_1" in output
|
||||||
|
assert "User Message: What's the weather?" in output
|
||||||
|
|
||||||
|
def test_format_contains_tool_calls(self) -> None:
|
||||||
|
"""Test that tool calls are in output."""
|
||||||
|
formatter = CaptureTextFormatter()
|
||||||
|
capture = _create_mock_capture_result()
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
assert "GetWeather" in output
|
||||||
|
assert "city: NYC" in output
|
||||||
|
|
||||||
|
def test_format_contains_summary(self) -> None:
|
||||||
|
"""Test that summary is in output."""
|
||||||
|
formatter = CaptureTextFormatter()
|
||||||
|
capture = _create_mock_capture_result()
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
assert "Summary: 1 tool calls across 1 cases" in output
|
||||||
|
|
||||||
|
def test_format_with_context(self) -> None:
|
||||||
|
"""Test formatting with context."""
|
||||||
|
formatter = CaptureTextFormatter()
|
||||||
|
capture = _create_mock_capture_result()
|
||||||
|
|
||||||
|
output = formatter.format([capture], include_context=True)
|
||||||
|
|
||||||
|
assert "System Message: You are helpful" in output
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptureMarkdownFormatter:
|
||||||
|
"""Tests for CaptureMarkdownFormatter."""
|
||||||
|
|
||||||
|
def test_file_extension(self) -> None:
|
||||||
|
"""Test file extension is md."""
|
||||||
|
formatter = CaptureMarkdownFormatter()
|
||||||
|
assert formatter.file_extension == "md"
|
||||||
|
|
||||||
|
def test_format_has_heading(self) -> None:
|
||||||
|
"""Test that markdown has main heading."""
|
||||||
|
formatter = CaptureMarkdownFormatter()
|
||||||
|
capture = _create_mock_capture_result()
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
assert "# Capture Results" in output
|
||||||
|
|
||||||
|
def test_format_has_suite_heading(self) -> None:
|
||||||
|
"""Test that suite has heading."""
|
||||||
|
formatter = CaptureMarkdownFormatter()
|
||||||
|
capture = _create_mock_capture_result()
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
assert "## TestSuite" in output
|
||||||
|
|
||||||
|
def test_format_has_case_heading(self) -> None:
|
||||||
|
"""Test that case has heading."""
|
||||||
|
formatter = CaptureMarkdownFormatter()
|
||||||
|
capture = _create_mock_capture_result()
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
assert "### Case: test_case_1" in output
|
||||||
|
|
||||||
|
def test_format_has_code_blocks(self) -> None:
|
||||||
|
"""Test that tool args are in code blocks."""
|
||||||
|
formatter = CaptureMarkdownFormatter()
|
||||||
|
capture = _create_mock_capture_result()
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
assert "```json" in output
|
||||||
|
assert '"city": "NYC"' in output
|
||||||
|
assert "```" in output
|
||||||
|
|
||||||
|
def test_format_has_summary(self) -> None:
|
||||||
|
"""Test that summary is present."""
|
||||||
|
formatter = CaptureMarkdownFormatter()
|
||||||
|
capture = _create_mock_capture_result()
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
assert "## Summary" in output
|
||||||
|
assert "**Total Cases:** 1" in output
|
||||||
|
assert "**Total Tool Calls:** 1" in output
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptureHtmlFormatter:
|
||||||
|
"""Tests for CaptureHtmlFormatter."""
|
||||||
|
|
||||||
|
def test_file_extension(self) -> None:
|
||||||
|
"""Test file extension is html."""
|
||||||
|
formatter = CaptureHtmlFormatter()
|
||||||
|
assert formatter.file_extension == "html"
|
||||||
|
|
||||||
|
def test_format_is_valid_html(self) -> None:
|
||||||
|
"""Test that output is valid HTML structure."""
|
||||||
|
formatter = CaptureHtmlFormatter()
|
||||||
|
capture = _create_mock_capture_result()
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
assert "<!DOCTYPE html>" in output
|
||||||
|
assert "<html" in output
|
||||||
|
assert "</html>" in output
|
||||||
|
assert "<head>" in output
|
||||||
|
assert "</head>" in output
|
||||||
|
assert "<body>" in output
|
||||||
|
assert "</body>" in output
|
||||||
|
|
||||||
|
def test_format_contains_styles(self) -> None:
|
||||||
|
"""Test that CSS styles are included."""
|
||||||
|
formatter = CaptureHtmlFormatter()
|
||||||
|
capture = _create_mock_capture_result()
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
assert "<style>" in output
|
||||||
|
assert "</style>" in output
|
||||||
|
|
||||||
|
def test_format_contains_suite_info(self) -> None:
|
||||||
|
"""Test that suite info is in output."""
|
||||||
|
formatter = CaptureHtmlFormatter()
|
||||||
|
capture = _create_mock_capture_result()
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
assert "TestSuite" in output
|
||||||
|
assert "gpt-4o" in output
|
||||||
|
|
||||||
|
def test_format_contains_tool_calls(self) -> None:
|
||||||
|
"""Test that tool calls are in output."""
|
||||||
|
formatter = CaptureHtmlFormatter()
|
||||||
|
capture = _create_mock_capture_result()
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
assert "GetWeather" in output
|
||||||
|
# Args should be HTML-escaped
|
||||||
|
assert ""city"" in output or '"city"' in output
|
||||||
|
|
||||||
|
def test_format_escapes_html(self) -> None:
|
||||||
|
"""Test that HTML special characters are escaped."""
|
||||||
|
formatter = CaptureHtmlFormatter()
|
||||||
|
capture = _create_mock_capture_result(
|
||||||
|
cases=[
|
||||||
|
{
|
||||||
|
"case_name": "Test <script>",
|
||||||
|
"user_message": "Hello & Goodbye",
|
||||||
|
"tool_calls": [],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
# Angle brackets should be escaped
|
||||||
|
assert "<script>" in output
|
||||||
|
# Ampersand should be escaped
|
||||||
|
assert "&" in output
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptureFormattersRegistry:
|
||||||
|
"""Tests for the CAPTURE_FORMATTERS registry."""
|
||||||
|
|
||||||
|
def test_all_formats_registered(self) -> None:
|
||||||
|
"""Test that all expected formats are registered."""
|
||||||
|
assert "json" in CAPTURE_FORMATTERS
|
||||||
|
assert "txt" in CAPTURE_FORMATTERS
|
||||||
|
assert "md" in CAPTURE_FORMATTERS
|
||||||
|
assert "html" in CAPTURE_FORMATTERS
|
||||||
|
|
||||||
|
def test_registry_returns_correct_types(self) -> None:
|
||||||
|
"""Test that registry maps to correct formatter types."""
|
||||||
|
assert CAPTURE_FORMATTERS["json"] == CaptureJsonFormatter
|
||||||
|
assert CAPTURE_FORMATTERS["txt"] == CaptureTextFormatter
|
||||||
|
assert CAPTURE_FORMATTERS["md"] == CaptureMarkdownFormatter
|
||||||
|
assert CAPTURE_FORMATTERS["html"] == CaptureHtmlFormatter
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptureFormatterEdgeCases:
|
||||||
|
"""Tests for edge cases in capture formatting."""
|
||||||
|
|
||||||
|
def test_empty_captures_list(self) -> None:
|
||||||
|
"""Test formatting with empty captures list."""
|
||||||
|
for formatter in [
|
||||||
|
CaptureJsonFormatter(),
|
||||||
|
CaptureTextFormatter(),
|
||||||
|
CaptureMarkdownFormatter(),
|
||||||
|
CaptureHtmlFormatter(),
|
||||||
|
]:
|
||||||
|
output = formatter.format([])
|
||||||
|
assert output # Should produce some output
|
||||||
|
|
||||||
|
def test_case_with_no_tool_calls(self) -> None:
|
||||||
|
"""Test formatting a case with no tool calls."""
|
||||||
|
capture = _create_mock_capture_result(
|
||||||
|
cases=[
|
||||||
|
{
|
||||||
|
"case_name": "empty_case",
|
||||||
|
"user_message": "Hello",
|
||||||
|
"tool_calls": [],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
for formatter in [
|
||||||
|
CaptureJsonFormatter(),
|
||||||
|
CaptureTextFormatter(),
|
||||||
|
CaptureMarkdownFormatter(),
|
||||||
|
CaptureHtmlFormatter(),
|
||||||
|
]:
|
||||||
|
output = formatter.format([capture])
|
||||||
|
assert output # Should produce some output
|
||||||
|
|
||||||
|
def test_multiple_captures(self) -> None:
|
||||||
|
"""Test formatting multiple capture results."""
|
||||||
|
capture1 = _create_mock_capture_result(suite_name="Suite1", model="gpt-4o")
|
||||||
|
capture2 = _create_mock_capture_result(suite_name="Suite2", model="claude-3")
|
||||||
|
|
||||||
|
for formatter in [
|
||||||
|
CaptureJsonFormatter(),
|
||||||
|
CaptureTextFormatter(),
|
||||||
|
CaptureMarkdownFormatter(),
|
||||||
|
CaptureHtmlFormatter(),
|
||||||
|
]:
|
||||||
|
output = formatter.format([capture1, capture2])
|
||||||
|
assert "Suite1" in output
|
||||||
|
assert "Suite2" in output
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultiModelCaptureFormatting:
|
||||||
|
"""Tests for multi-model capture formatting."""
|
||||||
|
|
||||||
|
def test_markdown_multi_model_detection(self) -> None:
|
||||||
|
"""Test that markdown formatter detects multi-model and groups by case."""
|
||||||
|
# Same suite, same case, different models
|
||||||
|
capture1 = _create_mock_capture_result(
|
||||||
|
suite_name="TestSuite",
|
||||||
|
model="gpt-4o",
|
||||||
|
cases=[
|
||||||
|
{
|
||||||
|
"case_name": "shared_case",
|
||||||
|
"user_message": "What's the weather?",
|
||||||
|
"tool_calls": [{"name": "GetWeather", "args": {"city": "NYC"}}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
capture2 = _create_mock_capture_result(
|
||||||
|
suite_name="TestSuite",
|
||||||
|
model="gpt-4-turbo",
|
||||||
|
cases=[
|
||||||
|
{
|
||||||
|
"case_name": "shared_case",
|
||||||
|
"user_message": "What's the weather?",
|
||||||
|
"tool_calls": [{"name": "GetWeather", "args": {"city": "New York"}}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
formatter = CaptureMarkdownFormatter()
|
||||||
|
output = formatter.format([capture1, capture2])
|
||||||
|
|
||||||
|
# Should detect multi-model and show comparison
|
||||||
|
assert "Multi-Model" in output
|
||||||
|
assert "gpt-4o" in output
|
||||||
|
assert "gpt-4-turbo" in output
|
||||||
|
assert "shared_case" in output
|
||||||
|
# Should show models comparison table
|
||||||
|
assert "| Model |" in output
|
||||||
|
|
||||||
|
def test_markdown_single_model_format(self) -> None:
|
||||||
|
"""Test that single-model captures use the simple format."""
|
||||||
|
capture = _create_mock_capture_result(suite_name="Suite", model="gpt-4o")
|
||||||
|
|
||||||
|
formatter = CaptureMarkdownFormatter()
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
# Should NOT have multi-model header
|
||||||
|
assert "Multi-Model" not in output
|
||||||
|
# Should have regular header
|
||||||
|
assert "# Capture Results" in output
|
||||||
|
|
||||||
|
def test_multi_model_tool_calls_grouped(self) -> None:
|
||||||
|
"""Test that tool calls are grouped by case in multi-model output."""
|
||||||
|
capture1 = _create_mock_capture_result(
|
||||||
|
suite_name="Suite",
|
||||||
|
model="model-a",
|
||||||
|
cases=[
|
||||||
|
{
|
||||||
|
"case_name": "case1",
|
||||||
|
"user_message": "Do something",
|
||||||
|
"tool_calls": [{"name": "ToolA", "args": {"x": 1}}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
capture2 = _create_mock_capture_result(
|
||||||
|
suite_name="Suite",
|
||||||
|
model="model-b",
|
||||||
|
cases=[
|
||||||
|
{
|
||||||
|
"case_name": "case1",
|
||||||
|
"user_message": "Do something",
|
||||||
|
"tool_calls": [{"name": "ToolA", "args": {"x": 2}}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
formatter = CaptureMarkdownFormatter()
|
||||||
|
output = formatter.format([capture1, capture2])
|
||||||
|
|
||||||
|
# Both models should appear for the same case
|
||||||
|
assert "model-a" in output
|
||||||
|
assert "model-b" in output
|
||||||
|
# Tool details should be in collapsible sections
|
||||||
|
assert "<details>" in output
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultiModelHelpers:
|
||||||
|
"""Tests for multi-model helper functions in base.py."""
|
||||||
|
|
||||||
|
def test_is_multi_model_capture_true(self) -> None:
|
||||||
|
"""Test detection of multiple models in captures."""
|
||||||
|
from arcade_cli.formatters.base import is_multi_model_capture
|
||||||
|
|
||||||
|
capture1 = _create_mock_capture_result(model="gpt-4o")
|
||||||
|
capture2 = _create_mock_capture_result(model="gpt-4-turbo")
|
||||||
|
|
||||||
|
assert is_multi_model_capture([capture1, capture2]) is True
|
||||||
|
|
||||||
|
def test_is_multi_model_capture_false(self) -> None:
|
||||||
|
"""Test single model detection."""
|
||||||
|
from arcade_cli.formatters.base import is_multi_model_capture
|
||||||
|
|
||||||
|
capture1 = _create_mock_capture_result(model="gpt-4o")
|
||||||
|
capture2 = _create_mock_capture_result(model="gpt-4o")
|
||||||
|
|
||||||
|
assert is_multi_model_capture([capture1, capture2]) is False
|
||||||
|
|
||||||
|
def test_group_captures_by_case(self) -> None:
|
||||||
|
"""Test grouping captures by case for comparison."""
|
||||||
|
from arcade_cli.formatters.base import group_captures_by_case
|
||||||
|
|
||||||
|
capture1 = _create_mock_capture_result(
|
||||||
|
suite_name="Suite",
|
||||||
|
model="model-a",
|
||||||
|
cases=[
|
||||||
|
{"case_name": "case1", "user_message": "msg1", "tool_calls": []},
|
||||||
|
{"case_name": "case2", "user_message": "msg2", "tool_calls": []},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
capture2 = _create_mock_capture_result(
|
||||||
|
suite_name="Suite",
|
||||||
|
model="model-b",
|
||||||
|
cases=[
|
||||||
|
{"case_name": "case1", "user_message": "msg1", "tool_calls": []},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
grouped, model_order = group_captures_by_case([capture1, capture2])
|
||||||
|
|
||||||
|
# Check structure
|
||||||
|
assert "Suite" in grouped
|
||||||
|
assert "case1" in grouped["Suite"]
|
||||||
|
assert "case2" in grouped["Suite"]
|
||||||
|
|
||||||
|
# Check model order
|
||||||
|
assert model_order == ["model-a", "model-b"]
|
||||||
|
|
||||||
|
# Check case1 has both models
|
||||||
|
assert "model-a" in grouped["Suite"]["case1"]["models"]
|
||||||
|
assert "model-b" in grouped["Suite"]["case1"]["models"]
|
||||||
|
|
||||||
|
# Check case2 only has model-a
|
||||||
|
assert "model-a" in grouped["Suite"]["case2"]["models"]
|
||||||
|
assert "model-b" not in grouped["Suite"]["case2"]["models"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultiModelTextCaptureFormatter:
|
||||||
|
"""Tests for multi-model text capture formatting."""
|
||||||
|
|
||||||
|
def test_text_multi_model_output(self) -> None:
|
||||||
|
"""Should produce multi-model text output."""
|
||||||
|
capture1 = _create_mock_capture_result(
|
||||||
|
suite_name="TestSuite", model="gpt-4o", cases=[
|
||||||
|
{"case_name": "case1", "user_message": "Hi", "tool_calls": [{"name": "Tool1", "args": {}}]}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
capture2 = _create_mock_capture_result(
|
||||||
|
suite_name="TestSuite", model="gpt-4-turbo", cases=[
|
||||||
|
{"case_name": "case1", "user_message": "Hi", "tool_calls": [{"name": "Tool2", "args": {}}]}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
formatter = CaptureTextFormatter()
|
||||||
|
output = formatter.format([capture1, capture2])
|
||||||
|
|
||||||
|
# Should have multi-model header
|
||||||
|
assert "MULTI-MODEL CAPTURE RESULTS" in output
|
||||||
|
|
||||||
|
# Should list both models
|
||||||
|
assert "gpt-4o" in output
|
||||||
|
assert "gpt-4-turbo" in output
|
||||||
|
|
||||||
|
# Should show case name
|
||||||
|
assert "case1" in output
|
||||||
|
|
||||||
|
def test_text_single_model_regular_format(self) -> None:
|
||||||
|
"""Should use regular format for single model."""
|
||||||
|
capture = _create_mock_capture_result(model="gpt-4o")
|
||||||
|
|
||||||
|
formatter = CaptureTextFormatter()
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
# Should NOT have multi-model header
|
||||||
|
assert "MULTI-MODEL CAPTURE RESULTS" not in output
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultiModelHtmlCaptureFormatter:
|
||||||
|
"""Tests for multi-model HTML capture formatting."""
|
||||||
|
|
||||||
|
def test_html_multi_model_output(self) -> None:
|
||||||
|
"""Should produce multi-model HTML output."""
|
||||||
|
capture1 = _create_mock_capture_result(
|
||||||
|
suite_name="TestSuite", model="gpt-4o", cases=[
|
||||||
|
{"case_name": "case1", "user_message": "Hi", "tool_calls": [{"name": "Tool1", "args": {}}]}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
capture2 = _create_mock_capture_result(
|
||||||
|
suite_name="TestSuite", model="gpt-4-turbo", cases=[
|
||||||
|
{"case_name": "case1", "user_message": "Hi", "tool_calls": [{"name": "Tool2", "args": {}}]}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
formatter = CaptureHtmlFormatter()
|
||||||
|
output = formatter.format([capture1, capture2])
|
||||||
|
|
||||||
|
# Should have multi-model title
|
||||||
|
assert "Multi-Model Capture Results" in output
|
||||||
|
|
||||||
|
# Should list models
|
||||||
|
assert "gpt-4o" in output
|
||||||
|
assert "gpt-4-turbo" in output
|
||||||
|
|
||||||
|
# Should have model panels
|
||||||
|
assert "model-panel" in output
|
||||||
|
|
||||||
|
def test_html_single_model_regular_format(self) -> None:
|
||||||
|
"""Should use regular format for single model."""
|
||||||
|
capture = _create_mock_capture_result(model="gpt-4o")
|
||||||
|
|
||||||
|
formatter = CaptureHtmlFormatter()
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
# Should NOT have multi-model title
|
||||||
|
assert "Multi-Model Capture Results" not in output
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultiModelJsonCaptureFormatter:
|
||||||
|
"""Tests for multi-model JSON capture formatting."""
|
||||||
|
|
||||||
|
def test_json_multi_model_output(self) -> None:
|
||||||
|
"""Should produce structured multi-model JSON."""
|
||||||
|
capture1 = _create_mock_capture_result(
|
||||||
|
suite_name="TestSuite", model="gpt-4o", cases=[
|
||||||
|
{"case_name": "case1", "user_message": "Hi", "tool_calls": [{"name": "Tool1", "args": {}}]}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
capture2 = _create_mock_capture_result(
|
||||||
|
suite_name="TestSuite", model="gpt-4-turbo", cases=[
|
||||||
|
{"case_name": "case1", "user_message": "Hi", "tool_calls": [{"name": "Tool2", "args": {}}]}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
formatter = CaptureJsonFormatter()
|
||||||
|
output = formatter.format([capture1, capture2])
|
||||||
|
|
||||||
|
data = json.loads(output)
|
||||||
|
|
||||||
|
# Should have multi-model type
|
||||||
|
assert data["type"] == "multi_model_capture"
|
||||||
|
|
||||||
|
# Should have models list
|
||||||
|
assert "models" in data
|
||||||
|
assert "gpt-4o" in data["models"]
|
||||||
|
assert "gpt-4-turbo" in data["models"]
|
||||||
|
|
||||||
|
# Should have grouped_by_case structure
|
||||||
|
assert "grouped_by_case" in data
|
||||||
|
assert "TestSuite" in data["grouped_by_case"]
|
||||||
|
assert "case1" in data["grouped_by_case"]["TestSuite"]
|
||||||
|
|
||||||
|
def test_json_single_model_regular_format(self) -> None:
|
||||||
|
"""Should use regular format for single model."""
|
||||||
|
capture = _create_mock_capture_result(model="gpt-4o")
|
||||||
|
|
||||||
|
formatter = CaptureJsonFormatter()
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
data = json.loads(output)
|
||||||
|
|
||||||
|
# Should have capture type
|
||||||
|
assert data["type"] == "capture"
|
||||||
|
# Should not have grouped_by_case
|
||||||
|
assert "grouped_by_case" not in data
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CAPTURE WITH TRACKS TESTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _create_mock_capture_with_tracks(
|
||||||
|
suite_name: str = "ComparativeSuite",
|
||||||
|
model: str = "gpt-4o",
|
||||||
|
provider: str = "openai",
|
||||||
|
) -> CaptureResult:
|
||||||
|
"""Create a mock CaptureResult with track information for testing."""
|
||||||
|
cases = [
|
||||||
|
{
|
||||||
|
"case_name": "weather_case",
|
||||||
|
"user_message": "What's the weather in NYC?",
|
||||||
|
"tool_calls": [
|
||||||
|
{"name": "get_weather_v1", "args": {"city": "NYC"}},
|
||||||
|
],
|
||||||
|
"track_name": "track_a",
|
||||||
|
"system_message": "You are a weather assistant",
|
||||||
|
"additional_messages": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case_name": "weather_case",
|
||||||
|
"user_message": "What's the weather in NYC?",
|
||||||
|
"tool_calls": [
|
||||||
|
{"name": "fetch_weather", "args": {"location": "NYC"}},
|
||||||
|
],
|
||||||
|
"track_name": "track_b",
|
||||||
|
"system_message": "You are a weather assistant",
|
||||||
|
"additional_messages": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case_name": "regular_case",
|
||||||
|
"user_message": "Hello world",
|
||||||
|
"tool_calls": [
|
||||||
|
{"name": "greet", "args": {}},
|
||||||
|
],
|
||||||
|
"track_name": None, # Regular case without track
|
||||||
|
"system_message": None,
|
||||||
|
"additional_messages": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
capture = MagicMock()
|
||||||
|
capture.suite_name = suite_name
|
||||||
|
capture.model = model
|
||||||
|
capture.provider = provider
|
||||||
|
|
||||||
|
captured_cases = []
|
||||||
|
for case_data in cases:
|
||||||
|
mock_case = MagicMock()
|
||||||
|
mock_case.case_name = case_data["case_name"]
|
||||||
|
mock_case.user_message = case_data["user_message"]
|
||||||
|
mock_case.system_message = case_data["system_message"]
|
||||||
|
mock_case.additional_messages = case_data["additional_messages"]
|
||||||
|
mock_case.track_name = case_data["track_name"]
|
||||||
|
|
||||||
|
mock_tool_calls = []
|
||||||
|
for tc in case_data["tool_calls"]:
|
||||||
|
mock_tc = MagicMock()
|
||||||
|
mock_tc.name = tc["name"]
|
||||||
|
mock_tc.args = tc["args"]
|
||||||
|
mock_tool_calls.append(mock_tc)
|
||||||
|
mock_case.tool_calls = mock_tool_calls
|
||||||
|
|
||||||
|
captured_cases.append(mock_case)
|
||||||
|
|
||||||
|
capture.captured_cases = captured_cases
|
||||||
|
|
||||||
|
def to_dict(include_context: bool = False) -> dict:
|
||||||
|
result = {
|
||||||
|
"suite_name": capture.suite_name,
|
||||||
|
"model": capture.model,
|
||||||
|
"provider": capture.provider,
|
||||||
|
"captured_cases": [],
|
||||||
|
}
|
||||||
|
for case in capture.captured_cases:
|
||||||
|
case_dict = {
|
||||||
|
"case_name": case.case_name,
|
||||||
|
"user_message": case.user_message,
|
||||||
|
"tool_calls": [{"name": tc.name, "args": tc.args} for tc in case.tool_calls],
|
||||||
|
}
|
||||||
|
if case.track_name:
|
||||||
|
case_dict["track_name"] = case.track_name
|
||||||
|
if include_context:
|
||||||
|
case_dict["system_message"] = case.system_message
|
||||||
|
case_dict["additional_messages"] = case.additional_messages
|
||||||
|
result["captured_cases"].append(case_dict)
|
||||||
|
return result
|
||||||
|
|
||||||
|
capture.to_dict = to_dict
|
||||||
|
return capture
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptureWithTracks:
|
||||||
|
"""Tests for capture mode with track support."""
|
||||||
|
|
||||||
|
def test_captured_case_has_track_name_field(self) -> None:
|
||||||
|
"""CapturedCase should have track_name field."""
|
||||||
|
from arcade_evals.capture import CapturedCase
|
||||||
|
|
||||||
|
# Create a captured case with track
|
||||||
|
case = CapturedCase(
|
||||||
|
case_name="test_case",
|
||||||
|
user_message="test",
|
||||||
|
tool_calls=[],
|
||||||
|
track_name="my_track",
|
||||||
|
)
|
||||||
|
assert case.track_name == "my_track"
|
||||||
|
|
||||||
|
# Create a captured case without track
|
||||||
|
case_no_track = CapturedCase(
|
||||||
|
case_name="test_case",
|
||||||
|
user_message="test",
|
||||||
|
tool_calls=[],
|
||||||
|
)
|
||||||
|
assert case_no_track.track_name is None
|
||||||
|
|
||||||
|
def test_captured_case_to_dict_includes_track_name(self) -> None:
|
||||||
|
"""CapturedCase.to_dict should include track_name when set."""
|
||||||
|
from arcade_evals.capture import CapturedCase
|
||||||
|
|
||||||
|
case = CapturedCase(
|
||||||
|
case_name="test_case",
|
||||||
|
user_message="test",
|
||||||
|
tool_calls=[],
|
||||||
|
track_name="my_track",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = case.to_dict()
|
||||||
|
assert "track_name" in result
|
||||||
|
assert result["track_name"] == "my_track"
|
||||||
|
|
||||||
|
def test_captured_case_to_dict_excludes_track_name_when_none(self) -> None:
|
||||||
|
"""CapturedCase.to_dict should not include track_name when None."""
|
||||||
|
from arcade_evals.capture import CapturedCase
|
||||||
|
|
||||||
|
case = CapturedCase(
|
||||||
|
case_name="test_case",
|
||||||
|
user_message="test",
|
||||||
|
tool_calls=[],
|
||||||
|
track_name=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = case.to_dict()
|
||||||
|
assert "track_name" not in result
|
||||||
|
|
||||||
|
def test_json_formatter_shows_track_name(self) -> None:
|
||||||
|
"""JSON formatter should include track_name in output."""
|
||||||
|
capture = _create_mock_capture_with_tracks()
|
||||||
|
formatter = CaptureJsonFormatter()
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
data = json.loads(output)
|
||||||
|
|
||||||
|
# Find case with track
|
||||||
|
cases = data["captures"][0]["captured_cases"]
|
||||||
|
track_case = next(c for c in cases if c.get("track_name") == "track_a")
|
||||||
|
assert track_case["track_name"] == "track_a"
|
||||||
|
|
||||||
|
# Find case without track
|
||||||
|
regular_case = next(c for c in cases if c.get("track_name") is None)
|
||||||
|
assert "track_name" not in regular_case
|
||||||
|
|
||||||
|
def test_text_formatter_shows_track_info(self) -> None:
|
||||||
|
"""Text formatter should show track information."""
|
||||||
|
capture = _create_mock_capture_with_tracks()
|
||||||
|
formatter = CaptureTextFormatter()
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
# Should show track names in output
|
||||||
|
assert "track_a" in output or "Track:" in output
|
||||||
|
|
||||||
|
def test_html_formatter_shows_track_info(self) -> None:
|
||||||
|
"""HTML formatter should show track information."""
|
||||||
|
capture = _create_mock_capture_with_tracks()
|
||||||
|
formatter = CaptureHtmlFormatter()
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
# Should include track info in HTML
|
||||||
|
assert "track_a" in output or "Track" in output
|
||||||
|
|
||||||
|
def test_markdown_formatter_shows_track_info(self) -> None:
|
||||||
|
"""Markdown formatter should show track information."""
|
||||||
|
capture = _create_mock_capture_with_tracks()
|
||||||
|
formatter = CaptureMarkdownFormatter()
|
||||||
|
|
||||||
|
output = formatter.format([capture])
|
||||||
|
|
||||||
|
# Should include track info in markdown
|
||||||
|
assert "[track_a]" in output or "track_a" in output
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from arcade_core.constants import PROD_ENGINE_HOST
|
|
||||||
from arcade_cli.main import cli
|
from arcade_cli.main import cli
|
||||||
|
from arcade_core.constants import PROD_ENGINE_HOST
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|
|
||||||
613
libs/tests/cli/test_display.py
Normal file
613
libs/tests/cli/test_display.py
Normal file
|
|
@ -0,0 +1,613 @@
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_cli.display import display_eval_results
|
||||||
|
from arcade_evals.eval import EvaluationResult
|
||||||
|
|
||||||
|
# Mark all tests in this module as requiring evals dependencies
|
||||||
|
pytestmark = pytest.mark.evals
|
||||||
|
|
||||||
|
|
||||||
|
def create_mock_evaluation_result(passed: bool, warning: bool, score: float) -> Mock:
|
||||||
|
"""Create a mock EvaluationResult with the specified properties."""
|
||||||
|
evaluation = Mock(spec=EvaluationResult)
|
||||||
|
evaluation.passed = passed
|
||||||
|
evaluation.warning = warning
|
||||||
|
evaluation.score = score
|
||||||
|
evaluation.failure_reason = None
|
||||||
|
evaluation.results = []
|
||||||
|
return evaluation
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_eval_results_normal() -> None:
|
||||||
|
"""Test normal display without filtering."""
|
||||||
|
results = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Test Case 1",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=True, warning=False, score=0.95
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Test Case 2",
|
||||||
|
"input": "Test input 2",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=False, score=0.5
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Should not raise any exceptions
|
||||||
|
display_eval_results(results, show_details=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_eval_results_with_failed_only() -> None:
|
||||||
|
"""Test display with failed_only flag and original counts."""
|
||||||
|
results = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Failed Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=False, score=0.3
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Original counts: 3 total, 1 passed, 1 failed, 1 warned
|
||||||
|
original_counts = (3, 1, 1, 1)
|
||||||
|
|
||||||
|
# Should not raise any exceptions
|
||||||
|
display_eval_results(
|
||||||
|
results,
|
||||||
|
show_details=False,
|
||||||
|
failed_only=True,
|
||||||
|
original_counts=original_counts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_eval_results_with_output_file() -> None:
|
||||||
|
"""Test display with output file."""
|
||||||
|
results = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Test Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=True, warning=False, score=0.9
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
output_file = Path(tmpdir) / "test_output.txt"
|
||||||
|
|
||||||
|
display_eval_results(
|
||||||
|
results,
|
||||||
|
show_details=False,
|
||||||
|
output_file=str(output_file),
|
||||||
|
output_formats=["txt"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify file was created
|
||||||
|
assert output_file.exists()
|
||||||
|
|
||||||
|
# Verify file contains some expected content
|
||||||
|
content = output_file.read_text()
|
||||||
|
assert "Model:" in content or "gpt-4o" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_eval_results_with_output_file_and_failed_only() -> None:
|
||||||
|
"""Test display with both output file and failed_only flag."""
|
||||||
|
results = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Failed Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=False, score=0.2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
original_counts = (5, 3, 1, 1)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
output_file = Path(tmpdir) / "test_output.txt"
|
||||||
|
|
||||||
|
display_eval_results(
|
||||||
|
results,
|
||||||
|
show_details=False,
|
||||||
|
output_file=str(output_file),
|
||||||
|
failed_only=True,
|
||||||
|
original_counts=original_counts,
|
||||||
|
output_formats=["txt"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify file was created
|
||||||
|
assert output_file.exists()
|
||||||
|
|
||||||
|
# Verify file contains disclaimer and summary
|
||||||
|
content = output_file.read_text()
|
||||||
|
assert "failed-only" in content.lower() or "failed evaluation" in content.lower()
|
||||||
|
assert "Total: 5" in content # Should show original total
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_eval_results_creates_parent_directories() -> None:
|
||||||
|
"""Test that output file creates parent directories if they don't exist."""
|
||||||
|
results = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Test Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=True, warning=False, score=0.9
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
output_file = Path(tmpdir) / "nested" / "path" / "test_output.txt"
|
||||||
|
|
||||||
|
# Parent directories don't exist yet
|
||||||
|
assert not output_file.parent.exists()
|
||||||
|
|
||||||
|
display_eval_results(
|
||||||
|
results,
|
||||||
|
show_details=False,
|
||||||
|
output_file=str(output_file),
|
||||||
|
output_formats=["txt"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parent directories should be created
|
||||||
|
assert output_file.parent.exists()
|
||||||
|
assert output_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_eval_results_with_warnings() -> None:
|
||||||
|
"""Test display with cases that have warnings."""
|
||||||
|
results: list = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Warning Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=True, score=0.85
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Failed Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=False, score=0.3
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Should not raise any exceptions
|
||||||
|
display_eval_results(results, show_details=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_eval_results_empty_results() -> None:
|
||||||
|
"""Test display with empty results."""
|
||||||
|
results: list = []
|
||||||
|
|
||||||
|
# Should not raise any exceptions
|
||||||
|
display_eval_results(results, show_details=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_eval_results_with_details() -> None:
|
||||||
|
"""Test display with show_details=True."""
|
||||||
|
evaluation = create_mock_evaluation_result(passed=True, warning=False, score=0.95)
|
||||||
|
evaluation.results = [
|
||||||
|
{
|
||||||
|
"field": "test_field",
|
||||||
|
"match": True,
|
||||||
|
"score": 1.0,
|
||||||
|
"weight": 1.0,
|
||||||
|
"expected": "expected_value",
|
||||||
|
"actual": "actual_value",
|
||||||
|
"is_criticized": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
results = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Test Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": evaluation,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Should not raise any exceptions
|
||||||
|
display_eval_results(results, show_details=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_eval_results_with_failed_only_no_warnings() -> None:
|
||||||
|
"""Test display with failed_only but original counts have no warnings."""
|
||||||
|
results = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Failed Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=False, score=0.3
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Original counts: 10 total, 8 passed, 2 failed, 0 warned
|
||||||
|
original_counts = (10, 8, 2, 0)
|
||||||
|
|
||||||
|
display_eval_results(
|
||||||
|
results,
|
||||||
|
show_details=False,
|
||||||
|
failed_only=True,
|
||||||
|
original_counts=original_counts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_eval_results_with_failed_only_no_failed() -> None:
|
||||||
|
"""Test display with failed_only but original counts have no failed."""
|
||||||
|
results = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Failed Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=False, score=0.3
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Original counts: 5 total, 5 passed, 0 failed, 0 warned (edge case)
|
||||||
|
original_counts = (5, 5, 0, 0)
|
||||||
|
|
||||||
|
display_eval_results(
|
||||||
|
results,
|
||||||
|
show_details=False,
|
||||||
|
failed_only=True,
|
||||||
|
original_counts=original_counts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_eval_results_multiple_suites() -> None:
|
||||||
|
"""Test display with multiple eval suites."""
|
||||||
|
results = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric 1",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Test Case 1",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=True, warning=False, score=0.95
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric 2",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Test Case 2",
|
||||||
|
"input": "Test input 2",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=False, score=0.5
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
display_eval_results(results, show_details=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_eval_results_multiple_models() -> None:
|
||||||
|
"""Test display with multiple models in same suite."""
|
||||||
|
results = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Test Case 1",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=True, warning=False, score=0.95
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "gpt-3.5-turbo",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Test Case 2",
|
||||||
|
"input": "Test input 2",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=False, score=0.5
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
display_eval_results(results, show_details=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_eval_results_summary_with_warnings() -> None:
|
||||||
|
"""Test summary display when warnings are present."""
|
||||||
|
results = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Passed Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=True, warning=False, score=0.95
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Warning Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=True, score=0.85
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Failed Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=False, score=0.3
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
display_eval_results(results, show_details=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_eval_results_summary_only_passed() -> None:
|
||||||
|
"""Test summary when all cases passed."""
|
||||||
|
results = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Passed Case 1",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=True, warning=False, score=0.95
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Passed Case 2",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=True, warning=False, score=0.98
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
display_eval_results(results, show_details=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_eval_results_failed_only_with_warnings_in_summary() -> None:
|
||||||
|
"""Test failed_only display when original counts include warnings."""
|
||||||
|
results = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Failed Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=False, score=0.3
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Original counts: 10 total, 7 passed, 2 failed, 1 warned
|
||||||
|
original_counts = (10, 7, 2, 1)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
output_file = Path(tmpdir) / "test_output.txt"
|
||||||
|
|
||||||
|
display_eval_results(
|
||||||
|
results,
|
||||||
|
show_details=False,
|
||||||
|
output_file=str(output_file),
|
||||||
|
failed_only=True,
|
||||||
|
original_counts=original_counts,
|
||||||
|
output_formats=["txt"],
|
||||||
|
)
|
||||||
|
|
||||||
|
content = output_file.read_text()
|
||||||
|
# Should show warnings in summary
|
||||||
|
assert "Warnings: 1" in content or "Warnings" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_eval_results_with_details_and_output() -> None:
|
||||||
|
"""Test display with details and output file."""
|
||||||
|
evaluation = create_mock_evaluation_result(passed=True, warning=False, score=0.95)
|
||||||
|
evaluation.results = [
|
||||||
|
{
|
||||||
|
"field": "test_field",
|
||||||
|
"match": True,
|
||||||
|
"score": 1.0,
|
||||||
|
"weight": 1.0,
|
||||||
|
"expected": "expected_value",
|
||||||
|
"actual": "actual_value",
|
||||||
|
"is_criticized": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
results = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Test Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": evaluation,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
output_file = Path(tmpdir) / "test_output.txt"
|
||||||
|
|
||||||
|
display_eval_results(
|
||||||
|
results,
|
||||||
|
show_details=True,
|
||||||
|
output_file=str(output_file),
|
||||||
|
output_formats=["txt"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output_file.exists()
|
||||||
|
content = output_file.read_text()
|
||||||
|
assert "User Input:" in content
|
||||||
|
assert "Details:" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_eval_results_multi_format_output() -> None:
|
||||||
|
"""Test display with multiple output formats."""
|
||||||
|
results = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Test Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=True, warning=False, score=0.9
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
output_file = Path(tmpdir) / "results"
|
||||||
|
|
||||||
|
display_eval_results(
|
||||||
|
results,
|
||||||
|
show_details=False,
|
||||||
|
output_file=str(output_file),
|
||||||
|
output_formats=["txt", "md", "html"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify all three files were created
|
||||||
|
assert (Path(tmpdir) / "results.txt").exists()
|
||||||
|
assert (Path(tmpdir) / "results.md").exists()
|
||||||
|
assert (Path(tmpdir) / "results.html").exists()
|
||||||
|
|
||||||
|
# Verify each file has appropriate content
|
||||||
|
txt_content = (Path(tmpdir) / "results.txt").read_text()
|
||||||
|
assert "Test Case" in txt_content
|
||||||
|
|
||||||
|
md_content = (Path(tmpdir) / "results.md").read_text()
|
||||||
|
assert "# " in md_content # Markdown header
|
||||||
|
|
||||||
|
html_content = (Path(tmpdir) / "results.html").read_text()
|
||||||
|
assert "<html" in html_content
|
||||||
548
libs/tests/cli/test_evals_runner.py
Normal file
548
libs/tests/cli/test_evals_runner.py
Normal file
|
|
@ -0,0 +1,548 @@
|
||||||
|
"""Tests for evals_runner error handling."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_cli.evals_runner import (
|
||||||
|
ALL_FORMATS,
|
||||||
|
CaptureTaskResult,
|
||||||
|
EvalTaskResult,
|
||||||
|
_run_capture_task,
|
||||||
|
_run_eval_task,
|
||||||
|
parse_output_formats,
|
||||||
|
run_capture,
|
||||||
|
run_evaluations,
|
||||||
|
)
|
||||||
|
from arcade_cli.utils import ModelSpec, Provider
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvalTaskResult:
|
||||||
|
"""Test EvalTaskResult dataclass."""
|
||||||
|
|
||||||
|
def test_from_success(self) -> None:
|
||||||
|
"""Test creating a successful result."""
|
||||||
|
result = EvalTaskResult.from_success("test_suite", "gpt-4o", "openai", {"score": 0.9})
|
||||||
|
assert result.success is True
|
||||||
|
assert result.suite_name == "test_suite"
|
||||||
|
assert result.model == "gpt-4o"
|
||||||
|
assert result.provider == "openai"
|
||||||
|
assert result.result == {"score": 0.9}
|
||||||
|
assert result.error is None
|
||||||
|
assert result.error_type is None
|
||||||
|
|
||||||
|
def test_from_error(self) -> None:
|
||||||
|
"""Test creating a failed result from an exception."""
|
||||||
|
error = ValueError("Something went wrong")
|
||||||
|
result = EvalTaskResult.from_error("test_suite", "gpt-4o", "openai", error)
|
||||||
|
assert result.success is False
|
||||||
|
assert result.suite_name == "test_suite"
|
||||||
|
assert result.model == "gpt-4o"
|
||||||
|
assert result.provider == "openai"
|
||||||
|
assert result.error == "Something went wrong"
|
||||||
|
assert result.error_type == "ValueError"
|
||||||
|
assert result.result is None
|
||||||
|
|
||||||
|
def test_from_error_with_different_exception_types(self) -> None:
|
||||||
|
"""Test that error_type captures the correct exception class name."""
|
||||||
|
errors = [
|
||||||
|
(RuntimeError("runtime"), "RuntimeError"),
|
||||||
|
(TypeError("type"), "TypeError"),
|
||||||
|
(KeyError("key"), "KeyError"),
|
||||||
|
(ConnectionError("conn"), "ConnectionError"),
|
||||||
|
]
|
||||||
|
for error, expected_type in errors:
|
||||||
|
result = EvalTaskResult.from_error("suite", "model", "openai", error)
|
||||||
|
assert result.error_type == expected_type
|
||||||
|
|
||||||
|
def test_display_name(self) -> None:
|
||||||
|
"""Test that display_name shows provider/model format."""
|
||||||
|
result = EvalTaskResult.from_success("suite", "gpt-4o", "openai", {})
|
||||||
|
assert result.display_name == "openai/gpt-4o"
|
||||||
|
|
||||||
|
result2 = EvalTaskResult.from_success("suite", "claude-3-sonnet", "anthropic", {})
|
||||||
|
assert result2.display_name == "anthropic/claude-3-sonnet"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptureTaskResult:
|
||||||
|
"""Test CaptureTaskResult dataclass."""
|
||||||
|
|
||||||
|
def test_from_success(self) -> None:
|
||||||
|
"""Test creating a successful capture result."""
|
||||||
|
mock_captures = [MagicMock(), MagicMock()]
|
||||||
|
result = CaptureTaskResult.from_success("test_suite", "gpt-4o", "openai", mock_captures)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.suite_name == "test_suite"
|
||||||
|
assert result.model == "gpt-4o"
|
||||||
|
assert result.provider == "openai"
|
||||||
|
assert result.result == mock_captures
|
||||||
|
assert result.error is None
|
||||||
|
assert result.error_type is None
|
||||||
|
|
||||||
|
def test_from_error(self) -> None:
|
||||||
|
"""Test creating a failed capture result."""
|
||||||
|
error = RuntimeError("Capture failed")
|
||||||
|
result = CaptureTaskResult.from_error("test_suite", "gpt-4o", "openai", error)
|
||||||
|
assert result.success is False
|
||||||
|
assert result.error == "Capture failed"
|
||||||
|
assert result.error_type == "RuntimeError"
|
||||||
|
assert result.result is None
|
||||||
|
|
||||||
|
def test_display_name(self) -> None:
|
||||||
|
"""Test that display_name shows provider/model format."""
|
||||||
|
result = CaptureTaskResult.from_success("suite", "gpt-4o", "openai", [])
|
||||||
|
assert result.display_name == "openai/gpt-4o"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunEvalTask:
|
||||||
|
"""Test _run_eval_task error handling."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_successful_task(self) -> None:
|
||||||
|
"""Test that successful task returns success result."""
|
||||||
|
mock_suite = AsyncMock(return_value={"score": 0.95})
|
||||||
|
mock_suite.__name__ = "test_suite"
|
||||||
|
|
||||||
|
model_spec = ModelSpec(provider=Provider.OPENAI, model="gpt-4o", api_key="test-key")
|
||||||
|
result = await _run_eval_task(
|
||||||
|
suite_func=mock_suite,
|
||||||
|
model_spec=model_spec,
|
||||||
|
max_concurrent=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.result == {"score": 0.95}
|
||||||
|
assert result.suite_name == "test_suite"
|
||||||
|
assert result.model == "gpt-4o"
|
||||||
|
assert result.provider == "openai"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_task_returns_error_result(self) -> None:
|
||||||
|
"""Test that failed task returns error result instead of raising."""
|
||||||
|
mock_suite = AsyncMock(side_effect=ValueError("API error"))
|
||||||
|
mock_suite.__name__ = "test_suite"
|
||||||
|
|
||||||
|
model_spec = ModelSpec(provider=Provider.OPENAI, model="gpt-4o", api_key="test-key")
|
||||||
|
result = await _run_eval_task(
|
||||||
|
suite_func=mock_suite,
|
||||||
|
model_spec=model_spec,
|
||||||
|
max_concurrent=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert "API error" in result.error
|
||||||
|
assert result.error_type == "ValueError"
|
||||||
|
assert result.result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_passes_correct_arguments_to_suite(self) -> None:
|
||||||
|
"""Test that correct arguments are passed to the suite function."""
|
||||||
|
mock_suite = AsyncMock(return_value={"score": 1.0})
|
||||||
|
mock_suite.__name__ = "test_suite"
|
||||||
|
|
||||||
|
model_spec = ModelSpec(provider=Provider.ANTHROPIC, model="claude-sonnet", api_key="my-key")
|
||||||
|
await _run_eval_task(
|
||||||
|
suite_func=mock_suite,
|
||||||
|
model_spec=model_spec,
|
||||||
|
max_concurrent=5,
|
||||||
|
include_context=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_suite.assert_called_once_with(
|
||||||
|
provider_api_key="my-key",
|
||||||
|
model="claude-sonnet",
|
||||||
|
max_concurrency=5,
|
||||||
|
provider="anthropic",
|
||||||
|
include_context=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunCaptureTask:
|
||||||
|
"""Test _run_capture_task error handling."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_successful_capture_task(self) -> None:
|
||||||
|
"""Test that successful capture task returns success result."""
|
||||||
|
mock_captures = [MagicMock()]
|
||||||
|
mock_suite = AsyncMock(return_value=mock_captures)
|
||||||
|
mock_suite.__name__ = "capture_suite"
|
||||||
|
|
||||||
|
model_spec = ModelSpec(provider=Provider.OPENAI, model="gpt-4o", api_key="test-key")
|
||||||
|
result = await _run_capture_task(
|
||||||
|
suite_func=mock_suite,
|
||||||
|
model_spec=model_spec,
|
||||||
|
max_concurrent=1,
|
||||||
|
include_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.result == mock_captures
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_capture_task_returns_error_result(self) -> None:
|
||||||
|
"""Test that failed capture task returns error result."""
|
||||||
|
mock_suite = AsyncMock(side_effect=ConnectionError("Network failed"))
|
||||||
|
mock_suite.__name__ = "capture_suite"
|
||||||
|
|
||||||
|
model_spec = ModelSpec(provider=Provider.OPENAI, model="gpt-4o", api_key="test-key")
|
||||||
|
result = await _run_capture_task(
|
||||||
|
suite_func=mock_suite,
|
||||||
|
model_spec=model_spec,
|
||||||
|
max_concurrent=1,
|
||||||
|
include_context=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert "Network failed" in result.error
|
||||||
|
assert result.error_type == "ConnectionError"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_mode_passed(self) -> None:
|
||||||
|
"""Test that capture_mode and include_context are passed."""
|
||||||
|
mock_suite = AsyncMock(return_value=[])
|
||||||
|
mock_suite.__name__ = "capture_suite"
|
||||||
|
|
||||||
|
model_spec = ModelSpec(provider=Provider.OPENAI, model="gpt-4o", api_key="key")
|
||||||
|
await _run_capture_task(
|
||||||
|
suite_func=mock_suite,
|
||||||
|
model_spec=model_spec,
|
||||||
|
max_concurrent=2,
|
||||||
|
include_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_suite.assert_called_once_with(
|
||||||
|
provider_api_key="key",
|
||||||
|
model="gpt-4o",
|
||||||
|
max_concurrency=2,
|
||||||
|
provider="openai",
|
||||||
|
capture_mode=True,
|
||||||
|
include_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunEvaluationsErrorHandling:
|
||||||
|
"""Test run_evaluations handles partial failures."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_partial_failure_continues(self) -> None:
|
||||||
|
"""Test that one failing task doesn't stop others."""
|
||||||
|
successful_suite = AsyncMock(return_value=MagicMock())
|
||||||
|
successful_suite.__name__ = "success_suite"
|
||||||
|
|
||||||
|
failing_suite = AsyncMock(side_effect=RuntimeError("Oops"))
|
||||||
|
failing_suite.__name__ = "failing_suite"
|
||||||
|
|
||||||
|
console = MagicMock()
|
||||||
|
model_specs = [ModelSpec(provider=Provider.OPENAI, model="gpt-4o", api_key="test")]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("arcade_cli.evals_runner.display_eval_results"),
|
||||||
|
patch("arcade_cli.evals_runner.Progress") as mock_progress,
|
||||||
|
):
|
||||||
|
# Mock Progress context manager
|
||||||
|
mock_progress.return_value.__enter__ = MagicMock(return_value=mock_progress)
|
||||||
|
mock_progress.return_value.__exit__ = MagicMock(return_value=None)
|
||||||
|
mock_progress.add_task = MagicMock(return_value=0)
|
||||||
|
mock_progress.update = MagicMock()
|
||||||
|
|
||||||
|
await run_evaluations(
|
||||||
|
eval_suites=[successful_suite, failing_suite],
|
||||||
|
model_specs=model_specs,
|
||||||
|
max_concurrent=1,
|
||||||
|
show_details=False,
|
||||||
|
output_file=None,
|
||||||
|
output_format="txt",
|
||||||
|
failed_only=False,
|
||||||
|
console=console,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify both were attempted
|
||||||
|
successful_suite.assert_called_once()
|
||||||
|
failing_suite.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_all_failures_reports_none_completed(self) -> None:
|
||||||
|
"""Test appropriate message when all tasks fail."""
|
||||||
|
failing_suite = AsyncMock(side_effect=RuntimeError("Oops"))
|
||||||
|
failing_suite.__name__ = "failing_suite"
|
||||||
|
|
||||||
|
console = MagicMock()
|
||||||
|
model_specs = [ModelSpec(provider=Provider.OPENAI, model="gpt-4o", api_key="test")]
|
||||||
|
|
||||||
|
await run_evaluations(
|
||||||
|
eval_suites=[failing_suite],
|
||||||
|
model_specs=model_specs,
|
||||||
|
max_concurrent=1,
|
||||||
|
show_details=False,
|
||||||
|
output_file=None,
|
||||||
|
output_format="txt",
|
||||||
|
failed_only=False,
|
||||||
|
console=console,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should print "No evaluations completed successfully" (with emoji)
|
||||||
|
console.print.assert_any_call(
|
||||||
|
"\n[bold red]❌ No evaluations completed successfully.[/bold red]"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failure_warning_displayed(self) -> None:
|
||||||
|
"""Test that failure warnings are displayed."""
|
||||||
|
failing_suite = AsyncMock(side_effect=ValueError("Bad input"))
|
||||||
|
failing_suite.__name__ = "bad_suite"
|
||||||
|
|
||||||
|
console = MagicMock()
|
||||||
|
model_specs = [ModelSpec(provider=Provider.OPENAI, model="gpt-4o", api_key="test")]
|
||||||
|
|
||||||
|
await run_evaluations(
|
||||||
|
eval_suites=[failing_suite],
|
||||||
|
model_specs=model_specs,
|
||||||
|
max_concurrent=1,
|
||||||
|
show_details=False,
|
||||||
|
output_file=None,
|
||||||
|
output_format="txt",
|
||||||
|
failed_only=False,
|
||||||
|
console=console,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that failure count is printed
|
||||||
|
calls = [str(c) for c in console.print.call_args_list]
|
||||||
|
assert any("1 evaluation(s) failed" in c for c in calls)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_all_success_no_failure_warning(self) -> None:
|
||||||
|
"""Test that no failure warning when all succeed."""
|
||||||
|
successful_suite = AsyncMock(return_value=MagicMock())
|
||||||
|
successful_suite.__name__ = "success_suite"
|
||||||
|
|
||||||
|
console = MagicMock()
|
||||||
|
model_specs = [ModelSpec(provider=Provider.OPENAI, model="gpt-4o", api_key="test")]
|
||||||
|
|
||||||
|
with patch("arcade_cli.evals_runner.display_eval_results"):
|
||||||
|
await run_evaluations(
|
||||||
|
eval_suites=[successful_suite],
|
||||||
|
model_specs=model_specs,
|
||||||
|
max_concurrent=1,
|
||||||
|
show_details=False,
|
||||||
|
output_file=None,
|
||||||
|
output_format="txt",
|
||||||
|
failed_only=False,
|
||||||
|
console=console,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that no failure warning is printed
|
||||||
|
calls = [str(c) for c in console.print.call_args_list]
|
||||||
|
assert not any("failed" in c.lower() for c in calls)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multiple_models_partial_failure(self) -> None:
|
||||||
|
"""Test partial failure with multiple models."""
|
||||||
|
|
||||||
|
# Suite that fails on one model but succeeds on another
|
||||||
|
async def conditional_suite(**kwargs):
|
||||||
|
if kwargs["model"] == "bad-model":
|
||||||
|
raise RuntimeError("Model not supported")
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
mock_suite = AsyncMock(side_effect=conditional_suite)
|
||||||
|
mock_suite.__name__ = "conditional_suite"
|
||||||
|
|
||||||
|
console = MagicMock()
|
||||||
|
model_specs = [
|
||||||
|
ModelSpec(provider=Provider.OPENAI, model="gpt-4o", api_key="test"),
|
||||||
|
ModelSpec(provider=Provider.OPENAI, model="bad-model", api_key="test"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("arcade_cli.evals_runner.display_eval_results"),
|
||||||
|
patch("arcade_cli.evals_runner.Progress") as mock_progress,
|
||||||
|
):
|
||||||
|
# Mock Progress context manager
|
||||||
|
mock_progress.return_value.__enter__ = MagicMock(return_value=mock_progress)
|
||||||
|
mock_progress.return_value.__exit__ = MagicMock(return_value=None)
|
||||||
|
mock_progress.add_task = MagicMock(return_value=0)
|
||||||
|
mock_progress.update = MagicMock()
|
||||||
|
|
||||||
|
await run_evaluations(
|
||||||
|
eval_suites=[mock_suite],
|
||||||
|
model_specs=model_specs,
|
||||||
|
max_concurrent=1,
|
||||||
|
show_details=False,
|
||||||
|
output_file=None,
|
||||||
|
output_format="txt",
|
||||||
|
failed_only=False,
|
||||||
|
console=console,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should have been called twice
|
||||||
|
assert mock_suite.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunCaptureErrorHandling:
|
||||||
|
"""Test run_capture handles partial failures."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_all_captures_fail_reports_none_completed(self) -> None:
|
||||||
|
"""Test appropriate message when all capture tasks fail."""
|
||||||
|
failing_suite = AsyncMock(side_effect=RuntimeError("Capture failed"))
|
||||||
|
failing_suite.__name__ = "failing_capture"
|
||||||
|
|
||||||
|
console = MagicMock()
|
||||||
|
model_specs = [ModelSpec(provider=Provider.OPENAI, model="gpt-4o", api_key="test")]
|
||||||
|
|
||||||
|
await run_capture(
|
||||||
|
eval_suites=[failing_suite],
|
||||||
|
model_specs=model_specs,
|
||||||
|
max_concurrent=1,
|
||||||
|
include_context=False,
|
||||||
|
output_file=None,
|
||||||
|
output_format="json",
|
||||||
|
console=console,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Error message includes emoji
|
||||||
|
console.print.assert_any_call(
|
||||||
|
"\n[bold red]❌ No captures completed successfully.[/bold red]"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_partial_capture_failure_continues(self) -> None:
|
||||||
|
"""Test that one failing capture doesn't stop others."""
|
||||||
|
# Mock CaptureResult
|
||||||
|
mock_capture = MagicMock()
|
||||||
|
mock_capture.to_dict.return_value = {"test": "data"}
|
||||||
|
mock_capture.captured_cases = []
|
||||||
|
|
||||||
|
successful_suite = AsyncMock(return_value=[mock_capture])
|
||||||
|
successful_suite.__name__ = "success_capture"
|
||||||
|
|
||||||
|
failing_suite = AsyncMock(side_effect=RuntimeError("Oops"))
|
||||||
|
failing_suite.__name__ = "failing_capture"
|
||||||
|
|
||||||
|
console = MagicMock()
|
||||||
|
model_specs = [ModelSpec(provider=Provider.OPENAI, model="gpt-4o", api_key="test")]
|
||||||
|
|
||||||
|
with patch("arcade_cli.evals_runner.Progress") as mock_progress:
|
||||||
|
# Mock Progress context manager
|
||||||
|
mock_progress.return_value.__enter__ = MagicMock(return_value=mock_progress)
|
||||||
|
mock_progress.return_value.__exit__ = MagicMock(return_value=None)
|
||||||
|
mock_progress.add_task = MagicMock(return_value=0)
|
||||||
|
mock_progress.update = MagicMock()
|
||||||
|
|
||||||
|
await run_capture(
|
||||||
|
eval_suites=[successful_suite, failing_suite],
|
||||||
|
model_specs=model_specs,
|
||||||
|
max_concurrent=1,
|
||||||
|
include_context=False,
|
||||||
|
output_file=None,
|
||||||
|
output_format="json",
|
||||||
|
console=console,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Both should have been attempted
|
||||||
|
successful_suite.assert_called_once()
|
||||||
|
failing_suite.assert_called_once()
|
||||||
|
|
||||||
|
# Check failure warning was printed
|
||||||
|
calls = [str(c) for c in console.print.call_args_list]
|
||||||
|
assert any("1 capture(s) failed" in c for c in calls)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_failure_details_displayed(self) -> None:
|
||||||
|
"""Test that capture failure details are shown."""
|
||||||
|
failing_suite = AsyncMock(side_effect=ConnectionError("Network error"))
|
||||||
|
failing_suite.__name__ = "network_capture"
|
||||||
|
|
||||||
|
console = MagicMock()
|
||||||
|
model_specs = [ModelSpec(provider=Provider.OPENAI, model="gpt-4o", api_key="test")]
|
||||||
|
|
||||||
|
await run_capture(
|
||||||
|
eval_suites=[failing_suite],
|
||||||
|
model_specs=model_specs,
|
||||||
|
max_concurrent=1,
|
||||||
|
include_context=False,
|
||||||
|
output_file=None,
|
||||||
|
output_format="json",
|
||||||
|
console=console,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check error details are printed
|
||||||
|
calls = [str(c) for c in console.print.call_args_list]
|
||||||
|
assert any("network_capture" in c for c in calls)
|
||||||
|
assert any("ConnectionError" in c for c in calls)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseOutputFormats:
|
||||||
|
"""Tests for parse_output_formats function."""
|
||||||
|
|
||||||
|
def test_single_format(self) -> None:
|
||||||
|
"""Should return a list with a single format."""
|
||||||
|
console = MagicMock()
|
||||||
|
assert parse_output_formats("md", console) == ["md"]
|
||||||
|
assert parse_output_formats("txt", console) == ["txt"]
|
||||||
|
assert parse_output_formats("html", console) == ["html"]
|
||||||
|
assert parse_output_formats("json", console) == ["json"]
|
||||||
|
|
||||||
|
def test_comma_separated_formats(self) -> None:
|
||||||
|
"""Should return a list of multiple formats."""
|
||||||
|
console = MagicMock()
|
||||||
|
assert parse_output_formats("md,html", console) == ["md", "html"]
|
||||||
|
assert parse_output_formats("txt,md,html,json", console) == ["txt", "md", "html", "json"]
|
||||||
|
|
||||||
|
def test_comma_separated_with_spaces(self) -> None:
|
||||||
|
"""Should handle spaces around commas."""
|
||||||
|
console = MagicMock()
|
||||||
|
assert parse_output_formats("md, html", console) == ["md", "html"]
|
||||||
|
assert parse_output_formats(" md , html ", console) == ["md", "html"]
|
||||||
|
|
||||||
|
def test_all_keyword(self) -> None:
|
||||||
|
"""Should return all formats for 'all' keyword."""
|
||||||
|
console = MagicMock()
|
||||||
|
assert parse_output_formats("all", console) == ALL_FORMATS
|
||||||
|
assert parse_output_formats("ALL", console) == ALL_FORMATS
|
||||||
|
assert parse_output_formats("All", console) == ALL_FORMATS
|
||||||
|
|
||||||
|
def test_case_insensitive(self) -> None:
|
||||||
|
"""Should be case-insensitive."""
|
||||||
|
console = MagicMock()
|
||||||
|
assert parse_output_formats("MD", console) == ["md"]
|
||||||
|
assert parse_output_formats("HTML,JSON", console) == ["html", "json"]
|
||||||
|
|
||||||
|
def test_invalid_formats_raise_error(self) -> None:
|
||||||
|
"""Should raise ValueError for invalid formats (parse-time validation)."""
|
||||||
|
console = MagicMock()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Invalid format.*invalid"):
|
||||||
|
parse_output_formats("md,invalid", console)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Invalid format.*invalid"):
|
||||||
|
parse_output_formats("invalid", console)
|
||||||
|
|
||||||
|
def test_mixed_valid_invalid_raises(self) -> None:
|
||||||
|
"""Should raise ValueError when any invalid formats present."""
|
||||||
|
with pytest.raises(ValueError, match="Invalid format"):
|
||||||
|
parse_output_formats("md,foo,html,bar", MagicMock())
|
||||||
|
|
||||||
|
def test_raises_on_invalid_formats(self) -> None:
|
||||||
|
"""Should raise ValueError when invalid formats are provided."""
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
parse_output_formats("xlsx,invalid", MagicMock())
|
||||||
|
|
||||||
|
error_msg = str(exc_info.value)
|
||||||
|
assert "Invalid format" in error_msg
|
||||||
|
assert "xlsx" in error_msg
|
||||||
|
assert "invalid" in error_msg
|
||||||
|
|
||||||
|
def test_raises_on_partially_invalid_formats(self) -> None:
|
||||||
|
"""Should raise ValueError even when some valid formats exist."""
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
parse_output_formats("md,xlsx,html", MagicMock())
|
||||||
|
|
||||||
|
error_msg = str(exc_info.value)
|
||||||
|
assert "Invalid format" in error_msg
|
||||||
|
assert "xlsx" in error_msg
|
||||||
|
|
||||||
|
def test_no_error_when_all_valid(self) -> None:
|
||||||
|
"""Should not raise when all formats are valid."""
|
||||||
|
console = MagicMock()
|
||||||
|
result = parse_output_formats("md,html,json", console)
|
||||||
|
assert result == ["md", "html", "json"]
|
||||||
290
libs/tests/cli/test_formatter_edge_cases.py
Normal file
290
libs/tests/cli/test_formatter_edge_cases.py
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
"""Additional edge case tests for formatters to ensure robustness."""
|
||||||
|
|
||||||
|
from arcade_cli.formatters import (
|
||||||
|
HtmlFormatter,
|
||||||
|
JsonFormatter,
|
||||||
|
MarkdownFormatter,
|
||||||
|
TextFormatter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MockEvaluation:
|
||||||
|
"""Mock EvaluationResult for testing."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
passed: bool = True,
|
||||||
|
warning: bool = False,
|
||||||
|
score: float = 1.0,
|
||||||
|
failure_reason: str | None = None,
|
||||||
|
results: list[dict] | None = None,
|
||||||
|
):
|
||||||
|
self.passed = passed
|
||||||
|
self.warning = warning
|
||||||
|
self.score = score
|
||||||
|
self.failure_reason = failure_reason
|
||||||
|
self.results = results or []
|
||||||
|
|
||||||
|
|
||||||
|
def make_empty_results() -> list[list[dict]]:
|
||||||
|
"""Create empty evaluation results."""
|
||||||
|
return [[{"model": "gpt-4o", "suite_name": "empty_suite", "rubric": "Test", "cases": []}]]
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatterEdgeCases:
|
||||||
|
"""Test edge cases that might not be covered elsewhere."""
|
||||||
|
|
||||||
|
def test_empty_results_all_formatters(self) -> None:
|
||||||
|
"""All formatters should handle empty results gracefully."""
|
||||||
|
results = make_empty_results()
|
||||||
|
|
||||||
|
for formatter_class in [TextFormatter, MarkdownFormatter, HtmlFormatter, JsonFormatter]:
|
||||||
|
formatter = formatter_class()
|
||||||
|
output = formatter.format(results)
|
||||||
|
assert output # Should produce some output
|
||||||
|
assert "0" in output or "Total: 0" in output.lower() or '"total_cases": 0' in output
|
||||||
|
|
||||||
|
def test_failed_only_with_zero_original_total(self) -> None:
|
||||||
|
"""Should handle original_counts with zero total without crashing."""
|
||||||
|
results = make_empty_results()
|
||||||
|
# Edge case: original_counts with 0 total (shouldn't happen in practice but should be safe)
|
||||||
|
original_counts = (0, 0, 0, 0)
|
||||||
|
|
||||||
|
for formatter_class in [TextFormatter, MarkdownFormatter, HtmlFormatter, JsonFormatter]:
|
||||||
|
formatter = formatter_class()
|
||||||
|
# Should not raise ZeroDivisionError
|
||||||
|
output = formatter.format(results, failed_only=True, original_counts=original_counts)
|
||||||
|
assert output # Should produce some output
|
||||||
|
|
||||||
|
def test_failed_only_with_empty_results_but_nonzero_original(self) -> None:
|
||||||
|
"""Should handle case where filtered results are empty but original had cases."""
|
||||||
|
results = make_empty_results()
|
||||||
|
# All cases were filtered out, but there were originally 5 cases (all passed)
|
||||||
|
original_counts = (5, 5, 0, 0)
|
||||||
|
|
||||||
|
for formatter_class in [TextFormatter, MarkdownFormatter, HtmlFormatter, JsonFormatter]:
|
||||||
|
formatter = formatter_class()
|
||||||
|
output = formatter.format(results, failed_only=True, original_counts=original_counts)
|
||||||
|
assert output
|
||||||
|
# Should show original counts
|
||||||
|
assert "5" in output
|
||||||
|
|
||||||
|
def test_all_formatters_handle_none_original_counts(self) -> None:
|
||||||
|
"""All formatters should handle None original_counts gracefully."""
|
||||||
|
results = [[{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"suite_name": "test",
|
||||||
|
"rubric": "Test",
|
||||||
|
"cases": [{
|
||||||
|
"name": "test_case",
|
||||||
|
"input": "test",
|
||||||
|
"evaluation": MockEvaluation(passed=False, score=0.0),
|
||||||
|
}],
|
||||||
|
}]]
|
||||||
|
|
||||||
|
for formatter_class in [TextFormatter, MarkdownFormatter, HtmlFormatter, JsonFormatter]:
|
||||||
|
formatter = formatter_class()
|
||||||
|
# Should not crash with None original_counts
|
||||||
|
output = formatter.format(results, failed_only=True, original_counts=None)
|
||||||
|
assert output
|
||||||
|
|
||||||
|
def test_comparative_with_missing_track_data(self) -> None:
|
||||||
|
"""Comparative formatters should handle missing track gracefully."""
|
||||||
|
# Create comparative result where one track is missing data
|
||||||
|
results = [[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"suite_name": "Test Suite [track_a]",
|
||||||
|
"track_name": "track_a",
|
||||||
|
"rubric": None,
|
||||||
|
"cases": [{
|
||||||
|
"name": "test_case",
|
||||||
|
"input": "test",
|
||||||
|
"evaluation": MockEvaluation(passed=True, score=1.0),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"suite_name": "Test Suite [track_b]",
|
||||||
|
"track_name": "track_b",
|
||||||
|
"rubric": None,
|
||||||
|
"cases": [], # Empty cases for this track
|
||||||
|
},
|
||||||
|
]]
|
||||||
|
|
||||||
|
for formatter_class in [TextFormatter, MarkdownFormatter, HtmlFormatter, JsonFormatter]:
|
||||||
|
formatter = formatter_class()
|
||||||
|
output = formatter.format(results)
|
||||||
|
assert output
|
||||||
|
# Should mention both tracks
|
||||||
|
assert "track_a" in output
|
||||||
|
assert "track_b" in output
|
||||||
|
|
||||||
|
def test_html_formatter_escapes_all_special_chars(self) -> None:
|
||||||
|
"""HTML formatter must escape all special characters to prevent XSS."""
|
||||||
|
results = [[{
|
||||||
|
"model": "gpt-4o<script>alert('xss')</script>",
|
||||||
|
"suite_name": "Suite & Test",
|
||||||
|
"rubric": "Test",
|
||||||
|
"cases": [{
|
||||||
|
"name": "<img src=x onerror=alert(1)>",
|
||||||
|
"input": "test' OR '1'='1",
|
||||||
|
"evaluation": MockEvaluation(
|
||||||
|
passed=False,
|
||||||
|
score=0.0,
|
||||||
|
failure_reason="Error: <script>malicious</script>",
|
||||||
|
),
|
||||||
|
}],
|
||||||
|
}]]
|
||||||
|
|
||||||
|
formatter = HtmlFormatter()
|
||||||
|
output = formatter.format(results)
|
||||||
|
|
||||||
|
# Should NOT contain raw script tags or other unescaped HTML
|
||||||
|
assert "<script>" not in output
|
||||||
|
assert "onerror" not in output or "&" in output # Should be escaped
|
||||||
|
# Should contain escaped versions
|
||||||
|
assert "<script>" in output or "<" in output
|
||||||
|
assert "&" in output # & should be escaped
|
||||||
|
|
||||||
|
def test_json_formatter_produces_valid_json_for_all_cases(self) -> None:
|
||||||
|
"""JSON formatter must always produce valid JSON."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
test_cases = [
|
||||||
|
make_empty_results(),
|
||||||
|
[[{
|
||||||
|
"model": "test",
|
||||||
|
"suite_name": "test",
|
||||||
|
"rubric": None,
|
||||||
|
"cases": [{
|
||||||
|
"name": "test",
|
||||||
|
"input": "test with \"quotes\" and \n newlines",
|
||||||
|
"evaluation": MockEvaluation(passed=True),
|
||||||
|
}],
|
||||||
|
}]],
|
||||||
|
]
|
||||||
|
|
||||||
|
formatter = JsonFormatter()
|
||||||
|
for results in test_cases:
|
||||||
|
output = formatter.format(results)
|
||||||
|
# Should be valid JSON (this will raise if invalid)
|
||||||
|
parsed = json.loads(output)
|
||||||
|
assert isinstance(parsed, dict)
|
||||||
|
assert "summary" in parsed
|
||||||
|
|
||||||
|
def test_formatters_with_suite_name_none(self) -> None:
|
||||||
|
"""Formatters should handle None suite_name gracefully."""
|
||||||
|
results = [[{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"suite_name": None, # Explicitly None
|
||||||
|
"rubric": "Test",
|
||||||
|
"cases": [{
|
||||||
|
"name": "test_case",
|
||||||
|
"input": "test",
|
||||||
|
"evaluation": MockEvaluation(passed=True),
|
||||||
|
}],
|
||||||
|
}]]
|
||||||
|
|
||||||
|
for formatter_class in [TextFormatter, MarkdownFormatter, HtmlFormatter, JsonFormatter]:
|
||||||
|
formatter = formatter_class()
|
||||||
|
output = formatter.format(results)
|
||||||
|
assert output
|
||||||
|
# Should use fallback name
|
||||||
|
assert "Unnamed Suite" in output or "unnamed" in output.lower()
|
||||||
|
|
||||||
|
def test_pass_rate_calculation_edge_cases(self) -> None:
|
||||||
|
"""Test pass rate calculation in various edge cases."""
|
||||||
|
# Case 1: All passed
|
||||||
|
results_all_passed = [[{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"suite_name": "test",
|
||||||
|
"rubric": "Test",
|
||||||
|
"cases": [
|
||||||
|
{"name": f"case_{i}", "input": "test", "evaluation": MockEvaluation(passed=True)}
|
||||||
|
for i in range(5)
|
||||||
|
],
|
||||||
|
}]]
|
||||||
|
|
||||||
|
# Case 2: All failed
|
||||||
|
results_all_failed = [[{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"suite_name": "test",
|
||||||
|
"rubric": "Test",
|
||||||
|
"cases": [
|
||||||
|
{"name": f"case_{i}", "input": "test", "evaluation": MockEvaluation(passed=False, score=0.0)}
|
||||||
|
for i in range(5)
|
||||||
|
],
|
||||||
|
}]]
|
||||||
|
|
||||||
|
formatter = JsonFormatter()
|
||||||
|
|
||||||
|
# All passed should show 100% pass rate
|
||||||
|
output_passed = formatter.format(results_all_passed)
|
||||||
|
assert "100" in output_passed or "100.0" in output_passed
|
||||||
|
|
||||||
|
# All failed should show 0% pass rate
|
||||||
|
output_failed = formatter.format(results_all_failed)
|
||||||
|
assert '"pass_rate": 0' in output_failed or '"pass_rate": 0.0' in output_failed
|
||||||
|
|
||||||
|
def test_comparative_with_none_evaluation(self) -> None:
|
||||||
|
"""Comparative formatters should handle None evaluation gracefully."""
|
||||||
|
# Simulate a track result with missing evaluation (edge case)
|
||||||
|
# This could happen if there was an error during evaluation
|
||||||
|
# Note: In real usage, group_comparative_by_case would build the tracks dict
|
||||||
|
# from cases, so we need to test this at the formatting level where
|
||||||
|
# the track might not have evaluation data
|
||||||
|
results = [[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"suite_name": "Test Suite [track_a]",
|
||||||
|
"track_name": "track_a",
|
||||||
|
"rubric": None,
|
||||||
|
"cases": [{
|
||||||
|
"name": "test_case",
|
||||||
|
"input": "test",
|
||||||
|
"evaluation": MockEvaluation(passed=True, score=1.0, results=[
|
||||||
|
{
|
||||||
|
"field": "test",
|
||||||
|
"match": True,
|
||||||
|
"score": 1.0,
|
||||||
|
"weight": 1.0,
|
||||||
|
"expected": "test",
|
||||||
|
"actual": "test",
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
# track_b exists but has no cases (edge case where data is missing)
|
||||||
|
]]
|
||||||
|
|
||||||
|
# All formatters should handle missing track data without crashing
|
||||||
|
for formatter_class in [TextFormatter, MarkdownFormatter, HtmlFormatter, JsonFormatter]:
|
||||||
|
formatter = formatter_class()
|
||||||
|
output = formatter.format(results)
|
||||||
|
# Should produce output
|
||||||
|
assert output
|
||||||
|
# Should show the track that exists
|
||||||
|
assert "track_a" in output or "Track" in output or "test_case" in output
|
||||||
|
|
||||||
|
def test_comparative_with_no_results_in_evaluation(self) -> None:
|
||||||
|
"""Comparative formatters should handle evaluation without results field."""
|
||||||
|
results = [[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"suite_name": "Test Suite [track_a]",
|
||||||
|
"track_name": "track_a",
|
||||||
|
"rubric": None,
|
||||||
|
"cases": [{
|
||||||
|
"name": "test_case",
|
||||||
|
"input": "test",
|
||||||
|
"evaluation": MockEvaluation(passed=True, score=1.0, results=[]), # Empty results
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
]]
|
||||||
|
|
||||||
|
for formatter_class in [TextFormatter, MarkdownFormatter, HtmlFormatter, JsonFormatter]:
|
||||||
|
formatter = formatter_class()
|
||||||
|
# Should not crash with empty results
|
||||||
|
output = formatter.format(results, show_details=True)
|
||||||
|
assert output
|
||||||
2794
libs/tests/cli/test_formatters.py
Normal file
2794
libs/tests/cli/test_formatters.py
Normal file
File diff suppressed because it is too large
Load diff
351
libs/tests/cli/test_main_evals.py
Normal file
351
libs/tests/cli/test_main_evals.py
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
import re
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_cli.main import cli
|
||||||
|
from arcade_cli.utils import filter_failed_evaluations
|
||||||
|
from arcade_evals.eval import EvaluationResult
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
# Mark all tests in this module as requiring evals dependencies
|
||||||
|
pytestmark = pytest.mark.evals
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_ansi(text: str) -> str:
|
||||||
|
return _ANSI_ESCAPE_RE.sub("", text)
|
||||||
|
|
||||||
|
|
||||||
|
def create_mock_evaluation_result(passed: bool, warning: bool, score: float) -> Mock:
|
||||||
|
"""Create a mock EvaluationResult with the specified properties."""
|
||||||
|
evaluation = Mock(spec=EvaluationResult)
|
||||||
|
evaluation.passed = passed
|
||||||
|
evaluation.warning = warning
|
||||||
|
evaluation.score = score
|
||||||
|
evaluation.failure_reason = None
|
||||||
|
evaluation.results = []
|
||||||
|
return evaluation
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_failed_evaluations_mixed_results() -> None:
|
||||||
|
"""Test filtering logic with mixed passed, failed, and warned cases."""
|
||||||
|
all_evaluations = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Passed Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=True, warning=False, score=0.95
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Warning Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=True, score=0.85
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Failed Case 1",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=False, score=0.3
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Failed Case 2",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=False, score=0.2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
filtered_evaluations, original_counts = filter_failed_evaluations(all_evaluations)
|
||||||
|
|
||||||
|
# Verify original counts
|
||||||
|
assert original_counts == (4, 1, 2, 1)
|
||||||
|
|
||||||
|
# Verify filtered results only contain failed cases
|
||||||
|
assert len(filtered_evaluations) == 1
|
||||||
|
assert len(filtered_evaluations[0]) == 1
|
||||||
|
assert len(filtered_evaluations[0][0]["cases"]) == 2
|
||||||
|
assert filtered_evaluations[0][0]["cases"][0]["name"] == "Failed Case 1"
|
||||||
|
assert filtered_evaluations[0][0]["cases"][1]["name"] == "Failed Case 2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_failed_evaluations_all_passed() -> None:
|
||||||
|
"""Test filtering when all cases passed (should return empty)."""
|
||||||
|
all_evaluations = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Passed Case 1",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=True, warning=False, score=0.95
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Passed Case 2",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=True, warning=False, score=0.98
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
filtered_evaluations, original_counts = filter_failed_evaluations(all_evaluations)
|
||||||
|
|
||||||
|
# Verify original counts
|
||||||
|
assert original_counts == (2, 2, 0, 0)
|
||||||
|
|
||||||
|
# Verify filtered results are empty (no failed cases)
|
||||||
|
assert len(filtered_evaluations) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_failed_evaluations_multiple_suites() -> None:
|
||||||
|
"""Test filtering with multiple eval suites."""
|
||||||
|
all_evaluations = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric 1",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Passed Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=True, warning=False, score=0.95
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Failed Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=False, score=0.3
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric 2",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Failed Case 2",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=False, score=0.2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
filtered_evaluations, original_counts = filter_failed_evaluations(all_evaluations)
|
||||||
|
|
||||||
|
# Verify original counts
|
||||||
|
assert original_counts == (3, 1, 2, 0)
|
||||||
|
|
||||||
|
# Verify filtered results
|
||||||
|
assert len(filtered_evaluations) == 2
|
||||||
|
assert len(filtered_evaluations[0][0]["cases"]) == 1
|
||||||
|
assert len(filtered_evaluations[1][0]["cases"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_failed_evaluations_multiple_models() -> None:
|
||||||
|
"""Test filtering with multiple models in same suite."""
|
||||||
|
all_evaluations = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Failed Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=False, score=0.3
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "gpt-3.5-turbo",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Passed Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=True, warning=False, score=0.95
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Failed Case 2",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=False, score=0.2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
filtered_evaluations, original_counts = filter_failed_evaluations(all_evaluations)
|
||||||
|
|
||||||
|
# Verify original counts
|
||||||
|
assert original_counts == (3, 1, 2, 0)
|
||||||
|
|
||||||
|
# Verify filtered results - should have both models with failed cases
|
||||||
|
assert len(filtered_evaluations) == 1
|
||||||
|
assert len(filtered_evaluations[0]) == 2 # Both models have failed cases
|
||||||
|
assert len(filtered_evaluations[0][0]["cases"]) == 1 # First model has 1 failed
|
||||||
|
assert len(filtered_evaluations[0][1]["cases"]) == 1 # Second model has 1 failed
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_failed_evaluations_model_with_no_failed() -> None:
|
||||||
|
"""Test filtering when one model has no failed cases."""
|
||||||
|
all_evaluations = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Passed Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=True, warning=False, score=0.95
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "gpt-3.5-turbo",
|
||||||
|
"rubric": "Test Rubric",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "Failed Case",
|
||||||
|
"input": "Test input",
|
||||||
|
"evaluation": create_mock_evaluation_result(
|
||||||
|
passed=False, warning=False, score=0.3
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
filtered_evaluations, original_counts = filter_failed_evaluations(all_evaluations)
|
||||||
|
|
||||||
|
# Verify original counts
|
||||||
|
assert original_counts == (2, 1, 1, 0)
|
||||||
|
|
||||||
|
# Verify filtered results - only second model should be included
|
||||||
|
assert len(filtered_evaluations) == 1
|
||||||
|
assert len(filtered_evaluations[0]) == 1 # Only one model with failed cases
|
||||||
|
assert filtered_evaluations[0][0]["model"] == "gpt-3.5-turbo"
|
||||||
|
assert len(filtered_evaluations[0][0]["cases"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- CLI Capture Mode Flag Tests ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_evals_help_shows_capture_flag() -> None:
|
||||||
|
"""Test that --capture flag is documented in help."""
|
||||||
|
result = runner.invoke(cli, ["evals", "--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
output = _strip_ansi(result.output)
|
||||||
|
assert "--capture" in output
|
||||||
|
assert "capture mode" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_evals_help_shows_include_context_flag() -> None:
|
||||||
|
"""Test that --include-context flag is documented in help."""
|
||||||
|
result = runner.invoke(cli, ["evals", "--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
output = _strip_ansi(result.output)
|
||||||
|
assert "--include-context" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_evals_help_shows_file_flag() -> None:
|
||||||
|
"""Test that --file flag is documented in help (deprecated, now hidden)."""
|
||||||
|
result = runner.invoke(cli, ["evals", "--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
output = _strip_ansi(result.output)
|
||||||
|
# Old flag is hidden, new --output should show
|
||||||
|
assert "--output" in output or "-o" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_evals_help_shows_format_flag() -> None:
|
||||||
|
"""Test that --format flag is documented in help (deprecated, now uses --output)."""
|
||||||
|
result = runner.invoke(cli, ["evals", "--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
output = _strip_ansi(result.output)
|
||||||
|
# New --output flag should show formats
|
||||||
|
assert "--output" in output
|
||||||
|
|
||||||
|
|
||||||
|
# --- New CLI Flags Tests (addressing Eric's review) ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_evals_help_shows_output_flag() -> None:
|
||||||
|
"""Test that --output/-o flag is documented in help."""
|
||||||
|
result = runner.invoke(cli, ["evals", "--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
output = _strip_ansi(result.output)
|
||||||
|
assert "--output" in output or "-o" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_evals_help_shows_api_key_flag() -> None:
|
||||||
|
"""Test that --api-key flag is documented in help."""
|
||||||
|
result = runner.invoke(cli, ["evals", "--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
output = _strip_ansi(result.output)
|
||||||
|
assert "--api-key" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_evals_help_shows_only_failed_flag() -> None:
|
||||||
|
"""Test that --only-failed flag is documented in help."""
|
||||||
|
result = runner.invoke(cli, ["evals", "--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
output = _strip_ansi(result.output)
|
||||||
|
assert "--only-failed" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_evals_help_shows_host_flag() -> None:
|
||||||
|
"""Test that --host flag is documented in help."""
|
||||||
|
result = runner.invoke(cli, ["evals", "--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
output = _strip_ansi(result.output)
|
||||||
|
assert "--host" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_evals_help_shows_port_flag() -> None:
|
||||||
|
"""Test that --port flag is documented in help."""
|
||||||
|
result = runner.invoke(cli, ["evals", "--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
output = _strip_ansi(result.output)
|
||||||
|
assert "--port" in output
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import tempfile
|
import tempfile
|
||||||
from io import StringIO
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
|
||||||
371
libs/tests/cli/test_utils_multi_provider.py
Normal file
371
libs/tests/cli/test_utils_multi_provider.py
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
"""Tests for multi-provider utils functions."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_cli.utils import (
|
||||||
|
ALL_OUTPUT_FORMATS,
|
||||||
|
ModelSpec,
|
||||||
|
Provider,
|
||||||
|
ProviderConfig,
|
||||||
|
expand_provider_configs,
|
||||||
|
get_default_model,
|
||||||
|
parse_api_key_spec,
|
||||||
|
parse_output_paths,
|
||||||
|
parse_provider_spec,
|
||||||
|
resolve_provider_api_keys,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseProviderSpec:
|
||||||
|
"""Tests for parse_provider_spec function."""
|
||||||
|
|
||||||
|
def test_provider_only_openai(self) -> None:
|
||||||
|
"""Test parsing just provider name."""
|
||||||
|
config = parse_provider_spec("openai")
|
||||||
|
assert config.provider == Provider.OPENAI
|
||||||
|
assert config.models == []
|
||||||
|
|
||||||
|
def test_provider_only_anthropic(self) -> None:
|
||||||
|
"""Test parsing just provider name for anthropic."""
|
||||||
|
config = parse_provider_spec("anthropic")
|
||||||
|
assert config.provider == Provider.ANTHROPIC
|
||||||
|
assert config.models == []
|
||||||
|
|
||||||
|
def test_provider_with_single_model(self) -> None:
|
||||||
|
"""Test parsing provider with single model."""
|
||||||
|
config = parse_provider_spec("openai:gpt-4o")
|
||||||
|
assert config.provider == Provider.OPENAI
|
||||||
|
assert config.models == ["gpt-4o"]
|
||||||
|
|
||||||
|
def test_provider_with_multiple_models(self) -> None:
|
||||||
|
"""Test parsing provider with multiple models."""
|
||||||
|
config = parse_provider_spec("openai:gpt-4o,gpt-4o-mini")
|
||||||
|
assert config.provider == Provider.OPENAI
|
||||||
|
assert config.models == ["gpt-4o", "gpt-4o-mini"]
|
||||||
|
|
||||||
|
def test_anthropic_with_model(self) -> None:
|
||||||
|
"""Test parsing anthropic with model."""
|
||||||
|
config = parse_provider_spec("anthropic:claude-sonnet-4-5-20250929")
|
||||||
|
assert config.provider == Provider.ANTHROPIC
|
||||||
|
assert config.models == ["claude-sonnet-4-5-20250929"]
|
||||||
|
|
||||||
|
def test_strips_whitespace(self) -> None:
|
||||||
|
"""Test that whitespace is stripped from models."""
|
||||||
|
config = parse_provider_spec("openai: gpt-4o , gpt-4o-mini ")
|
||||||
|
assert config.models == ["gpt-4o", "gpt-4o-mini"]
|
||||||
|
|
||||||
|
def test_case_insensitive_provider(self) -> None:
|
||||||
|
"""Test that provider name is case-insensitive."""
|
||||||
|
config = parse_provider_spec("OPENAI:gpt-4o")
|
||||||
|
assert config.provider == Provider.OPENAI
|
||||||
|
|
||||||
|
config2 = parse_provider_spec("OpenAI")
|
||||||
|
assert config2.provider == Provider.OPENAI
|
||||||
|
|
||||||
|
def test_invalid_provider_raises(self) -> None:
|
||||||
|
"""Test that invalid provider raises ValueError."""
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
parse_provider_spec("invalid_provider")
|
||||||
|
assert "Invalid provider" in str(exc_info.value)
|
||||||
|
assert "openai" in str(exc_info.value) # Suggests valid providers
|
||||||
|
|
||||||
|
def test_empty_models_ignored(self) -> None:
|
||||||
|
"""Test that empty model strings are filtered."""
|
||||||
|
config = parse_provider_spec("openai:,gpt-4o,,")
|
||||||
|
assert config.models == ["gpt-4o"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestProviderConfig:
|
||||||
|
"""Tests for ProviderConfig dataclass."""
|
||||||
|
|
||||||
|
def test_get_models_with_explicit_models(self) -> None:
|
||||||
|
"""Test get_models returns explicit models."""
|
||||||
|
config = ProviderConfig(provider=Provider.OPENAI, models=["gpt-4o"])
|
||||||
|
assert config.get_models() == ["gpt-4o"]
|
||||||
|
|
||||||
|
def test_get_models_uses_default_when_empty(self) -> None:
|
||||||
|
"""Test get_models returns default when no models specified."""
|
||||||
|
config = ProviderConfig(provider=Provider.OPENAI, models=[])
|
||||||
|
assert config.get_models() == [get_default_model(Provider.OPENAI)]
|
||||||
|
|
||||||
|
config2 = ProviderConfig(provider=Provider.ANTHROPIC, models=[])
|
||||||
|
assert config2.get_models() == [get_default_model(Provider.ANTHROPIC)]
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelSpec:
|
||||||
|
"""Tests for ModelSpec dataclass."""
|
||||||
|
|
||||||
|
def test_display_name(self) -> None:
|
||||||
|
"""Test display_name property."""
|
||||||
|
spec = ModelSpec(provider=Provider.OPENAI, model="gpt-4o", api_key="key")
|
||||||
|
assert spec.display_name == "openai/gpt-4o"
|
||||||
|
|
||||||
|
spec2 = ModelSpec(provider=Provider.ANTHROPIC, model="claude-3-sonnet", api_key="key")
|
||||||
|
assert spec2.display_name == "anthropic/claude-3-sonnet"
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpandProviderConfigs:
|
||||||
|
"""Tests for expand_provider_configs function."""
|
||||||
|
|
||||||
|
def test_single_provider_single_model(self) -> None:
|
||||||
|
"""Test expanding single provider with single model."""
|
||||||
|
configs = [ProviderConfig(provider=Provider.OPENAI, models=["gpt-4o"])]
|
||||||
|
api_keys = {Provider.OPENAI: "openai-key"}
|
||||||
|
|
||||||
|
specs = expand_provider_configs(configs, api_keys)
|
||||||
|
|
||||||
|
assert len(specs) == 1
|
||||||
|
assert specs[0].provider == Provider.OPENAI
|
||||||
|
assert specs[0].model == "gpt-4o"
|
||||||
|
assert specs[0].api_key == "openai-key"
|
||||||
|
|
||||||
|
def test_single_provider_multiple_models(self) -> None:
|
||||||
|
"""Test expanding single provider with multiple models."""
|
||||||
|
configs = [ProviderConfig(provider=Provider.OPENAI, models=["gpt-4o", "gpt-4o-mini"])]
|
||||||
|
api_keys = {Provider.OPENAI: "openai-key"}
|
||||||
|
|
||||||
|
specs = expand_provider_configs(configs, api_keys)
|
||||||
|
|
||||||
|
assert len(specs) == 2
|
||||||
|
assert specs[0].model == "gpt-4o"
|
||||||
|
assert specs[1].model == "gpt-4o-mini"
|
||||||
|
|
||||||
|
def test_multiple_providers(self) -> None:
|
||||||
|
"""Test expanding multiple providers."""
|
||||||
|
configs = [
|
||||||
|
ProviderConfig(provider=Provider.OPENAI, models=["gpt-4o"]),
|
||||||
|
ProviderConfig(provider=Provider.ANTHROPIC, models=["claude-3-sonnet"]),
|
||||||
|
]
|
||||||
|
api_keys = {
|
||||||
|
Provider.OPENAI: "openai-key",
|
||||||
|
Provider.ANTHROPIC: "anthropic-key",
|
||||||
|
}
|
||||||
|
|
||||||
|
specs = expand_provider_configs(configs, api_keys)
|
||||||
|
|
||||||
|
assert len(specs) == 2
|
||||||
|
assert specs[0].provider == Provider.OPENAI
|
||||||
|
assert specs[0].api_key == "openai-key"
|
||||||
|
assert specs[1].provider == Provider.ANTHROPIC
|
||||||
|
assert specs[1].api_key == "anthropic-key"
|
||||||
|
|
||||||
|
def test_missing_api_key_raises(self) -> None:
|
||||||
|
"""Test that missing API key raises ValueError."""
|
||||||
|
configs = [ProviderConfig(provider=Provider.OPENAI, models=["gpt-4o"])]
|
||||||
|
api_keys = {Provider.OPENAI: None} # No key
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
expand_provider_configs(configs, api_keys)
|
||||||
|
|
||||||
|
assert "API key required" in str(exc_info.value)
|
||||||
|
assert "openai" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_uses_default_model_when_empty(self) -> None:
|
||||||
|
"""Test that empty models list uses default."""
|
||||||
|
configs = [ProviderConfig(provider=Provider.OPENAI, models=[])]
|
||||||
|
api_keys = {Provider.OPENAI: "openai-key"}
|
||||||
|
|
||||||
|
specs = expand_provider_configs(configs, api_keys)
|
||||||
|
|
||||||
|
assert len(specs) == 1
|
||||||
|
assert specs[0].model == get_default_model(Provider.OPENAI)
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveProviderApiKeys:
|
||||||
|
"""Tests for resolve_provider_api_keys function."""
|
||||||
|
|
||||||
|
def test_explicit_keys_take_precedence(self) -> None:
|
||||||
|
"""Test that --api-key takes precedence over environment variables."""
|
||||||
|
with patch.dict(os.environ, {"OPENAI_API_KEY": "env-key"}, clear=False):
|
||||||
|
keys = resolve_provider_api_keys(api_keys_specs=["openai:explicit-key"])
|
||||||
|
assert keys[Provider.OPENAI] == "explicit-key"
|
||||||
|
|
||||||
|
def test_falls_back_to_env_var(self) -> None:
|
||||||
|
"""Test that environment variables are used when no explicit key."""
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{"OPENAI_API_KEY": "env-openai", "ANTHROPIC_API_KEY": "env-anthropic"},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
keys = resolve_provider_api_keys()
|
||||||
|
assert keys[Provider.OPENAI] == "env-openai"
|
||||||
|
assert keys[Provider.ANTHROPIC] == "env-anthropic"
|
||||||
|
|
||||||
|
def test_returns_none_when_not_found(self) -> None:
|
||||||
|
"""Test that None is returned when key not found anywhere."""
|
||||||
|
# Clear env vars and mock dotenv_values
|
||||||
|
with patch.dict(os.environ, {"OPENAI_API_KEY": "", "ANTHROPIC_API_KEY": ""}, clear=False):
|
||||||
|
# Removing keys by setting to empty won't work, so we need to unset them
|
||||||
|
env_copy = os.environ.copy()
|
||||||
|
if "OPENAI_API_KEY" in env_copy:
|
||||||
|
del env_copy["OPENAI_API_KEY"]
|
||||||
|
if "ANTHROPIC_API_KEY" in env_copy:
|
||||||
|
del env_copy["ANTHROPIC_API_KEY"]
|
||||||
|
|
||||||
|
with patch.dict(os.environ, env_copy, clear=True):
|
||||||
|
with patch("dotenv.dotenv_values", return_value={}):
|
||||||
|
keys = resolve_provider_api_keys()
|
||||||
|
# Check structure - values should be None when not found
|
||||||
|
assert Provider.OPENAI in keys
|
||||||
|
assert Provider.ANTHROPIC in keys
|
||||||
|
|
||||||
|
def test_multiple_api_key_specs(self) -> None:
|
||||||
|
"""Test parsing multiple --api-key specs."""
|
||||||
|
keys = resolve_provider_api_keys(
|
||||||
|
api_keys_specs=["openai:openai-key", "anthropic:anthropic-key"]
|
||||||
|
)
|
||||||
|
assert keys[Provider.OPENAI] == "openai-key"
|
||||||
|
assert keys[Provider.ANTHROPIC] == "anthropic-key"
|
||||||
|
|
||||||
|
def test_api_key_specs_override_env_vars(self) -> None:
|
||||||
|
"""Test that --api-key specs override environment variables."""
|
||||||
|
with patch.dict(
|
||||||
|
os.environ, {"OPENAI_API_KEY": "env-key", "ANTHROPIC_API_KEY": "env-key-2"}, clear=False
|
||||||
|
):
|
||||||
|
keys = resolve_provider_api_keys(api_keys_specs=["openai:explicit-key"])
|
||||||
|
assert keys[Provider.OPENAI] == "explicit-key"
|
||||||
|
assert keys[Provider.ANTHROPIC] == "env-key-2" # Not overridden, uses env
|
||||||
|
|
||||||
|
def test_invalid_api_key_spec_raises(self) -> None:
|
||||||
|
"""Test that invalid --api-key spec raises ValueError."""
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
resolve_provider_api_keys(api_keys_specs=["invalid-format"])
|
||||||
|
assert "Invalid --api-key format" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntegration:
|
||||||
|
"""Integration tests for the multi-provider workflow."""
|
||||||
|
|
||||||
|
def test_full_workflow_single_provider(self) -> None:
|
||||||
|
"""Test full workflow from spec parsing to model specs."""
|
||||||
|
# Parse spec
|
||||||
|
config = parse_provider_spec("openai:gpt-4o")
|
||||||
|
|
||||||
|
# Expand with key
|
||||||
|
specs = expand_provider_configs(
|
||||||
|
[config],
|
||||||
|
{Provider.OPENAI: "test-key"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(specs) == 1
|
||||||
|
assert specs[0].display_name == "openai/gpt-4o"
|
||||||
|
assert specs[0].api_key == "test-key"
|
||||||
|
|
||||||
|
def test_full_workflow_multi_provider(self) -> None:
|
||||||
|
"""Test full workflow with multiple providers."""
|
||||||
|
# Parse multiple specs
|
||||||
|
specs_str = ["openai:gpt-4o,gpt-4o-mini", "anthropic:claude-3-sonnet"]
|
||||||
|
configs = [parse_provider_spec(s) for s in specs_str]
|
||||||
|
|
||||||
|
# Expand with keys
|
||||||
|
api_keys = {
|
||||||
|
Provider.OPENAI: "openai-key",
|
||||||
|
Provider.ANTHROPIC: "anthropic-key",
|
||||||
|
}
|
||||||
|
specs = expand_provider_configs(configs, api_keys)
|
||||||
|
|
||||||
|
assert len(specs) == 3 # 2 OpenAI + 1 Anthropic
|
||||||
|
assert specs[0].display_name == "openai/gpt-4o"
|
||||||
|
assert specs[1].display_name == "openai/gpt-4o-mini"
|
||||||
|
assert specs[2].display_name == "anthropic/claude-3-sonnet"
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseApiKeySpec:
|
||||||
|
"""Tests for parse_api_key_spec function."""
|
||||||
|
|
||||||
|
def test_parse_openai_key(self) -> None:
|
||||||
|
"""Test parsing OpenAI API key."""
|
||||||
|
provider, key = parse_api_key_spec("openai:sk-test123")
|
||||||
|
assert provider == Provider.OPENAI
|
||||||
|
assert key == "sk-test123"
|
||||||
|
|
||||||
|
def test_parse_anthropic_key(self) -> None:
|
||||||
|
"""Test parsing Anthropic API key."""
|
||||||
|
provider, key = parse_api_key_spec("anthropic:sk-ant-test456")
|
||||||
|
assert provider == Provider.ANTHROPIC
|
||||||
|
assert key == "sk-ant-test456"
|
||||||
|
|
||||||
|
def test_strips_whitespace(self) -> None:
|
||||||
|
"""Test that whitespace is stripped."""
|
||||||
|
provider, key = parse_api_key_spec(" openai : sk-test ")
|
||||||
|
assert provider == Provider.OPENAI
|
||||||
|
assert key == "sk-test"
|
||||||
|
|
||||||
|
def test_case_insensitive_provider(self) -> None:
|
||||||
|
"""Test that provider name is case-insensitive."""
|
||||||
|
provider, key = parse_api_key_spec("OPENAI:sk-test")
|
||||||
|
assert provider == Provider.OPENAI
|
||||||
|
|
||||||
|
def test_missing_colon_raises(self) -> None:
|
||||||
|
"""Test that missing colon raises ValueError."""
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
parse_api_key_spec("openai-key-without-colon")
|
||||||
|
assert "Invalid --api-key format" in str(exc_info.value)
|
||||||
|
assert "provider:key" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_empty_key_raises(self) -> None:
|
||||||
|
"""Test that empty key raises ValueError."""
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
parse_api_key_spec("openai:")
|
||||||
|
assert "Empty API key" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_invalid_provider_raises(self) -> None:
|
||||||
|
"""Test that invalid provider raises ValueError."""
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
parse_api_key_spec("invalid:sk-test")
|
||||||
|
assert "Invalid provider" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseOutputPaths:
|
||||||
|
"""Tests for parse_output_paths function."""
|
||||||
|
|
||||||
|
def test_single_path_with_extension(self) -> None:
|
||||||
|
"""Test parsing single path with extension."""
|
||||||
|
base, formats = parse_output_paths(["results.json"])
|
||||||
|
assert base == "results"
|
||||||
|
assert formats == ["json"]
|
||||||
|
|
||||||
|
def test_multiple_paths_same_base(self) -> None:
|
||||||
|
"""Test parsing multiple paths with same base."""
|
||||||
|
base, formats = parse_output_paths(["results.md", "results.html"])
|
||||||
|
assert base == "results"
|
||||||
|
assert set(formats) == {"md", "html"}
|
||||||
|
|
||||||
|
def test_path_without_extension_returns_all_formats(self) -> None:
|
||||||
|
"""Test that path without extension returns all formats."""
|
||||||
|
base, formats = parse_output_paths(["results"])
|
||||||
|
assert base == "results"
|
||||||
|
assert formats == ALL_OUTPUT_FORMATS
|
||||||
|
|
||||||
|
def test_path_with_directory(self) -> None:
|
||||||
|
"""Test parsing path with directory."""
|
||||||
|
base, formats = parse_output_paths(["output/results.json"])
|
||||||
|
assert base == "output/results"
|
||||||
|
assert formats == ["json"]
|
||||||
|
|
||||||
|
def test_none_returns_empty(self) -> None:
|
||||||
|
"""Test that None returns (None, [])."""
|
||||||
|
base, formats = parse_output_paths(None)
|
||||||
|
assert base is None
|
||||||
|
assert formats == []
|
||||||
|
|
||||||
|
def test_empty_list_returns_empty(self) -> None:
|
||||||
|
"""Test that empty list returns (None, [])."""
|
||||||
|
base, formats = parse_output_paths([])
|
||||||
|
assert base is None
|
||||||
|
assert formats == []
|
||||||
|
|
||||||
|
def test_invalid_extension_raises(self) -> None:
|
||||||
|
"""Test that invalid extension raises ValueError."""
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
parse_output_paths(["results.xlsx"])
|
||||||
|
assert "Invalid output format" in str(exc_info.value)
|
||||||
|
assert ".xlsx" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_inconsistent_base_names_raises(self) -> None:
|
||||||
|
"""Test that inconsistent base names raise ValueError."""
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
parse_output_paths(["results1.md", "results2.html"])
|
||||||
|
assert "different base names" in str(exc_info.value)
|
||||||
|
|
@ -7,6 +7,38 @@ import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
# Check if eval dependencies are available
|
||||||
|
try:
|
||||||
|
import anthropic # noqa: F401
|
||||||
|
import openai # noqa: F401
|
||||||
|
|
||||||
|
EVALS_DEPS_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
EVALS_DEPS_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
"""Register custom markers."""
|
||||||
|
config.addinivalue_line(
|
||||||
|
"markers", "evals: marks tests that require eval dependencies (openai, anthropic, mcp)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_collection_modifyitems(config, items):
|
||||||
|
"""Auto-skip evals tests if dependencies not available.
|
||||||
|
|
||||||
|
Tests are detected as evals tests if they have the @pytest.mark.evals marker.
|
||||||
|
|
||||||
|
"""
|
||||||
|
skip_evals = pytest.mark.skip(
|
||||||
|
reason="Evals dependencies not installed. Install with: uv tool install 'arcade-mcp[evals]'"
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
# Check if test has the @pytest.mark.evals marker
|
||||||
|
if item.get_closest_marker("evals") and not EVALS_DEPS_AVAILABLE:
|
||||||
|
item.add_marker(skip_evals)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def disable_usage_tracking():
|
def disable_usage_tracking():
|
||||||
|
|
|
||||||
752
libs/tests/core/converters/test_anthropic.py
Normal file
752
libs/tests/core/converters/test_anthropic.py
Normal file
|
|
@ -0,0 +1,752 @@
|
||||||
|
"""Tests for Anthropic converter utilities."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_core.catalog import MaterializedTool, ToolMeta, create_func_models
|
||||||
|
from arcade_core.converters.anthropic import (
|
||||||
|
AnthropicInputSchema,
|
||||||
|
_convert_input_parameters_to_json_schema,
|
||||||
|
_convert_value_schema_to_json_schema,
|
||||||
|
_create_tool_schema,
|
||||||
|
to_anthropic,
|
||||||
|
)
|
||||||
|
from arcade_core.schema import (
|
||||||
|
InputParameter,
|
||||||
|
ToolDefinition,
|
||||||
|
ToolInput,
|
||||||
|
ToolkitDefinition,
|
||||||
|
ToolOutput,
|
||||||
|
ToolRequirements,
|
||||||
|
ValueSchema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnthropicConverter:
|
||||||
|
"""Test Anthropic converter functions."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_tool_def(self):
|
||||||
|
"""Create a sample tool definition."""
|
||||||
|
return ToolDefinition(
|
||||||
|
name="calculate",
|
||||||
|
fully_qualified_name="MathToolkit.calculate",
|
||||||
|
description="Perform a calculation",
|
||||||
|
toolkit=ToolkitDefinition(
|
||||||
|
name="MathToolkit",
|
||||||
|
description="Math tools",
|
||||||
|
version="1.0.0",
|
||||||
|
),
|
||||||
|
input=ToolInput(
|
||||||
|
parameters=[
|
||||||
|
InputParameter(
|
||||||
|
name="expression",
|
||||||
|
required=True,
|
||||||
|
description="Math expression to evaluate",
|
||||||
|
value_schema=ValueSchema(val_type="string"),
|
||||||
|
),
|
||||||
|
InputParameter(
|
||||||
|
name="precision",
|
||||||
|
required=False,
|
||||||
|
description="Decimal precision",
|
||||||
|
value_schema=ValueSchema(val_type="integer"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
output=ToolOutput(
|
||||||
|
description="Calculation result",
|
||||||
|
value_schema=ValueSchema(val_type="number"),
|
||||||
|
),
|
||||||
|
requirements=ToolRequirements(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def materialized_tool(self, sample_tool_def):
|
||||||
|
"""Create a materialized tool."""
|
||||||
|
|
||||||
|
def calculate(
|
||||||
|
expression: Annotated[str, "Math expression"] = "1 + 1",
|
||||||
|
precision: Annotated[int, "Decimal precision"] = 2,
|
||||||
|
) -> Annotated[float, "Calculation result"]:
|
||||||
|
"""Perform a calculation."""
|
||||||
|
return round(eval(expression), precision) # noqa: S307
|
||||||
|
|
||||||
|
input_model, output_model = create_func_models(calculate)
|
||||||
|
meta = ToolMeta(module=calculate.__module__, toolkit=sample_tool_def.toolkit.name)
|
||||||
|
return MaterializedTool(
|
||||||
|
tool=calculate,
|
||||||
|
definition=sample_tool_def,
|
||||||
|
meta=meta,
|
||||||
|
input_model=input_model,
|
||||||
|
output_model=output_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_to_anthropic_basic(self, materialized_tool):
|
||||||
|
"""Test basic Anthropic tool conversion."""
|
||||||
|
result = to_anthropic(materialized_tool)
|
||||||
|
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
# Anthropic has flat structure - no "type: function" wrapper
|
||||||
|
assert "type" not in result
|
||||||
|
assert "function" not in result
|
||||||
|
|
||||||
|
# Check top-level fields
|
||||||
|
assert result["name"] == "MathToolkit_calculate"
|
||||||
|
assert result["description"] == "Perform a calculation"
|
||||||
|
assert "input_schema" in result
|
||||||
|
|
||||||
|
def test_function_name_conversion(self, materialized_tool):
|
||||||
|
"""Test that dots in fully_qualified_name are converted to underscores."""
|
||||||
|
result = to_anthropic(materialized_tool)
|
||||||
|
assert result["name"] == "MathToolkit_calculate"
|
||||||
|
|
||||||
|
def test_input_schema_structure(self, materialized_tool):
|
||||||
|
"""Test the structure of input_schema."""
|
||||||
|
result = to_anthropic(materialized_tool)
|
||||||
|
input_schema = result["input_schema"]
|
||||||
|
|
||||||
|
assert input_schema["type"] == "object"
|
||||||
|
assert "properties" in input_schema
|
||||||
|
# Only required parameters should be in required list
|
||||||
|
assert input_schema["required"] == ["expression"]
|
||||||
|
|
||||||
|
def test_no_strict_mode_constraints(self, materialized_tool):
|
||||||
|
"""Test that Anthropic format doesn't have strict mode constraints."""
|
||||||
|
result = to_anthropic(materialized_tool)
|
||||||
|
input_schema = result["input_schema"]
|
||||||
|
|
||||||
|
# No additionalProperties constraint (unlike OpenAI strict mode)
|
||||||
|
assert "additionalProperties" not in input_schema
|
||||||
|
|
||||||
|
# No "strict" flag
|
||||||
|
assert "strict" not in result
|
||||||
|
|
||||||
|
def test_required_parameter_schema(self, materialized_tool):
|
||||||
|
"""Test required parameter schema generation."""
|
||||||
|
result = to_anthropic(materialized_tool)
|
||||||
|
props = result["input_schema"]["properties"]
|
||||||
|
|
||||||
|
expression_prop = props["expression"]
|
||||||
|
assert expression_prop["type"] == "string"
|
||||||
|
assert expression_prop["description"] == "Math expression to evaluate"
|
||||||
|
|
||||||
|
def test_optional_parameter_schema(self, materialized_tool):
|
||||||
|
"""Test optional parameter schema - no null union type like OpenAI."""
|
||||||
|
result = to_anthropic(materialized_tool)
|
||||||
|
props = result["input_schema"]["properties"]
|
||||||
|
|
||||||
|
precision_prop = props["precision"]
|
||||||
|
# Unlike OpenAI, optional parameters should NOT have union type with null
|
||||||
|
assert precision_prop["type"] == "integer"
|
||||||
|
assert precision_prop["description"] == "Decimal precision"
|
||||||
|
|
||||||
|
def test_optional_params_not_in_required(self, materialized_tool):
|
||||||
|
"""Test that only required params are in the required array."""
|
||||||
|
result = to_anthropic(materialized_tool)
|
||||||
|
required = result["input_schema"]["required"]
|
||||||
|
|
||||||
|
assert "expression" in required
|
||||||
|
assert "precision" not in required
|
||||||
|
|
||||||
|
def test_no_parameters_tool(self):
|
||||||
|
"""Test tool with no parameters."""
|
||||||
|
tool_def = ToolDefinition(
|
||||||
|
name="get_time",
|
||||||
|
fully_qualified_name="TimeToolkit.get_time",
|
||||||
|
description="Get current time",
|
||||||
|
toolkit=ToolkitDefinition(name="TimeToolkit"),
|
||||||
|
input=ToolInput(parameters=[]),
|
||||||
|
output=ToolOutput(),
|
||||||
|
requirements=ToolRequirements(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_time() -> Annotated[str, "current time"]:
|
||||||
|
return "2023-01-01T00:00:00Z"
|
||||||
|
|
||||||
|
input_model, output_model = create_func_models(get_time)
|
||||||
|
meta = ToolMeta(module=get_time.__module__, toolkit=tool_def.toolkit.name)
|
||||||
|
mat_tool = MaterializedTool(
|
||||||
|
tool=get_time,
|
||||||
|
definition=tool_def,
|
||||||
|
meta=meta,
|
||||||
|
input_model=input_model,
|
||||||
|
output_model=output_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = to_anthropic(mat_tool)
|
||||||
|
input_schema = result["input_schema"]
|
||||||
|
|
||||||
|
assert input_schema["type"] == "object"
|
||||||
|
assert input_schema["properties"] == {}
|
||||||
|
# No required field when there are no parameters
|
||||||
|
assert "required" not in input_schema
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"arcade_type,expected_json_type",
|
||||||
|
[
|
||||||
|
("string", "string"),
|
||||||
|
("integer", "integer"),
|
||||||
|
("number", "number"),
|
||||||
|
("boolean", "boolean"),
|
||||||
|
("array", "array"),
|
||||||
|
("json", "object"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parameter_type_conversion(self, arcade_type, expected_json_type):
|
||||||
|
"""Test different parameter type conversions."""
|
||||||
|
tool_def = ToolDefinition(
|
||||||
|
name="test",
|
||||||
|
fully_qualified_name="Test.test",
|
||||||
|
description="Test tool",
|
||||||
|
toolkit=ToolkitDefinition(name="Test"),
|
||||||
|
input=ToolInput(
|
||||||
|
parameters=[
|
||||||
|
InputParameter(
|
||||||
|
name="param",
|
||||||
|
required=True,
|
||||||
|
description="Test parameter",
|
||||||
|
value_schema=ValueSchema(val_type=arcade_type),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
output=ToolOutput(),
|
||||||
|
requirements=ToolRequirements(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_func(param: Annotated[str, "Test parameter"]):
|
||||||
|
return param
|
||||||
|
|
||||||
|
input_model, output_model = create_func_models(test_func)
|
||||||
|
meta = ToolMeta(module=test_func.__module__, toolkit=tool_def.toolkit.name)
|
||||||
|
mat_tool = MaterializedTool(
|
||||||
|
tool=test_func,
|
||||||
|
definition=tool_def,
|
||||||
|
meta=meta,
|
||||||
|
input_model=input_model,
|
||||||
|
output_model=output_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = to_anthropic(mat_tool)
|
||||||
|
param_schema = result["input_schema"]["properties"]["param"]
|
||||||
|
assert param_schema["type"] == expected_json_type
|
||||||
|
|
||||||
|
def test_array_parameter_with_inner_type(self):
|
||||||
|
"""Test array parameter with inner type specification."""
|
||||||
|
tool_def = ToolDefinition(
|
||||||
|
name="process_items",
|
||||||
|
fully_qualified_name="ArrayToolkit.process_items",
|
||||||
|
description="Process a list of items",
|
||||||
|
toolkit=ToolkitDefinition(name="ArrayToolkit"),
|
||||||
|
input=ToolInput(
|
||||||
|
parameters=[
|
||||||
|
InputParameter(
|
||||||
|
name="items",
|
||||||
|
required=True,
|
||||||
|
description="List of string items",
|
||||||
|
value_schema=ValueSchema(
|
||||||
|
val_type="array",
|
||||||
|
inner_val_type="string",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
output=ToolOutput(),
|
||||||
|
requirements=ToolRequirements(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def process_items(items: Annotated[list[str], "List of string items"]):
|
||||||
|
return items
|
||||||
|
|
||||||
|
input_model, output_model = create_func_models(process_items)
|
||||||
|
meta = ToolMeta(module=process_items.__module__, toolkit=tool_def.toolkit.name)
|
||||||
|
mat_tool = MaterializedTool(
|
||||||
|
tool=process_items,
|
||||||
|
definition=tool_def,
|
||||||
|
meta=meta,
|
||||||
|
input_model=input_model,
|
||||||
|
output_model=output_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = to_anthropic(mat_tool)
|
||||||
|
param_schema = result["input_schema"]["properties"]["items"]
|
||||||
|
|
||||||
|
assert param_schema["type"] == "array"
|
||||||
|
assert param_schema["items"]["type"] == "string"
|
||||||
|
|
||||||
|
def test_enum_parameter(self):
|
||||||
|
"""Test parameter with enum values."""
|
||||||
|
tool_def = ToolDefinition(
|
||||||
|
name="set_color",
|
||||||
|
fully_qualified_name="ColorToolkit.set_color",
|
||||||
|
description="Set a color",
|
||||||
|
toolkit=ToolkitDefinition(name="ColorToolkit"),
|
||||||
|
input=ToolInput(
|
||||||
|
parameters=[
|
||||||
|
InputParameter(
|
||||||
|
name="color",
|
||||||
|
required=True,
|
||||||
|
description="Color choice",
|
||||||
|
value_schema=ValueSchema(
|
||||||
|
val_type="string",
|
||||||
|
enum=["red", "green", "blue"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
output=ToolOutput(),
|
||||||
|
requirements=ToolRequirements(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_color(color: Annotated[str, "Color choice"]):
|
||||||
|
return color
|
||||||
|
|
||||||
|
input_model, output_model = create_func_models(set_color)
|
||||||
|
meta = ToolMeta(module=set_color.__module__, toolkit=tool_def.toolkit.name)
|
||||||
|
mat_tool = MaterializedTool(
|
||||||
|
tool=set_color,
|
||||||
|
definition=tool_def,
|
||||||
|
meta=meta,
|
||||||
|
input_model=input_model,
|
||||||
|
output_model=output_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = to_anthropic(mat_tool)
|
||||||
|
param_schema = result["input_schema"]["properties"]["color"]
|
||||||
|
|
||||||
|
assert param_schema["type"] == "string"
|
||||||
|
assert param_schema["enum"] == ["red", "green", "blue"]
|
||||||
|
|
||||||
|
def test_array_with_enum_items(self):
|
||||||
|
"""Test array parameter where items have enum values."""
|
||||||
|
tool_def = ToolDefinition(
|
||||||
|
name="set_colors",
|
||||||
|
fully_qualified_name="ColorToolkit.set_colors",
|
||||||
|
description="Set multiple colors",
|
||||||
|
toolkit=ToolkitDefinition(name="ColorToolkit"),
|
||||||
|
input=ToolInput(
|
||||||
|
parameters=[
|
||||||
|
InputParameter(
|
||||||
|
name="colors",
|
||||||
|
required=True,
|
||||||
|
description="List of colors",
|
||||||
|
value_schema=ValueSchema(
|
||||||
|
val_type="array",
|
||||||
|
inner_val_type="string",
|
||||||
|
enum=["red", "green", "blue"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
output=ToolOutput(),
|
||||||
|
requirements=ToolRequirements(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_colors(colors: Annotated[list[str], "List of colors"]):
|
||||||
|
return colors
|
||||||
|
|
||||||
|
input_model, output_model = create_func_models(set_colors)
|
||||||
|
meta = ToolMeta(module=set_colors.__module__, toolkit=tool_def.toolkit.name)
|
||||||
|
mat_tool = MaterializedTool(
|
||||||
|
tool=set_colors,
|
||||||
|
definition=tool_def,
|
||||||
|
meta=meta,
|
||||||
|
input_model=input_model,
|
||||||
|
output_model=output_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = to_anthropic(mat_tool)
|
||||||
|
param_schema = result["input_schema"]["properties"]["colors"]
|
||||||
|
|
||||||
|
assert param_schema["type"] == "array"
|
||||||
|
assert param_schema["items"]["type"] == "string"
|
||||||
|
assert param_schema["items"]["enum"] == ["red", "green", "blue"]
|
||||||
|
|
||||||
|
def test_json_parameter_with_properties(self):
|
||||||
|
"""Test JSON parameter with nested properties."""
|
||||||
|
tool_def = ToolDefinition(
|
||||||
|
name="create_user",
|
||||||
|
fully_qualified_name="UserToolkit.create_user",
|
||||||
|
description="Create a user",
|
||||||
|
toolkit=ToolkitDefinition(name="UserToolkit"),
|
||||||
|
input=ToolInput(
|
||||||
|
parameters=[
|
||||||
|
InputParameter(
|
||||||
|
name="user_data",
|
||||||
|
required=True,
|
||||||
|
description="User information",
|
||||||
|
value_schema=ValueSchema(
|
||||||
|
val_type="json",
|
||||||
|
properties={
|
||||||
|
"name": ValueSchema(val_type="string"),
|
||||||
|
"age": ValueSchema(val_type="integer"),
|
||||||
|
"active": ValueSchema(val_type="boolean"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
output=ToolOutput(),
|
||||||
|
requirements=ToolRequirements(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_user(user_data: Annotated[dict, "User information"]):
|
||||||
|
return user_data
|
||||||
|
|
||||||
|
input_model, output_model = create_func_models(create_user)
|
||||||
|
meta = ToolMeta(module=create_user.__module__, toolkit=tool_def.toolkit.name)
|
||||||
|
mat_tool = MaterializedTool(
|
||||||
|
tool=create_user,
|
||||||
|
definition=tool_def,
|
||||||
|
meta=meta,
|
||||||
|
input_model=input_model,
|
||||||
|
output_model=output_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = to_anthropic(mat_tool)
|
||||||
|
param_schema = result["input_schema"]["properties"]["user_data"]
|
||||||
|
|
||||||
|
assert param_schema["type"] == "object"
|
||||||
|
assert "properties" in param_schema
|
||||||
|
assert param_schema["properties"]["name"]["type"] == "string"
|
||||||
|
assert param_schema["properties"]["age"]["type"] == "integer"
|
||||||
|
assert param_schema["properties"]["active"]["type"] == "boolean"
|
||||||
|
|
||||||
|
def test_multiple_optional_parameters(self):
|
||||||
|
"""Test tool with multiple optional parameters."""
|
||||||
|
tool_def = ToolDefinition(
|
||||||
|
name="search",
|
||||||
|
fully_qualified_name="SearchToolkit.search",
|
||||||
|
description="Search with filters",
|
||||||
|
toolkit=ToolkitDefinition(name="SearchToolkit"),
|
||||||
|
input=ToolInput(
|
||||||
|
parameters=[
|
||||||
|
InputParameter(
|
||||||
|
name="query",
|
||||||
|
required=True,
|
||||||
|
description="Search query",
|
||||||
|
value_schema=ValueSchema(val_type="string"),
|
||||||
|
),
|
||||||
|
InputParameter(
|
||||||
|
name="limit",
|
||||||
|
required=False,
|
||||||
|
description="Result limit",
|
||||||
|
value_schema=ValueSchema(val_type="integer"),
|
||||||
|
),
|
||||||
|
InputParameter(
|
||||||
|
name="include_metadata",
|
||||||
|
required=False,
|
||||||
|
description="Include metadata in results",
|
||||||
|
value_schema=ValueSchema(val_type="boolean"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
output=ToolOutput(),
|
||||||
|
requirements=ToolRequirements(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def search(
|
||||||
|
query: Annotated[str, "Search query"],
|
||||||
|
limit: Annotated[int, "Result limit"] = 10,
|
||||||
|
include_metadata: Annotated[bool, "Include metadata"] = False,
|
||||||
|
):
|
||||||
|
return f"Search results for {query}"
|
||||||
|
|
||||||
|
input_model, output_model = create_func_models(search)
|
||||||
|
meta = ToolMeta(module=search.__module__, toolkit=tool_def.toolkit.name)
|
||||||
|
mat_tool = MaterializedTool(
|
||||||
|
tool=search,
|
||||||
|
definition=tool_def,
|
||||||
|
meta=meta,
|
||||||
|
input_model=input_model,
|
||||||
|
output_model=output_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = to_anthropic(mat_tool)
|
||||||
|
props = result["input_schema"]["properties"]
|
||||||
|
|
||||||
|
# All parameters should have their simple type (no null union)
|
||||||
|
assert props["query"]["type"] == "string"
|
||||||
|
assert props["limit"]["type"] == "integer"
|
||||||
|
assert props["include_metadata"]["type"] == "boolean"
|
||||||
|
|
||||||
|
# Only required parameter should be in required list
|
||||||
|
assert result["input_schema"]["required"] == ["query"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestHelperFunctions:
|
||||||
|
"""Test helper functions used by the converter."""
|
||||||
|
|
||||||
|
def test_create_tool_schema(self):
|
||||||
|
"""Test _create_tool_schema helper function."""
|
||||||
|
input_schema: AnthropicInputSchema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"test": {"type": "string"}},
|
||||||
|
"required": ["test"],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _create_tool_schema("test_func", "Test function", input_schema)
|
||||||
|
|
||||||
|
# Verify flat structure (no "type: function" wrapper)
|
||||||
|
assert "type" not in result
|
||||||
|
assert "function" not in result
|
||||||
|
|
||||||
|
assert result["name"] == "test_func"
|
||||||
|
assert result["description"] == "Test function"
|
||||||
|
assert result["input_schema"] == input_schema
|
||||||
|
|
||||||
|
def test_convert_value_schema_to_json_schema_basic_types(self):
|
||||||
|
"""Test _convert_value_schema_to_json_schema for basic types."""
|
||||||
|
test_cases = [
|
||||||
|
("string", "string"),
|
||||||
|
("integer", "integer"),
|
||||||
|
("number", "number"),
|
||||||
|
("boolean", "boolean"),
|
||||||
|
("json", "object"),
|
||||||
|
("array", "array"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for arcade_type, expected_json_type in test_cases:
|
||||||
|
schema = ValueSchema(val_type=arcade_type)
|
||||||
|
result = _convert_value_schema_to_json_schema(schema)
|
||||||
|
assert result["type"] == expected_json_type
|
||||||
|
|
||||||
|
def test_convert_value_schema_with_enum(self):
|
||||||
|
"""Test _convert_value_schema_to_json_schema with enum values."""
|
||||||
|
schema = ValueSchema(val_type="string", enum=["a", "b", "c"])
|
||||||
|
result = _convert_value_schema_to_json_schema(schema)
|
||||||
|
|
||||||
|
assert result["type"] == "string"
|
||||||
|
assert result["enum"] == ["a", "b", "c"]
|
||||||
|
|
||||||
|
def test_convert_input_parameters_empty_list(self):
|
||||||
|
"""Test _convert_input_parameters_to_json_schema with empty parameters."""
|
||||||
|
result = _convert_input_parameters_to_json_schema([])
|
||||||
|
|
||||||
|
assert result["type"] == "object"
|
||||||
|
assert result["properties"] == {}
|
||||||
|
# No additionalProperties constraint for Anthropic
|
||||||
|
assert "additionalProperties" not in result
|
||||||
|
assert "required" not in result
|
||||||
|
|
||||||
|
def test_convert_input_parameters_with_required_and_optional(self):
|
||||||
|
"""Test _convert_input_parameters_to_json_schema with mixed parameters."""
|
||||||
|
params = [
|
||||||
|
InputParameter(
|
||||||
|
name="required_param",
|
||||||
|
required=True,
|
||||||
|
description="Required parameter",
|
||||||
|
value_schema=ValueSchema(val_type="string"),
|
||||||
|
),
|
||||||
|
InputParameter(
|
||||||
|
name="optional_param",
|
||||||
|
required=False,
|
||||||
|
description="Optional parameter",
|
||||||
|
value_schema=ValueSchema(val_type="integer"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = _convert_input_parameters_to_json_schema(params)
|
||||||
|
|
||||||
|
assert result["type"] == "object"
|
||||||
|
# No additionalProperties for Anthropic
|
||||||
|
assert "additionalProperties" not in result
|
||||||
|
|
||||||
|
# Only required parameter should be in required list
|
||||||
|
assert result["required"] == ["required_param"]
|
||||||
|
|
||||||
|
# Required parameter should have single type
|
||||||
|
assert result["properties"]["required_param"]["type"] == "string"
|
||||||
|
|
||||||
|
# Optional parameter should also have single type (no null union like OpenAI)
|
||||||
|
assert result["properties"]["optional_param"]["type"] == "integer"
|
||||||
|
|
||||||
|
def test_convert_input_parameters_all_optional(self):
|
||||||
|
"""Test that required array is omitted when all parameters are optional."""
|
||||||
|
params = [
|
||||||
|
InputParameter(
|
||||||
|
name="optional_a",
|
||||||
|
required=False,
|
||||||
|
description="Optional A",
|
||||||
|
value_schema=ValueSchema(val_type="string"),
|
||||||
|
),
|
||||||
|
InputParameter(
|
||||||
|
name="optional_b",
|
||||||
|
required=False,
|
||||||
|
description="Optional B",
|
||||||
|
value_schema=ValueSchema(val_type="integer"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = _convert_input_parameters_to_json_schema(params)
|
||||||
|
|
||||||
|
assert result["type"] == "object"
|
||||||
|
assert "required" not in result # No required field when all optional
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnthropicVsOpenAIDifferences:
|
||||||
|
"""Tests that explicitly verify differences between Anthropic and OpenAI formats."""
|
||||||
|
|
||||||
|
def test_no_type_function_wrapper(self):
|
||||||
|
"""Verify Anthropic format doesn't have OpenAI's 'type: function' wrapper."""
|
||||||
|
tool_def = ToolDefinition(
|
||||||
|
name="test",
|
||||||
|
fully_qualified_name="Test.test",
|
||||||
|
description="Test",
|
||||||
|
toolkit=ToolkitDefinition(name="Test"),
|
||||||
|
input=ToolInput(parameters=[]),
|
||||||
|
output=ToolOutput(),
|
||||||
|
requirements=ToolRequirements(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_func() -> str:
|
||||||
|
"""Test func."""
|
||||||
|
return "test"
|
||||||
|
|
||||||
|
input_model, output_model = create_func_models(test_func)
|
||||||
|
meta = ToolMeta(module=test_func.__module__, toolkit=tool_def.toolkit.name)
|
||||||
|
mat_tool = MaterializedTool(
|
||||||
|
tool=test_func,
|
||||||
|
definition=tool_def,
|
||||||
|
meta=meta,
|
||||||
|
input_model=input_model,
|
||||||
|
output_model=output_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = to_anthropic(mat_tool)
|
||||||
|
|
||||||
|
# OpenAI has: {"type": "function", "function": {...}}
|
||||||
|
# Anthropic should NOT have this wrapper
|
||||||
|
assert "type" not in result
|
||||||
|
assert "function" not in result
|
||||||
|
|
||||||
|
def test_input_schema_key_not_parameters(self):
|
||||||
|
"""Verify Anthropic uses 'input_schema' not 'parameters'."""
|
||||||
|
tool_def = ToolDefinition(
|
||||||
|
name="test",
|
||||||
|
fully_qualified_name="Test.test",
|
||||||
|
description="Test",
|
||||||
|
toolkit=ToolkitDefinition(name="Test"),
|
||||||
|
input=ToolInput(
|
||||||
|
parameters=[
|
||||||
|
InputParameter(
|
||||||
|
name="param",
|
||||||
|
required=True,
|
||||||
|
description="A param",
|
||||||
|
value_schema=ValueSchema(val_type="string"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
output=ToolOutput(),
|
||||||
|
requirements=ToolRequirements(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_func(param: Annotated[str, "A param"]) -> str:
|
||||||
|
"""Test func."""
|
||||||
|
return param
|
||||||
|
|
||||||
|
input_model, output_model = create_func_models(test_func)
|
||||||
|
meta = ToolMeta(module=test_func.__module__, toolkit=tool_def.toolkit.name)
|
||||||
|
mat_tool = MaterializedTool(
|
||||||
|
tool=test_func,
|
||||||
|
definition=tool_def,
|
||||||
|
meta=meta,
|
||||||
|
input_model=input_model,
|
||||||
|
output_model=output_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = to_anthropic(mat_tool)
|
||||||
|
|
||||||
|
# Anthropic uses input_schema, not parameters
|
||||||
|
assert "input_schema" in result
|
||||||
|
assert "parameters" not in result
|
||||||
|
|
||||||
|
def test_no_strict_flag(self):
|
||||||
|
"""Verify Anthropic format doesn't have OpenAI's 'strict' flag."""
|
||||||
|
tool_def = ToolDefinition(
|
||||||
|
name="test",
|
||||||
|
fully_qualified_name="Test.test",
|
||||||
|
description="Test",
|
||||||
|
toolkit=ToolkitDefinition(name="Test"),
|
||||||
|
input=ToolInput(parameters=[]),
|
||||||
|
output=ToolOutput(),
|
||||||
|
requirements=ToolRequirements(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_func() -> str:
|
||||||
|
"""Test func."""
|
||||||
|
return "test"
|
||||||
|
|
||||||
|
input_model, output_model = create_func_models(test_func)
|
||||||
|
meta = ToolMeta(module=test_func.__module__, toolkit=tool_def.toolkit.name)
|
||||||
|
mat_tool = MaterializedTool(
|
||||||
|
tool=test_func,
|
||||||
|
definition=tool_def,
|
||||||
|
meta=meta,
|
||||||
|
input_model=input_model,
|
||||||
|
output_model=output_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = to_anthropic(mat_tool)
|
||||||
|
|
||||||
|
# Anthropic should NOT have strict flag
|
||||||
|
assert "strict" not in result
|
||||||
|
|
||||||
|
def test_optional_params_no_null_union(self):
|
||||||
|
"""Verify optional params don't get null union type (OpenAI strict mode behavior)."""
|
||||||
|
tool_def = ToolDefinition(
|
||||||
|
name="test",
|
||||||
|
fully_qualified_name="Test.test",
|
||||||
|
description="Test",
|
||||||
|
toolkit=ToolkitDefinition(name="Test"),
|
||||||
|
input=ToolInput(
|
||||||
|
parameters=[
|
||||||
|
InputParameter(
|
||||||
|
name="optional_param",
|
||||||
|
required=False,
|
||||||
|
description="Optional",
|
||||||
|
value_schema=ValueSchema(val_type="string"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
output=ToolOutput(),
|
||||||
|
requirements=ToolRequirements(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_func(optional_param: Annotated[str, "Optional"] = "default") -> str:
|
||||||
|
"""Test func."""
|
||||||
|
return optional_param
|
||||||
|
|
||||||
|
input_model, output_model = create_func_models(test_func)
|
||||||
|
meta = ToolMeta(module=test_func.__module__, toolkit=tool_def.toolkit.name)
|
||||||
|
mat_tool = MaterializedTool(
|
||||||
|
tool=test_func,
|
||||||
|
definition=tool_def,
|
||||||
|
meta=meta,
|
||||||
|
input_model=input_model,
|
||||||
|
output_model=output_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = to_anthropic(mat_tool)
|
||||||
|
param_type = result["input_schema"]["properties"]["optional_param"]["type"]
|
||||||
|
|
||||||
|
# OpenAI strict mode would have: ["string", "null"]
|
||||||
|
# Anthropic should just have: "string"
|
||||||
|
assert param_type == "string"
|
||||||
|
assert not isinstance(param_type, list)
|
||||||
|
|
||||||
|
def test_no_additional_properties_constraint(self):
|
||||||
|
"""Verify Anthropic format doesn't require additionalProperties: false."""
|
||||||
|
params = [
|
||||||
|
InputParameter(
|
||||||
|
name="param",
|
||||||
|
required=True,
|
||||||
|
description="Param",
|
||||||
|
value_schema=ValueSchema(val_type="string"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
result = _convert_input_parameters_to_json_schema(params)
|
||||||
|
|
||||||
|
# OpenAI strict mode requires: "additionalProperties": false
|
||||||
|
# Anthropic should NOT have this constraint
|
||||||
|
assert "additionalProperties" not in result
|
||||||
|
|
@ -5,10 +5,7 @@ from typing import Annotated
|
||||||
import pytest
|
import pytest
|
||||||
from arcade_core.catalog import MaterializedTool, ToolMeta, create_func_models
|
from arcade_core.catalog import MaterializedTool, ToolMeta, create_func_models
|
||||||
from arcade_core.converters.openai import (
|
from arcade_core.converters.openai import (
|
||||||
OpenAIFunctionParameterProperty,
|
|
||||||
OpenAIFunctionParameters,
|
OpenAIFunctionParameters,
|
||||||
OpenAIFunctionSchema,
|
|
||||||
OpenAIToolSchema,
|
|
||||||
_convert_input_parameters_to_json_schema,
|
_convert_input_parameters_to_json_schema,
|
||||||
_convert_value_schema_to_json_schema,
|
_convert_value_schema_to_json_schema,
|
||||||
_create_tool_schema,
|
_create_tool_schema,
|
||||||
|
|
|
||||||
92
libs/tests/core/test_converter_utils.py
Normal file
92
libs/tests/core/test_converter_utils.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
"""Tests for arcade_core.converters.utils module."""
|
||||||
|
|
||||||
|
from arcade_core.converters.utils import denormalize_tool_name, normalize_tool_name
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeToolName:
|
||||||
|
"""Tests for normalize_tool_name function."""
|
||||||
|
|
||||||
|
def test_simple_dot_notation(self):
|
||||||
|
"""Test converting simple dot notation to underscores."""
|
||||||
|
assert normalize_tool_name("Google.Search") == "Google_Search"
|
||||||
|
|
||||||
|
def test_multiple_dots(self):
|
||||||
|
"""Test converting multiple dots."""
|
||||||
|
assert normalize_tool_name("Namespace.Sub.Tool") == "Namespace_Sub_Tool"
|
||||||
|
|
||||||
|
def test_no_dots(self):
|
||||||
|
"""Test that names without dots are unchanged."""
|
||||||
|
assert normalize_tool_name("MyTool") == "MyTool"
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
"""Test empty string input."""
|
||||||
|
assert normalize_tool_name("") == ""
|
||||||
|
|
||||||
|
def test_underscore_preserved(self):
|
||||||
|
"""Test that existing underscores are preserved."""
|
||||||
|
assert normalize_tool_name("My_Tool.Name") == "My_Tool_Name"
|
||||||
|
|
||||||
|
def test_single_character(self):
|
||||||
|
"""Test single character names."""
|
||||||
|
assert normalize_tool_name("A") == "A"
|
||||||
|
assert normalize_tool_name(".") == "_"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDenormalizeToolName:
|
||||||
|
"""Tests for denormalize_tool_name function."""
|
||||||
|
|
||||||
|
def test_simple_underscore_notation(self):
|
||||||
|
"""Test converting simple underscore notation to dots."""
|
||||||
|
assert denormalize_tool_name("Google_Search") == "Google.Search"
|
||||||
|
|
||||||
|
def test_multiple_underscores(self):
|
||||||
|
"""Test converting multiple underscores."""
|
||||||
|
assert denormalize_tool_name("Namespace_Sub_Tool") == "Namespace.Sub.Tool"
|
||||||
|
|
||||||
|
def test_no_underscores(self):
|
||||||
|
"""Test that names without underscores are unchanged."""
|
||||||
|
assert denormalize_tool_name("MyTool") == "MyTool"
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
"""Test empty string input."""
|
||||||
|
assert denormalize_tool_name("") == ""
|
||||||
|
|
||||||
|
def test_custom_separator(self):
|
||||||
|
"""Test using a custom separator."""
|
||||||
|
assert denormalize_tool_name("Google_Search", separator="::") == "Google::Search"
|
||||||
|
|
||||||
|
def test_single_character(self):
|
||||||
|
"""Test single character names."""
|
||||||
|
assert denormalize_tool_name("A") == "A"
|
||||||
|
assert denormalize_tool_name("_") == "."
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoundTrip:
|
||||||
|
"""Tests for round-trip conversion (normalize then denormalize)."""
|
||||||
|
|
||||||
|
def test_roundtrip_simple(self):
|
||||||
|
"""Test round-trip for simple names without original underscores."""
|
||||||
|
original = "Google.Search"
|
||||||
|
normalized = normalize_tool_name(original)
|
||||||
|
denormalized = denormalize_tool_name(normalized)
|
||||||
|
assert denormalized == original
|
||||||
|
|
||||||
|
def test_roundtrip_multiple_dots(self):
|
||||||
|
"""Test round-trip for names with multiple dots."""
|
||||||
|
original = "Namespace.Sub.Tool"
|
||||||
|
normalized = normalize_tool_name(original)
|
||||||
|
denormalized = denormalize_tool_name(normalized)
|
||||||
|
assert denormalized == original
|
||||||
|
|
||||||
|
def test_roundtrip_with_original_underscores_is_lossy(self):
|
||||||
|
"""Test that round-trip is lossy when original has underscores.
|
||||||
|
|
||||||
|
This documents the known limitation: if the original name contains
|
||||||
|
underscores, denormalization cannot distinguish them from dots.
|
||||||
|
"""
|
||||||
|
original = "My_Tool.Name"
|
||||||
|
normalized = normalize_tool_name(original) # "My_Tool_Name"
|
||||||
|
denormalized = denormalize_tool_name(normalized) # "My.Tool.Name"
|
||||||
|
# This is NOT equal to original - expected behavior
|
||||||
|
assert denormalized != original
|
||||||
|
assert denormalized == "My.Tool.Name"
|
||||||
|
|
@ -9,7 +9,6 @@ This module tests that:
|
||||||
import pytest
|
import pytest
|
||||||
from arcade_core.schema import ToolContext
|
from arcade_core.schema import ToolContext
|
||||||
|
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# Non-Critical Features (No-Op Tests)
|
# Non-Critical Features (No-Op Tests)
|
||||||
# =====================
|
# =====================
|
||||||
|
|
|
||||||
1
libs/tests/sdk/__init__.py
Normal file
1
libs/tests/sdk/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Make sdk tests a package to avoid pytest module name collisions."""
|
||||||
69
libs/tests/sdk/test_errors.py
Normal file
69
libs/tests/sdk/test_errors.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
"""Tests for arcade_evals.errors module."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_evals.errors import EvalError, WeightError
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvalError:
|
||||||
|
"""Tests for EvalError base class."""
|
||||||
|
|
||||||
|
def test_eval_error_is_exception(self) -> None:
|
||||||
|
"""Test that EvalError is an Exception subclass."""
|
||||||
|
assert issubclass(EvalError, Exception)
|
||||||
|
|
||||||
|
def test_eval_error_can_be_raised(self) -> None:
|
||||||
|
"""Test that EvalError can be raised and caught."""
|
||||||
|
with pytest.raises(EvalError) as exc_info:
|
||||||
|
raise EvalError("test error")
|
||||||
|
assert str(exc_info.value) == "test error"
|
||||||
|
|
||||||
|
def test_eval_error_with_no_message(self) -> None:
|
||||||
|
"""Test that EvalError can be raised without a message."""
|
||||||
|
with pytest.raises(EvalError):
|
||||||
|
raise EvalError()
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeightError:
|
||||||
|
"""Tests for WeightError class."""
|
||||||
|
|
||||||
|
def test_weight_error_is_eval_error_subclass(self) -> None:
|
||||||
|
"""Test that WeightError is a subclass of EvalError."""
|
||||||
|
assert issubclass(WeightError, EvalError)
|
||||||
|
|
||||||
|
def test_weight_error_is_exception(self) -> None:
|
||||||
|
"""Test that WeightError is an Exception subclass."""
|
||||||
|
assert issubclass(WeightError, Exception)
|
||||||
|
|
||||||
|
def test_weight_error_can_be_raised(self) -> None:
|
||||||
|
"""Test that WeightError can be raised and caught."""
|
||||||
|
with pytest.raises(WeightError) as exc_info:
|
||||||
|
raise WeightError("invalid weight")
|
||||||
|
assert str(exc_info.value) == "invalid weight"
|
||||||
|
|
||||||
|
def test_weight_error_caught_as_eval_error(self) -> None:
|
||||||
|
"""Test that WeightError can be caught as EvalError."""
|
||||||
|
with pytest.raises(EvalError):
|
||||||
|
raise WeightError("weight constraint violated")
|
||||||
|
|
||||||
|
def test_weight_error_caught_as_exception(self) -> None:
|
||||||
|
"""Test that WeightError can be caught as generic Exception."""
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
raise WeightError("test")
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorImports:
|
||||||
|
"""Tests for error class imports."""
|
||||||
|
|
||||||
|
def test_import_from_errors_module(self) -> None:
|
||||||
|
"""Test that errors can be imported from arcade_evals.errors."""
|
||||||
|
from arcade_evals.errors import EvalError, WeightError
|
||||||
|
|
||||||
|
assert EvalError is not None
|
||||||
|
assert WeightError is not None
|
||||||
|
|
||||||
|
def test_errors_in_module_all(self) -> None:
|
||||||
|
"""Test that errors are in __all__."""
|
||||||
|
from arcade_evals import errors
|
||||||
|
|
||||||
|
assert "EvalError" in errors.__all__
|
||||||
|
assert "WeightError" in errors.__all__
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
from unittest.mock import Mock
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from arcade_evals import (
|
from arcade_evals import (
|
||||||
BinaryCritic,
|
BinaryCritic,
|
||||||
EvalRubric,
|
EvalRubric,
|
||||||
|
ExpectedMCPToolCall,
|
||||||
ExpectedToolCall,
|
ExpectedToolCall,
|
||||||
NamedExpectedToolCall,
|
NamedExpectedToolCall,
|
||||||
NoneCritic,
|
NoneCritic,
|
||||||
|
|
@ -12,6 +11,9 @@ from arcade_evals import (
|
||||||
from arcade_evals.eval import EvalCase, EvalSuite, EvaluationResult
|
from arcade_evals.eval import EvalCase, EvalSuite, EvaluationResult
|
||||||
from arcade_tdk import tool
|
from arcade_tdk import tool
|
||||||
|
|
||||||
|
# Mark all tests in this module as requiring evals dependencies
|
||||||
|
pytestmark = pytest.mark.evals
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
def mock_tool(param1: str):
|
def mock_tool(param1: str):
|
||||||
|
|
@ -57,6 +59,71 @@ def test_evaluation_result_accumulation():
|
||||||
assert evaluation.score == expected_score
|
assert evaluation.score == expected_score
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvaluationResultProperties:
|
||||||
|
"""Tests for EvaluationResult.passed, .fail, and .warn properties."""
|
||||||
|
|
||||||
|
def test_passed_true_no_warning(self):
|
||||||
|
"""Test .passed=True, .fail=False, .warn=False for passing evaluation."""
|
||||||
|
evaluation = EvaluationResult()
|
||||||
|
evaluation.passed = True
|
||||||
|
evaluation.warning = False
|
||||||
|
|
||||||
|
assert evaluation.passed is True
|
||||||
|
assert evaluation.fail is False
|
||||||
|
assert evaluation.warn is False
|
||||||
|
|
||||||
|
def test_passed_true_with_warning(self):
|
||||||
|
"""Test .passed=True, .fail=False, .warn=True for passing with warning."""
|
||||||
|
evaluation = EvaluationResult()
|
||||||
|
evaluation.passed = True
|
||||||
|
evaluation.warning = True
|
||||||
|
|
||||||
|
assert evaluation.passed is True
|
||||||
|
assert evaluation.fail is False
|
||||||
|
assert evaluation.warn is True
|
||||||
|
|
||||||
|
def test_not_passed_with_warning(self):
|
||||||
|
"""Test that warning=True does NOT classify as fail."""
|
||||||
|
evaluation = EvaluationResult()
|
||||||
|
evaluation.passed = False
|
||||||
|
evaluation.warning = True
|
||||||
|
|
||||||
|
# This is the key case: warning should NOT be a fail
|
||||||
|
assert evaluation.passed is False
|
||||||
|
assert evaluation.fail is False # Not passed but warning, so not a fail
|
||||||
|
assert evaluation.warn is True
|
||||||
|
|
||||||
|
def test_not_passed_no_warning_is_fail(self):
|
||||||
|
"""Test .passed=False, .warning=False means it's a real fail."""
|
||||||
|
evaluation = EvaluationResult()
|
||||||
|
evaluation.passed = False
|
||||||
|
evaluation.warning = False
|
||||||
|
|
||||||
|
assert evaluation.passed is False
|
||||||
|
assert evaluation.fail is True
|
||||||
|
assert evaluation.warn is False
|
||||||
|
|
||||||
|
def test_fail_property_distinguishes_warnings_from_failures(self):
|
||||||
|
"""Test that the fail property correctly excludes warnings from failures."""
|
||||||
|
# Case 1: Passed - not a fail
|
||||||
|
passed_eval = EvaluationResult()
|
||||||
|
passed_eval.passed = True
|
||||||
|
passed_eval.warning = False
|
||||||
|
assert passed_eval.fail is False
|
||||||
|
|
||||||
|
# Case 2: Warning (not passed but warning set) - not a fail
|
||||||
|
warning_eval = EvaluationResult()
|
||||||
|
warning_eval.passed = False
|
||||||
|
warning_eval.warning = True
|
||||||
|
assert warning_eval.fail is False
|
||||||
|
|
||||||
|
# Case 3: Actual failure (not passed and not warning) - is a fail
|
||||||
|
failed_eval = EvaluationResult()
|
||||||
|
failed_eval.passed = False
|
||||||
|
failed_eval.warning = False
|
||||||
|
assert failed_eval.fail is True
|
||||||
|
|
||||||
|
|
||||||
# Test EvalCase.evaluate()
|
# Test EvalCase.evaluate()
|
||||||
def test_eval_case_evaluate():
|
def test_eval_case_evaluate():
|
||||||
"""
|
"""
|
||||||
|
|
@ -210,20 +277,11 @@ def test_eval_suite_add_case():
|
||||||
"""
|
"""
|
||||||
Test that add_case correctly adds a new evaluation case to the suite.
|
Test that add_case correctly adds a new evaluation case to the suite.
|
||||||
"""
|
"""
|
||||||
mock_catalog = Mock()
|
suite = EvalSuite(name="TestSuite", system_message="System message")
|
||||||
mock_catalog.find_tool_by_func.return_value.get_fully_qualified_name.return_value = "MockTool"
|
|
||||||
|
|
||||||
suite = EvalSuite(name="TestSuite", system_message="System message", catalog=mock_catalog)
|
|
||||||
|
|
||||||
expected_tool_calls = [
|
expected_tool_calls = [
|
||||||
ExpectedToolCall(
|
ExpectedMCPToolCall(tool_name="MockTool", args={"param1": "value"}),
|
||||||
func=mock_tool,
|
ExpectedMCPToolCall(tool_name="MockTool", args={"param1": "value"}),
|
||||||
args={"param1": "value"},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
mock_tool,
|
|
||||||
{"param1": "value"},
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
suite.add_case(
|
suite.add_case(
|
||||||
|
|
@ -251,20 +309,11 @@ def test_eval_suite_extend_case():
|
||||||
"""
|
"""
|
||||||
Test that extend_case correctly extends the last added case with new information.
|
Test that extend_case correctly extends the last added case with new information.
|
||||||
"""
|
"""
|
||||||
mock_catalog = Mock()
|
suite = EvalSuite(name="TestSuite", system_message="System message")
|
||||||
mock_catalog.find_tool_by_func.return_value.get_fully_qualified_name.return_value = "MockTool"
|
|
||||||
|
|
||||||
suite = EvalSuite(name="TestSuite", system_message="System message", catalog=mock_catalog)
|
|
||||||
|
|
||||||
expected_tool_calls = [
|
expected_tool_calls = [
|
||||||
ExpectedToolCall(
|
ExpectedMCPToolCall(tool_name="MockTool", args={"param1": "value"}),
|
||||||
func=mock_tool,
|
ExpectedMCPToolCall(tool_name="MockTool", args={"param1": "value"}),
|
||||||
args={"param1": "value"},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
mock_tool,
|
|
||||||
{"param1": "value"},
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
suite.add_case(
|
suite.add_case(
|
||||||
|
|
@ -300,8 +349,7 @@ def test_eval_suite_validate_critics_raises_value_error():
|
||||||
"""
|
"""
|
||||||
Test that validate_critics raises a ValueError if multiple critics are detected for the same field.
|
Test that validate_critics raises a ValueError if multiple critics are detected for the same field.
|
||||||
"""
|
"""
|
||||||
mock_catalog = Mock()
|
suite = EvalSuite(name="TestSuite", system_message="System message")
|
||||||
suite = EvalSuite(name="TestSuite", system_message="System message", catalog=mock_catalog)
|
|
||||||
|
|
||||||
case_name = "TestCase"
|
case_name = "TestCase"
|
||||||
critics = [
|
critics = [
|
||||||
|
|
@ -316,8 +364,7 @@ def test_eval_suite_validate_critics_no_error():
|
||||||
"""
|
"""
|
||||||
Test that validate_critics does not raise an error when critics are valid.
|
Test that validate_critics does not raise an error when critics are valid.
|
||||||
"""
|
"""
|
||||||
mock_catalog = Mock()
|
suite = EvalSuite(name="TestSuite", system_message="System message")
|
||||||
suite = EvalSuite(name="TestSuite", system_message="System message", catalog=mock_catalog)
|
|
||||||
|
|
||||||
case_name = "TestCase"
|
case_name = "TestCase"
|
||||||
critics = [
|
critics = [
|
||||||
|
|
@ -402,11 +449,398 @@ def test_eval_suite_validate_critics_no_error():
|
||||||
def test_eval_suite_add_none_critics(
|
def test_eval_suite_add_none_critics(
|
||||||
expected_tool_calls, critics, expected_critics_count, expected_critics_types
|
expected_tool_calls, critics, expected_critics_count, expected_critics_types
|
||||||
):
|
):
|
||||||
mock_catalog = Mock()
|
suite = EvalSuite(name="TestSuite", system_message="System message")
|
||||||
suite = EvalSuite(name="TestSuite", system_message="System message", catalog=mock_catalog)
|
|
||||||
|
|
||||||
critics_with_none = suite._add_none_critics(expected_tool_calls, critics)
|
critics_with_none = suite._add_none_critics(expected_tool_calls, critics)
|
||||||
assert len(critics_with_none) == expected_critics_count
|
assert len(critics_with_none) == expected_critics_count
|
||||||
for i, (expected_type, expected_field) in enumerate(expected_critics_types):
|
for i, (expected_type, expected_field) in enumerate(expected_critics_types):
|
||||||
assert isinstance(critics_with_none[i], expected_type)
|
assert isinstance(critics_with_none[i], expected_type)
|
||||||
assert critics_with_none[i].critic_field == expected_field
|
assert critics_with_none[i].critic_field == expected_field
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tests for ExpectedToolCall and ExpectedMCPToolCall classes
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpectedToolCall:
|
||||||
|
"""Tests for the ExpectedToolCall dataclass (Python tools)."""
|
||||||
|
|
||||||
|
def test_keyword_args(self):
|
||||||
|
"""Test creating with keyword arguments."""
|
||||||
|
tc = ExpectedToolCall(func=mock_tool, args={"param1": "value"})
|
||||||
|
assert tc.func == mock_tool
|
||||||
|
assert tc.args == {"param1": "value"}
|
||||||
|
|
||||||
|
def test_positional_args(self):
|
||||||
|
"""Test creating with positional arguments (restored feature)."""
|
||||||
|
tc = ExpectedToolCall(mock_tool, {"param1": "value"})
|
||||||
|
assert tc.func == mock_tool
|
||||||
|
assert tc.args == {"param1": "value"}
|
||||||
|
|
||||||
|
def test_default_empty_args(self):
|
||||||
|
"""Test that args defaults to empty dict."""
|
||||||
|
tc = ExpectedToolCall(func=mock_tool)
|
||||||
|
assert tc.func == mock_tool
|
||||||
|
assert tc.args == {}
|
||||||
|
|
||||||
|
def test_func_is_required(self):
|
||||||
|
"""Test that func is required (no default)."""
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
ExpectedToolCall() # type: ignore[call-arg]
|
||||||
|
|
||||||
|
def test_func_with_positional_only(self):
|
||||||
|
"""Test creating with just func as positional."""
|
||||||
|
tc = ExpectedToolCall(mock_tool)
|
||||||
|
assert tc.func == mock_tool
|
||||||
|
assert tc.args == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpectedMCPToolCall:
|
||||||
|
"""Tests for the ExpectedMCPToolCall dataclass (MCP tools)."""
|
||||||
|
|
||||||
|
def test_keyword_args(self):
|
||||||
|
"""Test creating with keyword arguments."""
|
||||||
|
tc = ExpectedMCPToolCall(tool_name="Calculator_Add", args={"a": 5, "b": 3})
|
||||||
|
assert tc.tool_name == "Calculator_Add"
|
||||||
|
assert tc.args == {"a": 5, "b": 3}
|
||||||
|
|
||||||
|
def test_positional_args(self):
|
||||||
|
"""Test creating with positional arguments."""
|
||||||
|
tc = ExpectedMCPToolCall("Calculator_Add", {"a": 5, "b": 3})
|
||||||
|
assert tc.tool_name == "Calculator_Add"
|
||||||
|
assert tc.args == {"a": 5, "b": 3}
|
||||||
|
|
||||||
|
def test_default_empty_args(self):
|
||||||
|
"""Test that args defaults to empty dict."""
|
||||||
|
tc = ExpectedMCPToolCall(tool_name="Calculator_Add")
|
||||||
|
assert tc.tool_name == "Calculator_Add"
|
||||||
|
assert tc.args == {}
|
||||||
|
|
||||||
|
def test_tool_name_is_required(self):
|
||||||
|
"""Test that tool_name is required (no default)."""
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
ExpectedMCPToolCall() # type: ignore[call-arg]
|
||||||
|
|
||||||
|
def test_tool_name_with_positional_only(self):
|
||||||
|
"""Test creating with just tool_name as positional."""
|
||||||
|
tc = ExpectedMCPToolCall("MyTool")
|
||||||
|
assert tc.tool_name == "MyTool"
|
||||||
|
assert tc.args == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnyExpectedToolCall:
|
||||||
|
"""Tests for mixed usage of ExpectedToolCall and ExpectedMCPToolCall."""
|
||||||
|
|
||||||
|
def test_import_any_expected_tool_call(self):
|
||||||
|
"""Test that AnyExpectedToolCall can be imported."""
|
||||||
|
from arcade_evals import AnyExpectedToolCall
|
||||||
|
|
||||||
|
# Type alias should work with both types
|
||||||
|
python_tc: AnyExpectedToolCall = ExpectedToolCall(func=mock_tool, args={"param1": "v"})
|
||||||
|
mcp_tc: AnyExpectedToolCall = ExpectedMCPToolCall(tool_name="MyTool", args={"a": 1})
|
||||||
|
assert python_tc is not None
|
||||||
|
assert mcp_tc is not None
|
||||||
|
|
||||||
|
def test_mixed_list(self):
|
||||||
|
"""Test that mixed lists work correctly."""
|
||||||
|
from arcade_evals import AnyExpectedToolCall
|
||||||
|
|
||||||
|
mixed_list: list[AnyExpectedToolCall] = [
|
||||||
|
ExpectedToolCall(func=mock_tool, args={"param1": "value"}),
|
||||||
|
ExpectedMCPToolCall(tool_name="RemoteTool", args={"x": 1}),
|
||||||
|
]
|
||||||
|
assert len(mixed_list) == 2
|
||||||
|
assert isinstance(mixed_list[0], ExpectedToolCall)
|
||||||
|
assert isinstance(mixed_list[1], ExpectedMCPToolCall)
|
||||||
|
|
||||||
|
def test_isinstance_checks(self):
|
||||||
|
"""Test that isinstance works correctly for type narrowing."""
|
||||||
|
from arcade_evals import AnyExpectedToolCall
|
||||||
|
|
||||||
|
tc: AnyExpectedToolCall = ExpectedToolCall(func=mock_tool, args={})
|
||||||
|
|
||||||
|
if isinstance(tc, ExpectedToolCall):
|
||||||
|
assert tc.func == mock_tool
|
||||||
|
elif isinstance(tc, ExpectedMCPToolCall):
|
||||||
|
pytest.fail("Should not reach here")
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpectedToolCallConversion:
|
||||||
|
"""Tests for conversion of ExpectedToolCall types to NamedExpectedToolCall."""
|
||||||
|
|
||||||
|
def test_add_case_with_expected_mcp_tool_call(self):
|
||||||
|
"""Test add_case correctly converts ExpectedMCPToolCall."""
|
||||||
|
suite = EvalSuite(name="TestSuite", system_message="System message")
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="MCP Test",
|
||||||
|
user_message="Test message",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedMCPToolCall(tool_name="RemoteTool", args={"x": 1}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(suite.cases) == 1
|
||||||
|
assert suite.cases[0].expected_tool_calls[0].name == "RemoteTool"
|
||||||
|
assert suite.cases[0].expected_tool_calls[0].args == {"x": 1}
|
||||||
|
|
||||||
|
def test_add_case_with_mixed_tool_calls(self):
|
||||||
|
"""Test add_case with both Python and MCP tools in same case."""
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from arcade_core import ToolCatalog
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def annotated_tool(value: Annotated[str, "The input value"]) -> str:
|
||||||
|
"""A tool with proper annotations."""
|
||||||
|
return value
|
||||||
|
|
||||||
|
catalog = ToolCatalog()
|
||||||
|
catalog.add_tool(annotated_tool, "Test")
|
||||||
|
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="MixedSuite",
|
||||||
|
system_message="System message",
|
||||||
|
catalog=catalog,
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Mixed Test",
|
||||||
|
user_message="Test message",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(func=annotated_tool, args={"value": "test"}),
|
||||||
|
ExpectedMCPToolCall(tool_name="RemoteTool", args={"x": 1}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(suite.cases) == 1
|
||||||
|
case = suite.cases[0]
|
||||||
|
assert len(case.expected_tool_calls) == 2
|
||||||
|
# First is Python tool - name should be toolkit_name + tool_name (PascalCase)
|
||||||
|
assert case.expected_tool_calls[0].name == "Test_AnnotatedTool"
|
||||||
|
# Second is MCP tool - name should be as-is
|
||||||
|
assert case.expected_tool_calls[1].name == "RemoteTool"
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolSelectionFailure:
|
||||||
|
"""Tests for tool selection failure scenarios and partial matching."""
|
||||||
|
|
||||||
|
def test_tool_mismatch_with_fail_on_tool_selection_true(self):
|
||||||
|
"""Test that tool mismatch fails immediately when fail_on_tool_selection=True (default)."""
|
||||||
|
expected_tool_calls = [
|
||||||
|
NamedExpectedToolCall(name="ToolA", args={"param": "value"}),
|
||||||
|
]
|
||||||
|
actual_tool_calls = [
|
||||||
|
("ToolB", {"param": "value"}), # Wrong tool
|
||||||
|
]
|
||||||
|
|
||||||
|
case = EvalCase(
|
||||||
|
name="TestCase",
|
||||||
|
system_message="",
|
||||||
|
user_message="",
|
||||||
|
expected_tool_calls=expected_tool_calls,
|
||||||
|
critics=[BinaryCritic(critic_field="param", weight=1.0)],
|
||||||
|
rubric=EvalRubric(fail_on_tool_selection=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = case.evaluate(actual_tool_calls)
|
||||||
|
|
||||||
|
assert result.score == 0.0
|
||||||
|
assert result.passed is False
|
||||||
|
assert result.failure_reason is not None
|
||||||
|
assert "Tool selection mismatch" in result.failure_reason
|
||||||
|
|
||||||
|
def test_tool_mismatch_with_fail_on_tool_selection_false_partial_scoring(self):
|
||||||
|
"""Test that tool mismatch allows partial scoring when fail_on_tool_selection=False."""
|
||||||
|
expected_tool_calls = [
|
||||||
|
NamedExpectedToolCall(name="ToolA", args={"param": "value"}),
|
||||||
|
]
|
||||||
|
actual_tool_calls = [
|
||||||
|
("ToolB", {"param": "value"}), # Wrong tool but correct param
|
||||||
|
]
|
||||||
|
|
||||||
|
case = EvalCase(
|
||||||
|
name="TestCase",
|
||||||
|
system_message="",
|
||||||
|
user_message="",
|
||||||
|
expected_tool_calls=expected_tool_calls,
|
||||||
|
critics=[BinaryCritic(critic_field="param", weight=1.0)],
|
||||||
|
rubric=EvalRubric(
|
||||||
|
fail_on_tool_selection=False,
|
||||||
|
tool_selection_weight=1.0,
|
||||||
|
fail_threshold=0.3,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = case.evaluate(actual_tool_calls)
|
||||||
|
|
||||||
|
# Tool selection: 0.0 (wrong tool)
|
||||||
|
# Critic (param match): 1.0
|
||||||
|
# Total: 1.0 / 2.0 = 0.5
|
||||||
|
assert result.score == pytest.approx(0.5)
|
||||||
|
assert result.failure_reason is None # No early failure
|
||||||
|
assert result.passed is True # 0.5 >= 0.3 threshold
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolCallQuantityFailure:
|
||||||
|
"""Tests for tool call quantity mismatch scenarios."""
|
||||||
|
|
||||||
|
def test_more_tool_calls_than_expected_fails(self):
|
||||||
|
"""Test that calling the right tool more times than expected fails by default."""
|
||||||
|
expected_tool_calls = [
|
||||||
|
NamedExpectedToolCall(name="ToolA", args={"param": "value"}),
|
||||||
|
]
|
||||||
|
actual_tool_calls = [
|
||||||
|
("ToolA", {"param": "value"}),
|
||||||
|
("ToolA", {"param": "value2"}), # Extra call
|
||||||
|
]
|
||||||
|
|
||||||
|
case = EvalCase(
|
||||||
|
name="TestCase",
|
||||||
|
system_message="",
|
||||||
|
user_message="",
|
||||||
|
expected_tool_calls=expected_tool_calls,
|
||||||
|
critics=[BinaryCritic(critic_field="param", weight=1.0)],
|
||||||
|
rubric=EvalRubric(fail_on_tool_call_quantity=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = case.evaluate(actual_tool_calls)
|
||||||
|
|
||||||
|
assert result.score == 0.0
|
||||||
|
assert result.passed is False
|
||||||
|
assert result.failure_reason is not None
|
||||||
|
assert "Expected 1 tool call(s), but got 2" in result.failure_reason
|
||||||
|
|
||||||
|
def test_fewer_tool_calls_than_expected_fails(self):
|
||||||
|
"""Test that calling fewer tools than expected fails by default."""
|
||||||
|
expected_tool_calls = [
|
||||||
|
NamedExpectedToolCall(name="ToolA", args={"param": "value1"}),
|
||||||
|
NamedExpectedToolCall(name="ToolB", args={"param": "value2"}),
|
||||||
|
]
|
||||||
|
actual_tool_calls = [
|
||||||
|
("ToolA", {"param": "value1"}), # Only one call
|
||||||
|
]
|
||||||
|
|
||||||
|
case = EvalCase(
|
||||||
|
name="TestCase",
|
||||||
|
system_message="",
|
||||||
|
user_message="",
|
||||||
|
expected_tool_calls=expected_tool_calls,
|
||||||
|
critics=[BinaryCritic(critic_field="param", weight=1.0)],
|
||||||
|
rubric=EvalRubric(fail_on_tool_call_quantity=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = case.evaluate(actual_tool_calls)
|
||||||
|
|
||||||
|
assert result.score == 0.0
|
||||||
|
assert result.passed is False
|
||||||
|
assert result.failure_reason is not None
|
||||||
|
assert "Expected 2 tool call(s), but got 1" in result.failure_reason
|
||||||
|
|
||||||
|
def test_quantity_mismatch_with_fail_on_quantity_false(self):
|
||||||
|
"""Test partial scoring when fail_on_tool_call_quantity=False."""
|
||||||
|
expected_tool_calls = [
|
||||||
|
NamedExpectedToolCall(name="ToolA", args={"param": "value"}),
|
||||||
|
]
|
||||||
|
actual_tool_calls = [
|
||||||
|
("ToolA", {"param": "value"}),
|
||||||
|
("ToolA", {"param": "extra"}), # Extra call - should be ignored
|
||||||
|
]
|
||||||
|
|
||||||
|
case = EvalCase(
|
||||||
|
name="TestCase",
|
||||||
|
system_message="",
|
||||||
|
user_message="",
|
||||||
|
expected_tool_calls=expected_tool_calls,
|
||||||
|
critics=[BinaryCritic(critic_field="param", weight=1.0)],
|
||||||
|
rubric=EvalRubric(
|
||||||
|
fail_on_tool_call_quantity=False,
|
||||||
|
fail_on_tool_selection=False,
|
||||||
|
tool_selection_weight=1.0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = case.evaluate(actual_tool_calls)
|
||||||
|
|
||||||
|
# Should not fail early - evaluation continues
|
||||||
|
assert result.failure_reason is None
|
||||||
|
# Score depends on matching logic (Hungarian algorithm matches best pairs)
|
||||||
|
assert result.score > 0.0
|
||||||
|
|
||||||
|
def test_right_tool_called_multiple_times_partial_score(self):
|
||||||
|
"""Test calling the right tool multiple times with quantity check disabled."""
|
||||||
|
expected_tool_calls = [
|
||||||
|
NamedExpectedToolCall(name="Calculator_Add", args={"a": 5, "b": 3}),
|
||||||
|
]
|
||||||
|
actual_tool_calls = [
|
||||||
|
("Calculator_Add", {"a": 5, "b": 3}), # Correct call
|
||||||
|
("Calculator_Add", {"a": 10, "b": 20}), # Extra call with different args
|
||||||
|
]
|
||||||
|
|
||||||
|
case = EvalCase(
|
||||||
|
name="TestCase",
|
||||||
|
system_message="",
|
||||||
|
user_message="",
|
||||||
|
expected_tool_calls=expected_tool_calls,
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="a", weight=0.5),
|
||||||
|
BinaryCritic(critic_field="b", weight=0.5),
|
||||||
|
],
|
||||||
|
rubric=EvalRubric(
|
||||||
|
fail_on_tool_call_quantity=False,
|
||||||
|
fail_on_tool_selection=False,
|
||||||
|
tool_selection_weight=1.0,
|
||||||
|
fail_threshold=0.5,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = case.evaluate(actual_tool_calls)
|
||||||
|
|
||||||
|
# Should not fail immediately
|
||||||
|
assert result.failure_reason is None
|
||||||
|
# The Hungarian algorithm will match expected[0] with the best actual call
|
||||||
|
# First actual call matches perfectly: tool(1.0) + a(0.5) + b(0.5) = 2.0
|
||||||
|
assert result.score > 0.0
|
||||||
|
|
||||||
|
def test_no_tool_calls_when_one_expected_fails(self):
|
||||||
|
"""Test that zero tool calls when some expected fails by default."""
|
||||||
|
expected_tool_calls = [
|
||||||
|
NamedExpectedToolCall(name="ToolA", args={"param": "value"}),
|
||||||
|
]
|
||||||
|
actual_tool_calls = [] # No calls
|
||||||
|
|
||||||
|
case = EvalCase(
|
||||||
|
name="TestCase",
|
||||||
|
system_message="",
|
||||||
|
user_message="",
|
||||||
|
expected_tool_calls=expected_tool_calls,
|
||||||
|
critics=[BinaryCritic(critic_field="param", weight=1.0)],
|
||||||
|
rubric=EvalRubric(fail_on_tool_call_quantity=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = case.evaluate(actual_tool_calls)
|
||||||
|
|
||||||
|
assert result.score == 0.0
|
||||||
|
assert result.passed is False
|
||||||
|
assert "Expected 1 tool call(s), but got 0" in result.failure_reason
|
||||||
|
|
||||||
|
def test_both_empty_passes(self):
|
||||||
|
"""Test that no expected and no actual tool calls results in pass."""
|
||||||
|
expected_tool_calls = []
|
||||||
|
actual_tool_calls = []
|
||||||
|
|
||||||
|
case = EvalCase(
|
||||||
|
name="TestCase",
|
||||||
|
system_message="",
|
||||||
|
user_message="",
|
||||||
|
expected_tool_calls=expected_tool_calls,
|
||||||
|
critics=[],
|
||||||
|
rubric=EvalRubric(),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = case.evaluate(actual_tool_calls)
|
||||||
|
|
||||||
|
assert result.score == 1.0
|
||||||
|
assert result.passed is True
|
||||||
|
assert result.failure_reason is None
|
||||||
|
|
|
||||||
1294
libs/tests/sdk/test_eval_anthropic.py
Normal file
1294
libs/tests/sdk/test_eval_anthropic.py
Normal file
File diff suppressed because it is too large
Load diff
893
libs/tests/sdk/test_eval_capture.py
Normal file
893
libs/tests/sdk/test_eval_capture.py
Normal file
|
|
@ -0,0 +1,893 @@
|
||||||
|
"""
|
||||||
|
Tests for EvalSuite capture mode functionality.
|
||||||
|
|
||||||
|
Capture mode allows running evaluations without scoring - it simply records
|
||||||
|
the tool calls made by the model for debugging or generating expected calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_evals import (
|
||||||
|
CapturedCase,
|
||||||
|
CapturedToolCall,
|
||||||
|
CaptureResult,
|
||||||
|
EvalSuite,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark all tests in this module as requiring evals dependencies
|
||||||
|
pytestmark = pytest.mark.evals
|
||||||
|
|
||||||
|
# --- CapturedToolCall Tests ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapturedToolCall:
|
||||||
|
"""Tests for CapturedToolCall dataclass."""
|
||||||
|
|
||||||
|
def test_create_with_name_and_args(self):
|
||||||
|
"""Test creating a captured tool call with name and args."""
|
||||||
|
tc = CapturedToolCall(name="Weather_GetCurrent", args={"location": "London"})
|
||||||
|
assert tc.name == "Weather_GetCurrent"
|
||||||
|
assert tc.args == {"location": "London"}
|
||||||
|
|
||||||
|
def test_create_with_name_only(self):
|
||||||
|
"""Test creating a captured tool call with default empty args."""
|
||||||
|
tc = CapturedToolCall(name="Weather_GetCurrent")
|
||||||
|
assert tc.name == "Weather_GetCurrent"
|
||||||
|
assert tc.args == {}
|
||||||
|
|
||||||
|
def test_to_dict(self):
|
||||||
|
"""Test to_dict serialization."""
|
||||||
|
tc = CapturedToolCall(name="MyTool", args={"key": "value"})
|
||||||
|
result = tc.to_dict()
|
||||||
|
assert result == {"name": "MyTool", "args": {"key": "value"}}
|
||||||
|
|
||||||
|
def test_to_dict_empty_args(self):
|
||||||
|
"""Test to_dict with empty args."""
|
||||||
|
tc = CapturedToolCall(name="MyTool")
|
||||||
|
result = tc.to_dict()
|
||||||
|
assert result == {"name": "MyTool", "args": {}}
|
||||||
|
|
||||||
|
|
||||||
|
# --- CapturedCase Tests ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapturedCase:
|
||||||
|
"""Tests for CapturedCase dataclass."""
|
||||||
|
|
||||||
|
def test_create_basic(self):
|
||||||
|
"""Test creating a captured case with minimal fields."""
|
||||||
|
case = CapturedCase(
|
||||||
|
case_name="test_case",
|
||||||
|
user_message="Hello",
|
||||||
|
tool_calls=[CapturedToolCall(name="Tool1")],
|
||||||
|
)
|
||||||
|
assert case.case_name == "test_case"
|
||||||
|
assert case.user_message == "Hello"
|
||||||
|
assert len(case.tool_calls) == 1
|
||||||
|
assert case.system_message is None
|
||||||
|
assert case.additional_messages is None
|
||||||
|
|
||||||
|
def test_create_with_context(self):
|
||||||
|
"""Test creating a captured case with full context."""
|
||||||
|
case = CapturedCase(
|
||||||
|
case_name="test_case",
|
||||||
|
user_message="Hello",
|
||||||
|
tool_calls=[CapturedToolCall(name="Tool1")],
|
||||||
|
system_message="You are an assistant",
|
||||||
|
additional_messages=[{"role": "assistant", "content": "Hi"}],
|
||||||
|
)
|
||||||
|
assert case.system_message == "You are an assistant"
|
||||||
|
assert case.additional_messages == [{"role": "assistant", "content": "Hi"}]
|
||||||
|
|
||||||
|
def test_to_dict_without_context(self):
|
||||||
|
"""Test to_dict without including context."""
|
||||||
|
case = CapturedCase(
|
||||||
|
case_name="test_case",
|
||||||
|
user_message="Hello",
|
||||||
|
tool_calls=[CapturedToolCall(name="Tool1", args={"x": 1})],
|
||||||
|
system_message="System message",
|
||||||
|
additional_messages=[{"role": "user", "content": "msg"}],
|
||||||
|
)
|
||||||
|
result = case.to_dict(include_context=False)
|
||||||
|
assert result == {
|
||||||
|
"case_name": "test_case",
|
||||||
|
"user_message": "Hello",
|
||||||
|
"tool_calls": [{"name": "Tool1", "args": {"x": 1}}],
|
||||||
|
}
|
||||||
|
# Context should NOT be included
|
||||||
|
assert "system_message" not in result
|
||||||
|
assert "additional_messages" not in result
|
||||||
|
|
||||||
|
def test_to_dict_with_context(self):
|
||||||
|
"""Test to_dict including context."""
|
||||||
|
case = CapturedCase(
|
||||||
|
case_name="test_case",
|
||||||
|
user_message="Hello",
|
||||||
|
tool_calls=[CapturedToolCall(name="Tool1", args={"x": 1})],
|
||||||
|
system_message="System message",
|
||||||
|
additional_messages=[{"role": "user", "content": "msg"}],
|
||||||
|
)
|
||||||
|
result = case.to_dict(include_context=True)
|
||||||
|
assert result == {
|
||||||
|
"case_name": "test_case",
|
||||||
|
"user_message": "Hello",
|
||||||
|
"tool_calls": [{"name": "Tool1", "args": {"x": 1}}],
|
||||||
|
"system_message": "System message",
|
||||||
|
"additional_messages": [{"role": "user", "content": "msg"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_to_dict_with_context_null_messages(self):
|
||||||
|
"""Test to_dict with context when additional_messages is None."""
|
||||||
|
case = CapturedCase(
|
||||||
|
case_name="test_case",
|
||||||
|
user_message="Hello",
|
||||||
|
tool_calls=[],
|
||||||
|
system_message="Sys",
|
||||||
|
additional_messages=None,
|
||||||
|
)
|
||||||
|
result = case.to_dict(include_context=True)
|
||||||
|
assert result["additional_messages"] == []
|
||||||
|
|
||||||
|
def test_to_dict_normalizes_json_string_arguments(self):
|
||||||
|
"""Test that JSON string arguments in additional_messages are parsed into objects."""
|
||||||
|
# This simulates OpenAI's format where arguments is a JSON string
|
||||||
|
additional_messages = [
|
||||||
|
{"role": "user", "content": "List projects"},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call_123",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "Linear_ListProjects",
|
||||||
|
"arguments": '{"state": "started"}', # JSON string
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": '{"projects": []}',
|
||||||
|
"tool_call_id": "call_123",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
case = CapturedCase(
|
||||||
|
case_name="test_case",
|
||||||
|
user_message="Hello",
|
||||||
|
tool_calls=[],
|
||||||
|
system_message="Sys",
|
||||||
|
additional_messages=additional_messages,
|
||||||
|
)
|
||||||
|
result = case.to_dict(include_context=True)
|
||||||
|
|
||||||
|
# Arguments should be parsed into an object, not a string
|
||||||
|
assistant_msg = result["additional_messages"][1]
|
||||||
|
assert assistant_msg["tool_calls"][0]["function"]["arguments"] == {"state": "started"}
|
||||||
|
|
||||||
|
def test_to_dict_handles_invalid_json_arguments(self):
|
||||||
|
"""Test that invalid JSON arguments are kept as strings."""
|
||||||
|
additional_messages = [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call_123",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "SomeTool",
|
||||||
|
"arguments": "not valid json {", # Invalid JSON
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
case = CapturedCase(
|
||||||
|
case_name="test_case",
|
||||||
|
user_message="Hello",
|
||||||
|
tool_calls=[],
|
||||||
|
system_message="Sys",
|
||||||
|
additional_messages=additional_messages,
|
||||||
|
)
|
||||||
|
result = case.to_dict(include_context=True)
|
||||||
|
|
||||||
|
# Invalid JSON should remain as string
|
||||||
|
assistant_msg = result["additional_messages"][0]
|
||||||
|
assert assistant_msg["tool_calls"][0]["function"]["arguments"] == "not valid json {"
|
||||||
|
|
||||||
|
def test_to_dict_normalizes_tool_response_content(self):
|
||||||
|
"""Test that JSON content in tool response messages is parsed into objects."""
|
||||||
|
additional_messages = [
|
||||||
|
{"role": "user", "content": "Get the initiative"},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call_get_init",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "Linear_GetInitiative",
|
||||||
|
"arguments": '{"id": "init_123"}',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": '{"id": "init_123", "name": "Q1 Goals", "status": "Planned"}',
|
||||||
|
"tool_call_id": "call_get_init",
|
||||||
|
"name": "Linear_GetInitiative",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
case = CapturedCase(
|
||||||
|
case_name="test_case",
|
||||||
|
user_message="Hello",
|
||||||
|
tool_calls=[],
|
||||||
|
system_message="Sys",
|
||||||
|
additional_messages=additional_messages,
|
||||||
|
)
|
||||||
|
result = case.to_dict(include_context=True)
|
||||||
|
|
||||||
|
# Tool call arguments should be parsed
|
||||||
|
assistant_msg = result["additional_messages"][1]
|
||||||
|
assert assistant_msg["tool_calls"][0]["function"]["arguments"] == {"id": "init_123"}
|
||||||
|
|
||||||
|
# Tool response content should be parsed
|
||||||
|
tool_msg = result["additional_messages"][2]
|
||||||
|
assert tool_msg["content"] == {"id": "init_123", "name": "Q1 Goals", "status": "Planned"}
|
||||||
|
|
||||||
|
def test_to_dict_keeps_non_json_tool_content_as_string(self):
|
||||||
|
"""Test that non-JSON tool content is kept as string."""
|
||||||
|
additional_messages = [
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": "Error: Tool not found", # Plain text, not JSON
|
||||||
|
"tool_call_id": "call_123",
|
||||||
|
"name": "SomeTool",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
case = CapturedCase(
|
||||||
|
case_name="test_case",
|
||||||
|
user_message="Hello",
|
||||||
|
tool_calls=[],
|
||||||
|
system_message="Sys",
|
||||||
|
additional_messages=additional_messages,
|
||||||
|
)
|
||||||
|
result = case.to_dict(include_context=True)
|
||||||
|
|
||||||
|
# Non-JSON content should remain as string
|
||||||
|
tool_msg = result["additional_messages"][0]
|
||||||
|
assert tool_msg["content"] == "Error: Tool not found"
|
||||||
|
|
||||||
|
def test_empty_tool_calls(self):
|
||||||
|
"""Test case with no tool calls."""
|
||||||
|
case = CapturedCase(
|
||||||
|
case_name="no_tools",
|
||||||
|
user_message="Just chat",
|
||||||
|
tool_calls=[],
|
||||||
|
)
|
||||||
|
result = case.to_dict()
|
||||||
|
assert result["tool_calls"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- CaptureResult Tests ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptureResult:
|
||||||
|
"""Tests for CaptureResult dataclass."""
|
||||||
|
|
||||||
|
def test_create_basic(self):
|
||||||
|
"""Test creating a capture result."""
|
||||||
|
result = CaptureResult(
|
||||||
|
suite_name="My Suite",
|
||||||
|
model="gpt-4o",
|
||||||
|
provider="openai",
|
||||||
|
captured_cases=[
|
||||||
|
CapturedCase(
|
||||||
|
case_name="case1",
|
||||||
|
user_message="Hello",
|
||||||
|
tool_calls=[CapturedToolCall(name="Tool1")],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.suite_name == "My Suite"
|
||||||
|
assert result.model == "gpt-4o"
|
||||||
|
assert result.provider == "openai"
|
||||||
|
assert len(result.captured_cases) == 1
|
||||||
|
|
||||||
|
def test_to_dict_without_context(self):
|
||||||
|
"""Test to_dict without context."""
|
||||||
|
result = CaptureResult(
|
||||||
|
suite_name="Suite",
|
||||||
|
model="gpt-4o",
|
||||||
|
provider="openai",
|
||||||
|
captured_cases=[
|
||||||
|
CapturedCase(
|
||||||
|
case_name="case1",
|
||||||
|
user_message="Hello",
|
||||||
|
tool_calls=[CapturedToolCall(name="Tool1", args={"a": 1})],
|
||||||
|
system_message="System",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
d = result.to_dict(include_context=False)
|
||||||
|
assert d["suite_name"] == "Suite"
|
||||||
|
assert d["model"] == "gpt-4o"
|
||||||
|
assert d["provider"] == "openai"
|
||||||
|
assert len(d["captured_cases"]) == 1
|
||||||
|
assert "system_message" not in d["captured_cases"][0]
|
||||||
|
|
||||||
|
def test_to_dict_with_context(self):
|
||||||
|
"""Test to_dict with context."""
|
||||||
|
result = CaptureResult(
|
||||||
|
suite_name="Suite",
|
||||||
|
model="gpt-4o",
|
||||||
|
provider="openai",
|
||||||
|
captured_cases=[
|
||||||
|
CapturedCase(
|
||||||
|
case_name="case1",
|
||||||
|
user_message="Hello",
|
||||||
|
tool_calls=[],
|
||||||
|
system_message="System",
|
||||||
|
additional_messages=[],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
d = result.to_dict(include_context=True)
|
||||||
|
assert d["captured_cases"][0]["system_message"] == "System"
|
||||||
|
|
||||||
|
def test_to_json(self):
|
||||||
|
"""Test JSON serialization."""
|
||||||
|
result = CaptureResult(
|
||||||
|
suite_name="Suite",
|
||||||
|
model="gpt-4o",
|
||||||
|
provider="openai",
|
||||||
|
captured_cases=[
|
||||||
|
CapturedCase(
|
||||||
|
case_name="case1",
|
||||||
|
user_message="Hello",
|
||||||
|
tool_calls=[CapturedToolCall(name="Tool1")],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
json_str = result.to_json(include_context=False)
|
||||||
|
parsed = json.loads(json_str)
|
||||||
|
assert parsed["suite_name"] == "Suite"
|
||||||
|
assert parsed["model"] == "gpt-4o"
|
||||||
|
|
||||||
|
def test_to_json_with_indent(self):
|
||||||
|
"""Test JSON serialization with custom indent."""
|
||||||
|
result = CaptureResult(
|
||||||
|
suite_name="Suite",
|
||||||
|
model="gpt-4o",
|
||||||
|
provider="openai",
|
||||||
|
captured_cases=[],
|
||||||
|
)
|
||||||
|
json_str = result.to_json(indent=4)
|
||||||
|
# Check that indentation is present (4 spaces)
|
||||||
|
assert " " in json_str
|
||||||
|
|
||||||
|
def test_write_to_file(self):
|
||||||
|
"""Test writing capture result to file."""
|
||||||
|
result = CaptureResult(
|
||||||
|
suite_name="Suite",
|
||||||
|
model="gpt-4o",
|
||||||
|
provider="openai",
|
||||||
|
captured_cases=[
|
||||||
|
CapturedCase(
|
||||||
|
case_name="case1",
|
||||||
|
user_message="Hello",
|
||||||
|
tool_calls=[CapturedToolCall(name="Tool1", args={"x": 1})],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
filepath = Path(tmpdir) / "capture_output.json"
|
||||||
|
result.write_to_file(str(filepath))
|
||||||
|
|
||||||
|
# Verify file was created and has valid content
|
||||||
|
assert filepath.exists()
|
||||||
|
with open(filepath) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
assert data["suite_name"] == "Suite"
|
||||||
|
assert len(data["captured_cases"]) == 1
|
||||||
|
|
||||||
|
def test_write_to_file_with_context(self):
|
||||||
|
"""Test writing capture result with context to file."""
|
||||||
|
result = CaptureResult(
|
||||||
|
suite_name="Suite",
|
||||||
|
model="gpt-4o",
|
||||||
|
provider="openai",
|
||||||
|
captured_cases=[
|
||||||
|
CapturedCase(
|
||||||
|
case_name="case1",
|
||||||
|
user_message="Hello",
|
||||||
|
tool_calls=[],
|
||||||
|
system_message="System",
|
||||||
|
additional_messages=[{"role": "user", "content": "x"}],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
filepath = Path(tmpdir) / "capture_output.json"
|
||||||
|
result.write_to_file(str(filepath), include_context=True)
|
||||||
|
|
||||||
|
with open(filepath) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
assert data["captured_cases"][0]["system_message"] == "System"
|
||||||
|
|
||||||
|
def test_empty_captured_cases(self):
|
||||||
|
"""Test with no captured cases."""
|
||||||
|
result = CaptureResult(
|
||||||
|
suite_name="Empty Suite",
|
||||||
|
model="gpt-4o",
|
||||||
|
provider="openai",
|
||||||
|
captured_cases=[],
|
||||||
|
)
|
||||||
|
d = result.to_dict()
|
||||||
|
assert d["captured_cases"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- Imports Test ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptureImports:
|
||||||
|
"""Tests for capture mode imports."""
|
||||||
|
|
||||||
|
def test_import_from_arcade_evals(self):
|
||||||
|
"""Test that capture classes are importable from arcade_evals."""
|
||||||
|
from arcade_evals import CapturedCase, CapturedToolCall, CaptureResult
|
||||||
|
|
||||||
|
assert CapturedToolCall is not None
|
||||||
|
assert CapturedCase is not None
|
||||||
|
assert CaptureResult is not None
|
||||||
|
|
||||||
|
|
||||||
|
# --- EvalSuite.capture() Tests ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvalSuiteCapture:
|
||||||
|
"""Tests for EvalSuite.capture() method."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_openai_client(self):
|
||||||
|
"""Create a mock OpenAI client."""
|
||||||
|
client = AsyncMock()
|
||||||
|
# Create mock response with tool calls
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.choices = [MagicMock()]
|
||||||
|
mock_response.choices[0].message.tool_calls = [MagicMock()]
|
||||||
|
mock_response.choices[0].message.tool_calls[0].function.name = "Weather_GetCurrent"
|
||||||
|
mock_response.choices[0].message.tool_calls[0].function.arguments = '{"location": "London"}'
|
||||||
|
client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||||
|
return client
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def basic_suite(self):
|
||||||
|
"""Create a basic EvalSuite with a tool and case."""
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="Test Suite",
|
||||||
|
system_message="You are a helpful assistant",
|
||||||
|
)
|
||||||
|
suite.add_tool_definitions([
|
||||||
|
{"name": "Weather_GetCurrent", "description": "Get weather", "inputSchema": {}}
|
||||||
|
])
|
||||||
|
suite.add_case(
|
||||||
|
name="test_case",
|
||||||
|
user_message="What's the weather in London?",
|
||||||
|
expected_tool_calls=[], # No expectations in capture mode
|
||||||
|
)
|
||||||
|
return suite
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_returns_capture_result(self, basic_suite, mock_openai_client):
|
||||||
|
"""Test that capture() returns a CaptureResult."""
|
||||||
|
result = await basic_suite.capture(
|
||||||
|
client=mock_openai_client,
|
||||||
|
model="gpt-4o",
|
||||||
|
provider="openai",
|
||||||
|
)
|
||||||
|
assert isinstance(result, CaptureResult)
|
||||||
|
assert result.suite_name == "Test Suite"
|
||||||
|
assert result.model == "gpt-4o"
|
||||||
|
assert result.provider == "openai"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_records_tool_calls(self, basic_suite, mock_openai_client):
|
||||||
|
"""Test that capture() records tool calls from model."""
|
||||||
|
result = await basic_suite.capture(
|
||||||
|
client=mock_openai_client,
|
||||||
|
model="gpt-4o",
|
||||||
|
provider="openai",
|
||||||
|
)
|
||||||
|
assert len(result.captured_cases) == 1
|
||||||
|
case = result.captured_cases[0]
|
||||||
|
assert case.case_name == "test_case"
|
||||||
|
assert len(case.tool_calls) == 1
|
||||||
|
assert case.tool_calls[0].name == "Weather_GetCurrent"
|
||||||
|
assert case.tool_calls[0].args == {"location": "London"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_without_context(self, basic_suite, mock_openai_client):
|
||||||
|
"""Test that capture() without context doesn't include system message."""
|
||||||
|
result = await basic_suite.capture(
|
||||||
|
client=mock_openai_client,
|
||||||
|
model="gpt-4o",
|
||||||
|
provider="openai",
|
||||||
|
include_context=False,
|
||||||
|
)
|
||||||
|
case = result.captured_cases[0]
|
||||||
|
assert case.system_message is None
|
||||||
|
assert case.additional_messages is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_with_context(self, basic_suite, mock_openai_client):
|
||||||
|
"""Test that capture() with context includes system message."""
|
||||||
|
result = await basic_suite.capture(
|
||||||
|
client=mock_openai_client,
|
||||||
|
model="gpt-4o",
|
||||||
|
provider="openai",
|
||||||
|
include_context=True,
|
||||||
|
)
|
||||||
|
case = result.captured_cases[0]
|
||||||
|
assert case.system_message == "You are a helpful assistant"
|
||||||
|
assert case.additional_messages is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_requires_tools(self):
|
||||||
|
"""Test that capture() raises error when no tools registered."""
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="Empty Suite",
|
||||||
|
system_message="Test",
|
||||||
|
)
|
||||||
|
suite.add_case(
|
||||||
|
name="test_case",
|
||||||
|
user_message="Hello",
|
||||||
|
expected_tool_calls=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
with pytest.raises(ValueError, match="No tools registered"):
|
||||||
|
await suite.capture(mock_client, "gpt-4o", provider="openai")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_multiple_cases(self, mock_openai_client):
|
||||||
|
"""Test capture with multiple cases."""
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="Multi Case Suite",
|
||||||
|
system_message="You are an assistant",
|
||||||
|
)
|
||||||
|
suite.add_tool_definitions([
|
||||||
|
{"name": "Tool1", "description": "Tool 1"},
|
||||||
|
{"name": "Tool2", "description": "Tool 2"},
|
||||||
|
])
|
||||||
|
suite.add_case(name="case1", user_message="Do thing 1", expected_tool_calls=[])
|
||||||
|
suite.add_case(name="case2", user_message="Do thing 2", expected_tool_calls=[])
|
||||||
|
|
||||||
|
result = await suite.capture(
|
||||||
|
client=mock_openai_client,
|
||||||
|
model="gpt-4o",
|
||||||
|
provider="openai",
|
||||||
|
)
|
||||||
|
assert len(result.captured_cases) == 2
|
||||||
|
assert result.captured_cases[0].case_name == "case1"
|
||||||
|
assert result.captured_cases[1].case_name == "case2"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_no_tool_calls(self):
|
||||||
|
"""Test capture when model doesn't call any tools."""
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="No Calls Suite",
|
||||||
|
system_message="Test",
|
||||||
|
)
|
||||||
|
suite.add_tool_definitions([{"name": "Tool1", "description": "Tool 1"}])
|
||||||
|
suite.add_case(name="case1", user_message="Hello", expected_tool_calls=[])
|
||||||
|
|
||||||
|
# Mock client that returns no tool calls
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.choices = [MagicMock()]
|
||||||
|
mock_response.choices[0].message.tool_calls = None
|
||||||
|
mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||||
|
|
||||||
|
result = await suite.capture(
|
||||||
|
client=mock_client,
|
||||||
|
model="gpt-4o",
|
||||||
|
provider="openai",
|
||||||
|
)
|
||||||
|
assert len(result.captured_cases) == 1
|
||||||
|
assert len(result.captured_cases[0].tool_calls) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_normalizes_tool_calls(self, mock_openai_client):
|
||||||
|
"""Test that capture() normalizes tool names and fills defaults."""
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="Normalization Suite",
|
||||||
|
system_message="Test",
|
||||||
|
)
|
||||||
|
# Add tool with default arg
|
||||||
|
suite.add_tool_definitions([
|
||||||
|
{
|
||||||
|
"name": "My.Tool",
|
||||||
|
"description": "Tool with default",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"arg1": {"type": "string", "default": "default_val"},
|
||||||
|
"arg2": {"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
])
|
||||||
|
suite.add_case(name="case1", user_message="Call it", expected_tool_calls=[])
|
||||||
|
|
||||||
|
# Mock client returning tool call with underscored name and missing default arg
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.choices = [MagicMock()]
|
||||||
|
mock_response.choices[0].message.tool_calls = [MagicMock()]
|
||||||
|
|
||||||
|
tool_call = mock_response.choices[0].message.tool_calls[0]
|
||||||
|
tool_call.function.name = "My_Tool" # Normalized name
|
||||||
|
tool_call.function.arguments = '{"arg2": "provided"}' # Missing arg1
|
||||||
|
|
||||||
|
mock_openai_client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||||
|
|
||||||
|
result = await suite.capture(
|
||||||
|
client=mock_openai_client,
|
||||||
|
model="gpt-4o",
|
||||||
|
provider="openai",
|
||||||
|
)
|
||||||
|
|
||||||
|
case = result.captured_cases[0]
|
||||||
|
# Name is resolved to original format (My_Tool -> My.Tool)
|
||||||
|
# This ensures consistency with expected tool names
|
||||||
|
assert case.tool_calls[0].name == "My.Tool"
|
||||||
|
# Args should include default value
|
||||||
|
assert case.tool_calls[0].args == {"arg1": "default_val", "arg2": "provided"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- tool_eval decorator capture mode Tests ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolEvalCaptureMode:
|
||||||
|
"""Tests for tool_eval decorator with capture mode."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_eval_capture_mode_flag(self):
|
||||||
|
"""Test that tool_eval wrapper passes capture_mode correctly."""
|
||||||
|
from arcade_evals import tool_eval
|
||||||
|
|
||||||
|
@tool_eval()
|
||||||
|
def my_eval():
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="Test Suite",
|
||||||
|
system_message="Test",
|
||||||
|
)
|
||||||
|
suite.add_tool_definitions([{"name": "Tool1", "description": "D"}])
|
||||||
|
suite.add_case(name="case1", user_message="Hello", expected_tool_calls=[])
|
||||||
|
return suite
|
||||||
|
|
||||||
|
# Mock the underlying capture functions
|
||||||
|
with patch("arcade_evals.eval._capture_with_openai") as mock_capture:
|
||||||
|
mock_capture.return_value = CaptureResult(
|
||||||
|
suite_name="Test",
|
||||||
|
model="gpt-4o",
|
||||||
|
provider="openai",
|
||||||
|
captured_cases=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await my_eval(
|
||||||
|
provider_api_key="test-key",
|
||||||
|
model="gpt-4o",
|
||||||
|
capture_mode=True,
|
||||||
|
include_context=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_capture.assert_called_once()
|
||||||
|
assert len(results) == 1
|
||||||
|
assert isinstance(results[0], CaptureResult)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_eval_capture_mode_with_context(self):
|
||||||
|
"""Test that tool_eval wrapper passes include_context correctly."""
|
||||||
|
from arcade_evals import tool_eval
|
||||||
|
|
||||||
|
@tool_eval()
|
||||||
|
def my_eval():
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="Test Suite",
|
||||||
|
system_message="Test",
|
||||||
|
)
|
||||||
|
suite.add_tool_definitions([{"name": "Tool1", "description": "D"}])
|
||||||
|
suite.add_case(name="case1", user_message="Hello", expected_tool_calls=[])
|
||||||
|
return suite
|
||||||
|
|
||||||
|
with patch("arcade_evals.eval._capture_with_openai") as mock_capture:
|
||||||
|
mock_capture.return_value = CaptureResult(
|
||||||
|
suite_name="Test",
|
||||||
|
model="gpt-4o",
|
||||||
|
provider="openai",
|
||||||
|
captured_cases=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
await my_eval(
|
||||||
|
provider_api_key="test-key",
|
||||||
|
model="gpt-4o",
|
||||||
|
capture_mode=True,
|
||||||
|
include_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify include_context was passed
|
||||||
|
call_args = mock_capture.call_args
|
||||||
|
assert call_args[0][3] is True # include_context is 4th positional arg
|
||||||
|
|
||||||
|
|
||||||
|
# --- Multiple Tool Calls per Case Tests ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultipleToolCalls:
|
||||||
|
"""Tests for capturing multiple tool calls from a single case."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_multiple_tool_calls(self):
|
||||||
|
"""Test capturing multiple tool calls from one model response."""
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="Multi Tool Suite",
|
||||||
|
system_message="Test",
|
||||||
|
)
|
||||||
|
suite.add_tool_definitions([
|
||||||
|
{"name": "Tool1", "description": "D1"},
|
||||||
|
{"name": "Tool2", "description": "D2"},
|
||||||
|
])
|
||||||
|
suite.add_case(name="case1", user_message="Do both", expected_tool_calls=[])
|
||||||
|
|
||||||
|
# Mock client returning multiple tool calls
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.choices = [MagicMock()]
|
||||||
|
|
||||||
|
tool_call_1 = MagicMock()
|
||||||
|
tool_call_1.function.name = "Tool1"
|
||||||
|
tool_call_1.function.arguments = '{"arg1": "val1"}'
|
||||||
|
|
||||||
|
tool_call_2 = MagicMock()
|
||||||
|
tool_call_2.function.name = "Tool2"
|
||||||
|
tool_call_2.function.arguments = '{"arg2": "val2"}'
|
||||||
|
|
||||||
|
mock_response.choices[0].message.tool_calls = [tool_call_1, tool_call_2]
|
||||||
|
mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||||
|
|
||||||
|
result = await suite.capture(
|
||||||
|
client=mock_client,
|
||||||
|
model="gpt-4o",
|
||||||
|
provider="openai",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result.captured_cases) == 1
|
||||||
|
case = result.captured_cases[0]
|
||||||
|
assert len(case.tool_calls) == 2
|
||||||
|
assert case.tool_calls[0].name == "Tool1"
|
||||||
|
assert case.tool_calls[0].args == {"arg1": "val1"}
|
||||||
|
assert case.tool_calls[1].name == "Tool2"
|
||||||
|
assert case.tool_calls[1].args == {"arg2": "val2"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptureWithAnthropic:
|
||||||
|
"""Tests for capture mode with Anthropic provider."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_with_anthropic_provider(self):
|
||||||
|
"""Test capture mode using Anthropic provider."""
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="Anthropic Capture Suite",
|
||||||
|
system_message="Test system message",
|
||||||
|
)
|
||||||
|
suite.add_tool_definitions([
|
||||||
|
{"name": "Google.Search", "description": "Search"},
|
||||||
|
])
|
||||||
|
suite.add_case(
|
||||||
|
name="test_case",
|
||||||
|
user_message="Search for something",
|
||||||
|
expected_tool_calls=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock Anthropic client
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_response = MagicMock()
|
||||||
|
|
||||||
|
# Anthropic returns tool_use blocks
|
||||||
|
mock_tool_use = MagicMock()
|
||||||
|
mock_tool_use.type = "tool_use"
|
||||||
|
mock_tool_use.name = "Google_Search" # Anthropic uses underscores
|
||||||
|
mock_tool_use.input = {"query": "test"}
|
||||||
|
|
||||||
|
mock_response.content = [mock_tool_use]
|
||||||
|
mock_client.messages.create = AsyncMock(return_value=mock_response)
|
||||||
|
|
||||||
|
result = await suite.capture(
|
||||||
|
client=mock_client,
|
||||||
|
model="claude-3-opus",
|
||||||
|
provider="anthropic",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.provider == "anthropic"
|
||||||
|
assert len(result.captured_cases) == 1
|
||||||
|
# Should resolve Google_Search back to Google.Search
|
||||||
|
assert result.captured_cases[0].tool_calls[0].name == "Google.Search"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptureHelperFunctions:
|
||||||
|
"""Tests for _capture_with_openai and _capture_with_anthropic helpers."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capture_with_openai_helper(self):
|
||||||
|
"""Test the _capture_with_openai helper function."""
|
||||||
|
from arcade_evals.capture import _capture_with_openai
|
||||||
|
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="OpenAI Helper Test",
|
||||||
|
system_message="Test",
|
||||||
|
)
|
||||||
|
suite.add_tool_definitions([{"name": "TestTool", "description": "A test tool"}])
|
||||||
|
suite.add_case(name="case1", user_message="Test", expected_tool_calls=[])
|
||||||
|
|
||||||
|
# Mock the suite.capture method directly instead of AsyncOpenAI
|
||||||
|
mock_result = CaptureResult(
|
||||||
|
suite_name="OpenAI Helper Test",
|
||||||
|
provider="openai",
|
||||||
|
model="gpt-4o",
|
||||||
|
captured_cases=[
|
||||||
|
CapturedCase(
|
||||||
|
case_name="case1",
|
||||||
|
user_message="Test",
|
||||||
|
tool_calls=[],
|
||||||
|
system_message="Test",
|
||||||
|
additional_messages=[],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(suite, "capture", return_value=mock_result) as mock_capture:
|
||||||
|
result = await _capture_with_openai(
|
||||||
|
suite=suite,
|
||||||
|
api_key="test-key",
|
||||||
|
model="gpt-4o",
|
||||||
|
include_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.suite_name == "OpenAI Helper Test"
|
||||||
|
assert result.provider == "openai"
|
||||||
|
# Verify capture was called with correct arguments
|
||||||
|
mock_capture.assert_called_once()
|
||||||
|
call_args = mock_capture.call_args
|
||||||
|
# Arguments: (client, model, provider=..., include_context=...)
|
||||||
|
assert call_args.args[1] == "gpt-4o" # model
|
||||||
|
assert call_args.kwargs.get("provider") == "openai"
|
||||||
|
assert call_args.kwargs.get("include_context") is True
|
||||||
|
|
||||||
|
def test_capture_with_anthropic_function_exists(self):
|
||||||
|
"""Test that _capture_with_anthropic helper function exists and is callable."""
|
||||||
|
# Verify the function exists and has the expected signature
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from arcade_evals.capture import _capture_with_anthropic
|
||||||
|
|
||||||
|
sig = inspect.signature(_capture_with_anthropic)
|
||||||
|
params = list(sig.parameters.keys())
|
||||||
|
assert "suite" in params
|
||||||
|
assert "api_key" in params
|
||||||
|
assert "model" in params
|
||||||
|
assert "include_context" in params
|
||||||
|
|
@ -12,6 +12,9 @@ from arcade_evals import (
|
||||||
from arcade_evals.errors import WeightError
|
from arcade_evals.errors import WeightError
|
||||||
from dateutil import parser
|
from dateutil import parser
|
||||||
|
|
||||||
|
# Mark all tests in this module as requiring evals dependencies
|
||||||
|
pytestmark = pytest.mark.evals
|
||||||
|
|
||||||
|
|
||||||
# Test NoneCritic initialization
|
# Test NoneCritic initialization
|
||||||
@pytest.mark.parametrize("weight, expected_weight", [(0.0, 0.0), (0.5, 0.0)])
|
@pytest.mark.parametrize("weight, expected_weight", [(0.0, 0.0), (0.5, 0.0)])
|
||||||
|
|
@ -113,19 +116,148 @@ def test_similarity_critic_evaluate(
|
||||||
assert result["score"] <= weight + 1e-6 # Allow a small epsilon for floating-point comparison
|
assert result["score"] <= weight + 1e-6 # Allow a small epsilon for floating-point comparison
|
||||||
|
|
||||||
|
|
||||||
# Test that WeightError is raised for invalid critic weights
|
# Test SimilarityCritic with non-string inputs (lists, dicts, etc.)
|
||||||
|
# This is critical because sklearn's TfidfVectorizer calls .lower() which fails on non-strings
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"expected, actual, expected_match",
|
||||||
|
[
|
||||||
|
# Lists with same items - should be similar
|
||||||
|
(["team1", "team2"], ["team1", "team2"], True),
|
||||||
|
# Lists with different items - should not match
|
||||||
|
(["team1", "team2"], ["team3", "team4"], False),
|
||||||
|
# Mixed string and list - can still compare
|
||||||
|
("team1 team2", ["team1", "team2"], True),
|
||||||
|
# Single item lists
|
||||||
|
(["engineering"], ["engineering"], True),
|
||||||
|
# Dicts converted to strings
|
||||||
|
({"key": "value"}, {"key": "value"}, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_similarity_critic_non_string_inputs(expected, actual, expected_match):
|
||||||
|
"""
|
||||||
|
Test that SimilarityCritic handles non-string inputs (lists, dicts)
|
||||||
|
by converting them to strings before comparison.
|
||||||
|
"""
|
||||||
|
critic = SimilarityCritic(
|
||||||
|
critic_field="teams_to_add",
|
||||||
|
weight=1.0,
|
||||||
|
similarity_threshold=0.8,
|
||||||
|
)
|
||||||
|
result = critic.evaluate(expected=expected, actual=actual)
|
||||||
|
assert result["match"] == expected_match
|
||||||
|
assert result["score"] >= 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# Additional edge case tests for SimilarityCritic non-string handling
|
||||||
|
class TestSimilarityCriticNonStringEdgeCases:
|
||||||
|
"""
|
||||||
|
Extended tests for SimilarityCritic handling of non-string inputs.
|
||||||
|
These tests ensure robustness when tool arguments are lists, numbers, or other types.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_empty_lists_produce_empty_strings(self):
|
||||||
|
"""Empty lists should be converted to empty strings and match each other."""
|
||||||
|
critic = SimilarityCritic(critic_field="tags", weight=1.0, similarity_threshold=0.0)
|
||||||
|
result = critic.evaluate(expected=[], actual=[])
|
||||||
|
assert result["match"] == True # noqa: E712 - numpy bool comparison
|
||||||
|
assert result["score"] == 1.0
|
||||||
|
|
||||||
|
def test_empty_vs_non_empty_list(self):
|
||||||
|
"""Empty list vs non-empty list should not match."""
|
||||||
|
critic = SimilarityCritic(critic_field="tags", weight=1.0, similarity_threshold=0.8)
|
||||||
|
result = critic.evaluate(expected=[], actual=["item"])
|
||||||
|
assert result["match"] == False # noqa: E712
|
||||||
|
|
||||||
|
def test_lists_with_numbers_only(self):
|
||||||
|
"""Lists containing only numbers fall back to exact match (TF-IDF filters digits)."""
|
||||||
|
critic = SimilarityCritic(critic_field="ids", weight=1.0, similarity_threshold=0.8)
|
||||||
|
result = critic.evaluate(expected=[1, 2, 3], actual=[1, 2, 3])
|
||||||
|
assert result["match"] == True # noqa: E712 - exact match fallback
|
||||||
|
assert result["score"] > 0
|
||||||
|
|
||||||
|
def test_lists_with_mixed_types(self):
|
||||||
|
"""Lists with mixed types (strings and numbers) should work."""
|
||||||
|
critic = SimilarityCritic(critic_field="mixed", weight=1.0, similarity_threshold=0.8)
|
||||||
|
result = critic.evaluate(expected=["user", 123, "admin"], actual=["user", 123, "admin"])
|
||||||
|
assert result["match"] == True # noqa: E712
|
||||||
|
|
||||||
|
def test_integer_inputs(self):
|
||||||
|
"""Integer inputs fall back to exact string match."""
|
||||||
|
critic = SimilarityCritic(critic_field="count", weight=1.0, similarity_threshold=0.8)
|
||||||
|
result = critic.evaluate(expected=42, actual=42)
|
||||||
|
assert result["match"] == True # noqa: E712
|
||||||
|
|
||||||
|
def test_integer_inputs_different(self):
|
||||||
|
"""Different integers should not match."""
|
||||||
|
critic = SimilarityCritic(critic_field="count", weight=1.0, similarity_threshold=0.8)
|
||||||
|
result = critic.evaluate(expected=42, actual=99)
|
||||||
|
assert result["match"] == False # noqa: E712
|
||||||
|
|
||||||
|
def test_float_inputs(self):
|
||||||
|
"""Float inputs fall back to exact string match."""
|
||||||
|
critic = SimilarityCritic(critic_field="price", weight=1.0, similarity_threshold=0.8)
|
||||||
|
result = critic.evaluate(expected=19.99, actual=19.99)
|
||||||
|
assert result["match"] == True # noqa: E712
|
||||||
|
|
||||||
|
def test_boolean_inputs(self):
|
||||||
|
"""Boolean inputs fall back to exact string match."""
|
||||||
|
critic = SimilarityCritic(critic_field="enabled", weight=1.0, similarity_threshold=0.8)
|
||||||
|
result = critic.evaluate(expected=True, actual=True)
|
||||||
|
assert result["match"] == True # noqa: E712
|
||||||
|
|
||||||
|
def test_boolean_inputs_different(self):
|
||||||
|
"""Different booleans should not match."""
|
||||||
|
critic = SimilarityCritic(critic_field="enabled", weight=1.0, similarity_threshold=0.8)
|
||||||
|
result = critic.evaluate(expected=True, actual=False)
|
||||||
|
assert result["match"] == False # noqa: E712
|
||||||
|
|
||||||
|
def test_list_order_similarity(self):
|
||||||
|
"""Same items in different order are similar (TF-IDF is order-agnostic)."""
|
||||||
|
critic = SimilarityCritic(critic_field="teams", weight=1.0, similarity_threshold=0.9)
|
||||||
|
result = critic.evaluate(
|
||||||
|
expected=["alpha", "beta", "gamma"], actual=["gamma", "beta", "alpha"]
|
||||||
|
)
|
||||||
|
assert result["match"] == True # noqa: E712
|
||||||
|
|
||||||
|
def test_nested_list_exact_match(self):
|
||||||
|
"""Nested lists fall back to exact match (special chars filtered by TF-IDF)."""
|
||||||
|
critic = SimilarityCritic(critic_field="nested", weight=1.0, similarity_threshold=0.5)
|
||||||
|
result = critic.evaluate(expected=[["a", "b"], ["c", "d"]], actual=[["a", "b"], ["c", "d"]])
|
||||||
|
assert result["match"] == True # noqa: E712
|
||||||
|
|
||||||
|
def test_unicode_in_lists(self):
|
||||||
|
"""Lists with unicode strings should work correctly."""
|
||||||
|
critic = SimilarityCritic(critic_field="names", weight=1.0, similarity_threshold=0.8)
|
||||||
|
result = critic.evaluate(
|
||||||
|
expected=["café", "naïve", "résumé"], actual=["café", "naïve", "résumé"]
|
||||||
|
)
|
||||||
|
assert result["match"] == True # noqa: E712
|
||||||
|
|
||||||
|
def test_none_converted_to_string(self):
|
||||||
|
"""None values fall back to exact string match."""
|
||||||
|
critic = SimilarityCritic(critic_field="optional", weight=1.0, similarity_threshold=0.8)
|
||||||
|
result = critic.evaluate(expected=None, actual=None)
|
||||||
|
assert result["match"] == True # noqa: E712
|
||||||
|
|
||||||
|
def test_none_vs_value(self):
|
||||||
|
"""None vs actual value should not match."""
|
||||||
|
critic = SimilarityCritic(critic_field="optional", weight=1.0, similarity_threshold=0.8)
|
||||||
|
result = critic.evaluate(expected=None, actual="value")
|
||||||
|
assert result["match"] == False # noqa: E712
|
||||||
|
|
||||||
|
|
||||||
|
# Test that WeightError is raised for negative critic weights
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"critic_class, weight",
|
"critic_class, weight",
|
||||||
[
|
[
|
||||||
(BinaryCritic, -0.1),
|
(BinaryCritic, -0.1),
|
||||||
(BinaryCritic, 1.1),
|
|
||||||
(NumericCritic, -0.5),
|
(NumericCritic, -0.5),
|
||||||
(SimilarityCritic, 1.5),
|
(SimilarityCritic, -0.3),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_critic_invalid_weight(critic_class, weight):
|
def test_critic_invalid_weight(critic_class, weight):
|
||||||
"""
|
"""
|
||||||
Test that initializing a critic with an invalid weight raises a WeightError.
|
Test that initializing a critic with a negative weight raises a WeightError.
|
||||||
"""
|
"""
|
||||||
with pytest.raises(WeightError):
|
with pytest.raises(WeightError):
|
||||||
if critic_class == NumericCritic:
|
if critic_class == NumericCritic:
|
||||||
|
|
@ -136,6 +268,29 @@ def test_critic_invalid_weight(critic_class, weight):
|
||||||
critic_class(critic_field="test_field", weight=weight)
|
critic_class(critic_field="test_field", weight=weight)
|
||||||
|
|
||||||
|
|
||||||
|
# Test that weights > 1.0 are now allowed (softmax normalization handles them)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"critic_class, weight",
|
||||||
|
[
|
||||||
|
(BinaryCritic, 1.5),
|
||||||
|
(BinaryCritic, 3.0),
|
||||||
|
(NumericCritic, 2.0),
|
||||||
|
(SimilarityCritic, 5.0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_critic_allows_weights_above_one(critic_class, weight):
|
||||||
|
"""
|
||||||
|
Test that weights > 1.0 are allowed (softmax normalization handles them).
|
||||||
|
"""
|
||||||
|
if critic_class == NumericCritic:
|
||||||
|
critic = critic_class(critic_field="test_field", weight=weight, value_range=(0, 1))
|
||||||
|
elif critic_class == SimilarityCritic:
|
||||||
|
critic = critic_class(critic_field="test_field", weight=weight)
|
||||||
|
else:
|
||||||
|
critic = critic_class(critic_field="test_field", weight=weight)
|
||||||
|
assert critic.weight == weight
|
||||||
|
|
||||||
|
|
||||||
# Test NumericCritic with invalid value range
|
# Test NumericCritic with invalid value range
|
||||||
def test_numeric_critic_invalid_range():
|
def test_numeric_critic_invalid_range():
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
262
libs/tests/sdk/test_evalsuite_convenience.py
Normal file
262
libs/tests/sdk/test_evalsuite_convenience.py
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
"""Tests for EvalSuite convenience methods (TICKET-003)."""
|
||||||
|
|
||||||
|
from typing import Annotated, Any
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_core import ToolCatalog
|
||||||
|
from arcade_evals import EvalSuite, ExpectedToolCall, MCPToolDefinition
|
||||||
|
from arcade_tdk import tool
|
||||||
|
|
||||||
|
# Mark all tests in this module as requiring evals dependencies
|
||||||
|
pytestmark = pytest.mark.evals
|
||||||
|
|
||||||
|
|
||||||
|
def sample_tool_def(name: str = "test_tool") -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"description": "A test tool",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"param": {"type": "string"}},
|
||||||
|
"required": ["param"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def py_add(a: Annotated[int, "Left operand"], b: Annotated[int, "Right operand"] = 0) -> int:
|
||||||
|
"""Add two integers."""
|
||||||
|
return a + b
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddToolDefinitions:
|
||||||
|
def test_add_single_tool(self) -> None:
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
suite.add_tool_definitions([sample_tool_def()])
|
||||||
|
assert suite.get_tool_count() == 1
|
||||||
|
assert "test_tool" in suite.list_tool_names()
|
||||||
|
|
||||||
|
def test_method_chaining(self) -> None:
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
assert suite.add_tool_definitions([sample_tool_def()]) is suite
|
||||||
|
|
||||||
|
def test_add_empty_list(self) -> None:
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
suite.add_tool_definitions([])
|
||||||
|
assert suite.get_tool_count() == 0
|
||||||
|
|
||||||
|
def test_invalid_tool_raises(self) -> None:
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
with pytest.raises(ValueError, match="name"):
|
||||||
|
suite.add_tool_definitions([{"description": "No name"}])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddMcpServer:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calls_loader_with_correct_params(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"arcade_evals._evalsuite._convenience.load_mcp_remote_async", new_callable=AsyncMock
|
||||||
|
) as mock_load:
|
||||||
|
mock_load.return_value = [sample_tool_def()]
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
await suite.add_mcp_server("http://localhost:8000", headers={"Auth": "t"}, timeout=30)
|
||||||
|
mock_load.assert_called_once_with(
|
||||||
|
"http://localhost:8000",
|
||||||
|
timeout=30,
|
||||||
|
headers={"Auth": "t"},
|
||||||
|
use_sse=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_response_warns(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"arcade_evals._evalsuite._convenience.load_mcp_remote_async", new_callable=AsyncMock
|
||||||
|
) as mock_load:
|
||||||
|
mock_load.return_value = []
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
with pytest.warns(UserWarning, match="No tools loaded"):
|
||||||
|
await suite.add_mcp_server("http://localhost:8000")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddMcpStdioServer:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calls_loader_with_correct_params(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"arcade_evals._evalsuite._convenience.load_from_stdio_async", new_callable=AsyncMock
|
||||||
|
) as mock_load:
|
||||||
|
mock_load.return_value = [sample_tool_def()]
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
await suite.add_mcp_stdio_server(["python", "server.py"], env={"K": "V"}, timeout=20)
|
||||||
|
mock_load.assert_called_once_with(
|
||||||
|
["python", "server.py"],
|
||||||
|
timeout=20,
|
||||||
|
env={"K": "V"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddArcadeGateway:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calls_loader_with_correct_params(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"arcade_evals._evalsuite._convenience.load_arcade_mcp_gateway_async",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_load:
|
||||||
|
mock_load.return_value = [sample_tool_def()]
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
await suite.add_arcade_gateway(
|
||||||
|
"my-gateway",
|
||||||
|
arcade_api_key="k",
|
||||||
|
arcade_user_id="u",
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
# base_url defaults to None, loader handles the default
|
||||||
|
mock_load.assert_called_once_with(
|
||||||
|
"my-gateway",
|
||||||
|
arcade_api_key="k",
|
||||||
|
arcade_user_id="u",
|
||||||
|
base_url=None,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddToolCatalog:
|
||||||
|
def test_add_tool_catalog_registers_python_tool_and_allows_callable_in_case(self) -> None:
|
||||||
|
catalog = ToolCatalog()
|
||||||
|
catalog.add_tool(py_add, "sample_toolkit")
|
||||||
|
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test").add_tool_catalog(catalog)
|
||||||
|
names = suite.list_tool_names()
|
||||||
|
assert suite.get_tool_count() == 1
|
||||||
|
assert len(names) == 1
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Case",
|
||||||
|
user_message="Add 1 and 2",
|
||||||
|
expected_tool_calls=[ExpectedToolCall(func=py_add, args={"a": 1, "b": 2})],
|
||||||
|
)
|
||||||
|
assert suite.cases[0].expected_tool_calls[0].name in names
|
||||||
|
|
||||||
|
|
||||||
|
class TestMCPToolDefinition:
|
||||||
|
"""Tests for MCPToolDefinition TypedDict."""
|
||||||
|
|
||||||
|
def test_typedict_is_importable(self) -> None:
|
||||||
|
"""MCPToolDefinition should be importable from arcade_evals."""
|
||||||
|
from arcade_evals import MCPToolDefinition
|
||||||
|
|
||||||
|
assert MCPToolDefinition is not None
|
||||||
|
|
||||||
|
def test_typedict_has_expected_keys(self) -> None:
|
||||||
|
"""MCPToolDefinition should have name, description, and inputSchema keys."""
|
||||||
|
annotations = MCPToolDefinition.__annotations__
|
||||||
|
# Check all expected keys are present (from both base and child TypedDict)
|
||||||
|
all_keys = set(annotations.keys())
|
||||||
|
# The parent class _MCPToolDefinitionRequired adds 'name'
|
||||||
|
assert "description" in all_keys
|
||||||
|
assert "inputSchema" in all_keys
|
||||||
|
|
||||||
|
def test_tool_with_only_required_fields(self) -> None:
|
||||||
|
"""Tool definition with only 'name' should work (other fields default)."""
|
||||||
|
tool_def: MCPToolDefinition = {"name": "minimal_tool"}
|
||||||
|
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
suite.add_tool_definitions([tool_def])
|
||||||
|
|
||||||
|
assert suite.get_tool_count() == 1
|
||||||
|
assert "minimal_tool" in suite.list_tool_names()
|
||||||
|
|
||||||
|
def test_tool_with_all_fields(self) -> None:
|
||||||
|
"""Tool definition with all fields should work."""
|
||||||
|
tool_def: MCPToolDefinition = {
|
||||||
|
"name": "full_tool",
|
||||||
|
"description": "A fully specified tool",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"x": {"type": "string"}},
|
||||||
|
"required": ["x"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
suite.add_tool_definitions([tool_def])
|
||||||
|
|
||||||
|
assert suite.get_tool_count() == 1
|
||||||
|
assert "full_tool" in suite.list_tool_names()
|
||||||
|
|
||||||
|
def test_multiple_tools_with_typed_list(self) -> None:
|
||||||
|
"""A list[MCPToolDefinition] should work with add_tool_definitions."""
|
||||||
|
tools: list[MCPToolDefinition] = [
|
||||||
|
{"name": "tool_a", "description": "Tool A"},
|
||||||
|
{"name": "tool_b"},
|
||||||
|
{"name": "tool_c", "inputSchema": {"type": "object", "properties": {}}},
|
||||||
|
]
|
||||||
|
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
suite.add_tool_definitions(tools)
|
||||||
|
|
||||||
|
assert suite.get_tool_count() == 3
|
||||||
|
assert set(suite.list_tool_names()) == {"tool_a", "tool_b", "tool_c"}
|
||||||
|
|
||||||
|
def test_duplicate_tool_name_raises_error(self) -> None:
|
||||||
|
"""Registering a tool with a duplicate name should raise ValueError."""
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
suite.add_tool_definitions([{"name": "my_tool"}])
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="already registered"):
|
||||||
|
suite.add_tool_definitions([{"name": "my_tool"}])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddToolDefinitionsEdgeCases:
|
||||||
|
"""Additional edge case tests for add_tool_definitions."""
|
||||||
|
|
||||||
|
def test_invalid_type_raises_typeerror(self) -> None:
|
||||||
|
"""Non-dict tool definitions should raise TypeError."""
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
with pytest.raises(TypeError, match="must be dictionaries"):
|
||||||
|
suite.add_tool_definitions(["not_a_dict"]) # type: ignore
|
||||||
|
|
||||||
|
def test_does_not_mutate_input(self) -> None:
|
||||||
|
"""add_tool_definitions should not mutate the input dicts."""
|
||||||
|
original_tool = {"name": "my_tool"}
|
||||||
|
original_copy = dict(original_tool)
|
||||||
|
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
suite.add_tool_definitions([original_tool])
|
||||||
|
|
||||||
|
# Original dict should be unchanged (no defaults added)
|
||||||
|
assert original_tool == original_copy
|
||||||
|
assert "description" not in original_tool
|
||||||
|
assert "inputSchema" not in original_tool
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddMcpStdioServerWarnings:
|
||||||
|
"""Tests for add_mcp_stdio_server warning paths."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_response_warns(self) -> None:
|
||||||
|
"""Empty response from stdio server should warn."""
|
||||||
|
with patch(
|
||||||
|
"arcade_evals._evalsuite._convenience.load_from_stdio_async", new_callable=AsyncMock
|
||||||
|
) as mock_load:
|
||||||
|
mock_load.return_value = []
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
with pytest.warns(UserWarning, match="No tools loaded"):
|
||||||
|
await suite.add_mcp_stdio_server(["python", "server.py"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddArcadeGatewayWarnings:
|
||||||
|
"""Tests for add_arcade_gateway warning paths."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_response_warns(self) -> None:
|
||||||
|
"""Empty response from arcade gateway should warn."""
|
||||||
|
with patch(
|
||||||
|
"arcade_evals._evalsuite._convenience.load_arcade_mcp_gateway_async",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_load:
|
||||||
|
mock_load.return_value = []
|
||||||
|
suite = EvalSuite(name="Test", system_message="Test")
|
||||||
|
with pytest.warns(UserWarning, match="No tools loaded"):
|
||||||
|
await suite.add_arcade_gateway("my-gateway")
|
||||||
518
libs/tests/sdk/test_fuzzy_weight.py
Normal file
518
libs/tests/sdk/test_fuzzy_weight.py
Normal file
|
|
@ -0,0 +1,518 @@
|
||||||
|
"""Tests for FuzzyWeight functionality."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_evals import BinaryCritic, EvalRubric, FuzzyWeight, NoneCritic, Weight
|
||||||
|
from arcade_evals.eval import EvalCase, NamedExpectedToolCall
|
||||||
|
from arcade_evals.weights import normalize_fuzzy_weights, resolve_weight
|
||||||
|
|
||||||
|
# Mark all tests in this module as requiring evals dependencies
|
||||||
|
pytestmark = pytest.mark.evals
|
||||||
|
|
||||||
|
|
||||||
|
class TestFuzzyWeightEnum:
|
||||||
|
"""Test FuzzyWeight enum values."""
|
||||||
|
|
||||||
|
def test_fuzzy_weight_values(self) -> None:
|
||||||
|
"""Test FuzzyWeight enum has correct base values (linear scale 1-7)."""
|
||||||
|
assert FuzzyWeight.MINIMAL.value == 1
|
||||||
|
assert FuzzyWeight.VERY_LOW.value == 2
|
||||||
|
assert FuzzyWeight.LOW.value == 3
|
||||||
|
assert FuzzyWeight.MEDIUM.value == 4
|
||||||
|
assert FuzzyWeight.HIGH.value == 5
|
||||||
|
assert FuzzyWeight.VERY_HIGH.value == 6
|
||||||
|
assert FuzzyWeight.CRITICAL.value == 7
|
||||||
|
|
||||||
|
def test_fuzzy_weight_ordering(self) -> None:
|
||||||
|
"""Test FuzzyWeight values are properly ordered."""
|
||||||
|
assert FuzzyWeight.MINIMAL.value < FuzzyWeight.VERY_LOW.value
|
||||||
|
assert FuzzyWeight.VERY_LOW.value < FuzzyWeight.LOW.value
|
||||||
|
assert FuzzyWeight.LOW.value < FuzzyWeight.MEDIUM.value
|
||||||
|
assert FuzzyWeight.MEDIUM.value < FuzzyWeight.HIGH.value
|
||||||
|
assert FuzzyWeight.HIGH.value < FuzzyWeight.VERY_HIGH.value
|
||||||
|
assert FuzzyWeight.VERY_HIGH.value < FuzzyWeight.CRITICAL.value
|
||||||
|
|
||||||
|
def test_fuzzy_weight_uniform_increment(self) -> None:
|
||||||
|
"""Test FuzzyWeight values have uniform increment of 1."""
|
||||||
|
values = [fw.value for fw in FuzzyWeight]
|
||||||
|
increments = [values[i + 1] - values[i] for i in range(len(values) - 1)]
|
||||||
|
assert all(inc == 1 for inc in increments), f"Increments should all be 1: {increments}"
|
||||||
|
|
||||||
|
def test_fuzzy_weight_is_enum(self) -> None:
|
||||||
|
"""Test that FuzzyWeight is a proper enum."""
|
||||||
|
assert len(list(FuzzyWeight)) == 7
|
||||||
|
assert FuzzyWeight.MEDIUM.name == "MEDIUM"
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeFuzzyWeights:
|
||||||
|
"""Test normalize_fuzzy_weights function."""
|
||||||
|
|
||||||
|
def test_normalize_two_weights(self) -> None:
|
||||||
|
"""Test normalization with two weights."""
|
||||||
|
critics = [
|
||||||
|
BinaryCritic(critic_field="a", weight=FuzzyWeight.HIGH),
|
||||||
|
BinaryCritic(critic_field="b", weight=FuzzyWeight.LOW),
|
||||||
|
]
|
||||||
|
normalized = normalize_fuzzy_weights(critics)
|
||||||
|
|
||||||
|
# HIGH=5, LOW=3, total=8
|
||||||
|
# HIGH: 5/8 = 0.625, LOW: 3/8 = 0.375
|
||||||
|
assert normalized[0] == pytest.approx(5 / 8)
|
||||||
|
assert normalized[1] == pytest.approx(3 / 8)
|
||||||
|
assert sum(normalized) == pytest.approx(1.0)
|
||||||
|
|
||||||
|
def test_normalize_equal_weights(self) -> None:
|
||||||
|
"""Test normalization with equal weights."""
|
||||||
|
critics = [
|
||||||
|
BinaryCritic(critic_field="a", weight=FuzzyWeight.MEDIUM),
|
||||||
|
BinaryCritic(critic_field="b", weight=FuzzyWeight.MEDIUM),
|
||||||
|
BinaryCritic(critic_field="c", weight=FuzzyWeight.MEDIUM),
|
||||||
|
]
|
||||||
|
normalized = normalize_fuzzy_weights(critics)
|
||||||
|
|
||||||
|
assert all(w == pytest.approx(1 / 3) for w in normalized)
|
||||||
|
assert sum(normalized) == pytest.approx(1.0)
|
||||||
|
|
||||||
|
def test_normalize_mixed_weights(self) -> None:
|
||||||
|
"""Test normalization with mixed weight levels."""
|
||||||
|
critics = [
|
||||||
|
BinaryCritic(critic_field="owner", weight=FuzzyWeight.HIGH),
|
||||||
|
BinaryCritic(critic_field="repo", weight=FuzzyWeight.HIGH),
|
||||||
|
BinaryCritic(critic_field="number", weight=FuzzyWeight.MEDIUM),
|
||||||
|
BinaryCritic(critic_field="state", weight=FuzzyWeight.LOW),
|
||||||
|
]
|
||||||
|
normalized = normalize_fuzzy_weights(critics)
|
||||||
|
|
||||||
|
# HIGH=5, HIGH=5, MEDIUM=4, LOW=3, total=17
|
||||||
|
assert normalized[0] == pytest.approx(5 / 17)
|
||||||
|
assert normalized[1] == pytest.approx(5 / 17)
|
||||||
|
assert normalized[2] == pytest.approx(4 / 17)
|
||||||
|
assert normalized[3] == pytest.approx(3 / 17)
|
||||||
|
assert sum(normalized) == pytest.approx(1.0)
|
||||||
|
|
||||||
|
def test_normalize_empty_list(self) -> None:
|
||||||
|
"""Test normalization with empty list."""
|
||||||
|
normalized = normalize_fuzzy_weights([])
|
||||||
|
assert normalized == []
|
||||||
|
|
||||||
|
def test_normalize_single_weight(self) -> None:
|
||||||
|
"""Test normalization with single critic."""
|
||||||
|
critics = [BinaryCritic(critic_field="a", weight=FuzzyWeight.HIGH)]
|
||||||
|
normalized = normalize_fuzzy_weights(critics)
|
||||||
|
|
||||||
|
assert len(normalized) == 1
|
||||||
|
assert normalized[0] == pytest.approx(1.0)
|
||||||
|
|
||||||
|
def test_normalize_with_float_weights(self) -> None:
|
||||||
|
"""Test normalization works with float weights too."""
|
||||||
|
critics = [
|
||||||
|
BinaryCritic(critic_field="a", weight=3.0), # Acts like HIGH
|
||||||
|
BinaryCritic(critic_field="b", weight=1.0), # Acts like LOW
|
||||||
|
]
|
||||||
|
normalized = normalize_fuzzy_weights(critics)
|
||||||
|
|
||||||
|
# 3.0 / 4.0 = 0.75, 1.0 / 4.0 = 0.25
|
||||||
|
assert normalized[0] == pytest.approx(0.75)
|
||||||
|
assert normalized[1] == pytest.approx(0.25)
|
||||||
|
|
||||||
|
def test_small_weights_allowed(self) -> None:
|
||||||
|
"""Test that small weights are preserved after normalization."""
|
||||||
|
# Create scenario where one weight would be relatively small
|
||||||
|
critics = [
|
||||||
|
BinaryCritic(critic_field="a", weight=FuzzyWeight.VERY_HIGH),
|
||||||
|
BinaryCritic(critic_field="b", weight=FuzzyWeight.VERY_HIGH),
|
||||||
|
BinaryCritic(critic_field="c", weight=FuzzyWeight.VERY_HIGH),
|
||||||
|
BinaryCritic(critic_field="d", weight=FuzzyWeight.VERY_HIGH),
|
||||||
|
BinaryCritic(critic_field="e", weight=FuzzyWeight.VERY_LOW),
|
||||||
|
]
|
||||||
|
normalized = normalize_fuzzy_weights(critics)
|
||||||
|
|
||||||
|
# VERY_LOW=2, VERY_HIGH=6*4=24, total=26
|
||||||
|
# VERY_LOW: 2/26, VERY_HIGH: 6/26
|
||||||
|
assert normalized[4] == pytest.approx(2 / 26)
|
||||||
|
assert normalized[0] == pytest.approx(6 / 26)
|
||||||
|
assert sum(normalized) == pytest.approx(1.0)
|
||||||
|
|
||||||
|
def test_normalize_all_very_low(self) -> None:
|
||||||
|
"""Test normalization when all weights are VERY_LOW."""
|
||||||
|
critics = [
|
||||||
|
BinaryCritic(critic_field="a", weight=FuzzyWeight.VERY_LOW),
|
||||||
|
BinaryCritic(critic_field="b", weight=FuzzyWeight.VERY_LOW),
|
||||||
|
]
|
||||||
|
normalized = normalize_fuzzy_weights(critics)
|
||||||
|
|
||||||
|
# Both equal, should split 50/50
|
||||||
|
assert normalized[0] == pytest.approx(0.5)
|
||||||
|
assert normalized[1] == pytest.approx(0.5)
|
||||||
|
|
||||||
|
def test_normalize_all_very_high(self) -> None:
|
||||||
|
"""Test normalization when all weights are VERY_HIGH."""
|
||||||
|
critics = [
|
||||||
|
BinaryCritic(critic_field="a", weight=FuzzyWeight.VERY_HIGH),
|
||||||
|
BinaryCritic(critic_field="b", weight=FuzzyWeight.VERY_HIGH),
|
||||||
|
]
|
||||||
|
normalized = normalize_fuzzy_weights(critics)
|
||||||
|
|
||||||
|
# Both equal, should split 50/50
|
||||||
|
assert normalized[0] == pytest.approx(0.5)
|
||||||
|
assert normalized[1] == pytest.approx(0.5)
|
||||||
|
|
||||||
|
def test_normalize_extreme_weights(self) -> None:
|
||||||
|
"""Test normalization with MINIMAL and CRITICAL weights."""
|
||||||
|
critics = [
|
||||||
|
BinaryCritic(critic_field="a", weight=FuzzyWeight.CRITICAL),
|
||||||
|
BinaryCritic(critic_field="b", weight=FuzzyWeight.MINIMAL),
|
||||||
|
]
|
||||||
|
normalized = normalize_fuzzy_weights(critics)
|
||||||
|
|
||||||
|
# CRITICAL=7, MINIMAL=1, total=8
|
||||||
|
assert normalized[0] == pytest.approx(7 / 8) # 0.875
|
||||||
|
assert normalized[1] == pytest.approx(1 / 8) # 0.125
|
||||||
|
assert sum(normalized) == pytest.approx(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveWeight:
|
||||||
|
"""Test resolve_weight function."""
|
||||||
|
|
||||||
|
def test_resolve_fuzzy_weight(self) -> None:
|
||||||
|
"""Test resolving FuzzyWeight to float."""
|
||||||
|
assert resolve_weight(FuzzyWeight.MINIMAL) == 1
|
||||||
|
assert resolve_weight(FuzzyWeight.VERY_LOW) == 2
|
||||||
|
assert resolve_weight(FuzzyWeight.LOW) == 3
|
||||||
|
assert resolve_weight(FuzzyWeight.MEDIUM) == 4
|
||||||
|
assert resolve_weight(FuzzyWeight.HIGH) == 5
|
||||||
|
assert resolve_weight(FuzzyWeight.VERY_HIGH) == 6
|
||||||
|
assert resolve_weight(FuzzyWeight.CRITICAL) == 7
|
||||||
|
|
||||||
|
def test_resolve_float_weight(self) -> None:
|
||||||
|
"""Test resolving float weight (passthrough)."""
|
||||||
|
assert resolve_weight(0.5) == 0.5
|
||||||
|
assert resolve_weight(1.0) == 1.0
|
||||||
|
assert resolve_weight(0.0) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestCriticWithFuzzyWeight:
|
||||||
|
"""Test Critic classes with FuzzyWeight."""
|
||||||
|
|
||||||
|
def test_binary_critic_accepts_fuzzy_weight(self) -> None:
|
||||||
|
"""Test BinaryCritic accepts FuzzyWeight."""
|
||||||
|
critic = BinaryCritic(critic_field="test", weight=FuzzyWeight.HIGH)
|
||||||
|
assert critic.weight == FuzzyWeight.HIGH
|
||||||
|
|
||||||
|
def test_critic_resolved_weight_property(self) -> None:
|
||||||
|
"""Test resolved_weight property returns float."""
|
||||||
|
critic = BinaryCritic(critic_field="test", weight=FuzzyWeight.HIGH)
|
||||||
|
assert critic.resolved_weight == 5
|
||||||
|
|
||||||
|
def test_critic_still_accepts_float(self) -> None:
|
||||||
|
"""Test backwards compatibility with float weights."""
|
||||||
|
critic = BinaryCritic(critic_field="test", weight=0.5)
|
||||||
|
assert critic.weight == 0.5
|
||||||
|
assert critic.resolved_weight == 0.5
|
||||||
|
|
||||||
|
def test_none_critic_works_with_fuzzy_system(self) -> None:
|
||||||
|
"""Test NoneCritic still works alongside FuzzyWeight critics."""
|
||||||
|
none_critic = NoneCritic(critic_field="optional")
|
||||||
|
assert none_critic.weight == 0.0
|
||||||
|
|
||||||
|
def test_all_fuzzy_weight_levels_on_critic(self) -> None:
|
||||||
|
"""Test all FuzzyWeight levels can be assigned to critics."""
|
||||||
|
for fw in FuzzyWeight:
|
||||||
|
critic = BinaryCritic(critic_field="test", weight=fw)
|
||||||
|
assert critic.weight == fw
|
||||||
|
assert critic.resolved_weight == fw.value
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvalCaseWithFuzzyWeight:
|
||||||
|
"""Test EvalCase integration with FuzzyWeight."""
|
||||||
|
|
||||||
|
def test_eval_case_normalizes_fuzzy_weights(self) -> None:
|
||||||
|
"""Test EvalCase normalizes FuzzyWeight critics."""
|
||||||
|
case = EvalCase(
|
||||||
|
name="Test",
|
||||||
|
system_message="",
|
||||||
|
user_message="",
|
||||||
|
expected_tool_calls=[NamedExpectedToolCall(name="test", args={})],
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="a", weight=FuzzyWeight.HIGH),
|
||||||
|
BinaryCritic(critic_field="b", weight=FuzzyWeight.LOW),
|
||||||
|
],
|
||||||
|
rubric=EvalRubric(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Weights should be normalized after __post_init__
|
||||||
|
# HIGH=5, LOW=3, total=8
|
||||||
|
assert case.critics[0].weight == pytest.approx(5 / 8)
|
||||||
|
assert case.critics[1].weight == pytest.approx(3 / 8)
|
||||||
|
|
||||||
|
def test_eval_case_mixed_fuzzy_and_float_normalizes(self) -> None:
|
||||||
|
"""Test EvalCase with mixed FuzzyWeight and float normalizes all."""
|
||||||
|
case = EvalCase(
|
||||||
|
name="Test",
|
||||||
|
system_message="",
|
||||||
|
user_message="",
|
||||||
|
expected_tool_calls=[NamedExpectedToolCall(name="test", args={})],
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="a", weight=FuzzyWeight.HIGH),
|
||||||
|
BinaryCritic(critic_field="b", weight=3.0), # Same value as LOW
|
||||||
|
],
|
||||||
|
rubric=EvalRubric(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mixed: if any FuzzyWeight present, all normalize
|
||||||
|
# HIGH=5, float=3.0, total=8
|
||||||
|
assert sum(c.weight for c in case.critics) == pytest.approx(1.0)
|
||||||
|
assert case.critics[0].weight == pytest.approx(5 / 8)
|
||||||
|
assert case.critics[1].weight == pytest.approx(3 / 8)
|
||||||
|
|
||||||
|
def test_eval_case_float_only_legacy_validation(self) -> None:
|
||||||
|
"""Test EvalCase with only float weights uses legacy validation."""
|
||||||
|
# This should work (valid legacy weights)
|
||||||
|
case = EvalCase(
|
||||||
|
name="Test",
|
||||||
|
system_message="",
|
||||||
|
user_message="",
|
||||||
|
expected_tool_calls=[NamedExpectedToolCall(name="test", args={})],
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="a", weight=0.5),
|
||||||
|
BinaryCritic(critic_field="b", weight=0.5),
|
||||||
|
],
|
||||||
|
rubric=EvalRubric(),
|
||||||
|
)
|
||||||
|
assert case.critics[0].weight == 0.5
|
||||||
|
assert case.critics[1].weight == 0.5
|
||||||
|
|
||||||
|
def test_eval_case_preserves_original_weight(self) -> None:
|
||||||
|
"""Test EvalCase preserves original FuzzyWeight for reference."""
|
||||||
|
case = EvalCase(
|
||||||
|
name="Test",
|
||||||
|
system_message="",
|
||||||
|
user_message="",
|
||||||
|
expected_tool_calls=[NamedExpectedToolCall(name="test", args={})],
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="a", weight=FuzzyWeight.HIGH),
|
||||||
|
],
|
||||||
|
rubric=EvalRubric(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Original weight should be stored
|
||||||
|
assert case.critics[0]._original_weight == FuzzyWeight.HIGH # type: ignore[attr-defined]
|
||||||
|
# Normalized weight should be 1.0 (only one critic)
|
||||||
|
assert case.critics[0].weight == pytest.approx(1.0)
|
||||||
|
|
||||||
|
def test_eval_case_with_none_critics_and_fuzzy(self) -> None:
|
||||||
|
"""Test EvalCase handles NoneCritic alongside FuzzyWeight critics."""
|
||||||
|
case = EvalCase(
|
||||||
|
name="Test",
|
||||||
|
system_message="",
|
||||||
|
user_message="",
|
||||||
|
expected_tool_calls=[NamedExpectedToolCall(name="test", args={"a": 1, "b": 2, "c": 3})],
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="a", weight=FuzzyWeight.HIGH),
|
||||||
|
BinaryCritic(critic_field="b", weight=FuzzyWeight.LOW),
|
||||||
|
NoneCritic(critic_field="c"), # Should be ignored in normalization
|
||||||
|
],
|
||||||
|
rubric=EvalRubric(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# NoneCritic should keep weight=0
|
||||||
|
assert case.critics[2].weight == 0.0
|
||||||
|
# Only non-None critics should be normalized to sum to 1.0
|
||||||
|
non_none_sum = sum(c.weight for c in case.critics if not isinstance(c, NoneCritic))
|
||||||
|
assert non_none_sum == pytest.approx(1.0)
|
||||||
|
|
||||||
|
def test_eval_case_empty_critics(self) -> None:
|
||||||
|
"""Test EvalCase with no critics."""
|
||||||
|
case = EvalCase(
|
||||||
|
name="Test",
|
||||||
|
system_message="",
|
||||||
|
user_message="",
|
||||||
|
expected_tool_calls=[NamedExpectedToolCall(name="test", args={})],
|
||||||
|
critics=None,
|
||||||
|
rubric=EvalRubric(),
|
||||||
|
)
|
||||||
|
assert case.critics == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvalCaseEvaluationWithFuzzyWeight:
|
||||||
|
"""Test that EvalCase.evaluate works correctly with normalized FuzzyWeight critics."""
|
||||||
|
|
||||||
|
def test_evaluation_with_fuzzy_weights(self) -> None:
|
||||||
|
"""Test that evaluation scoring works correctly after FuzzyWeight normalization."""
|
||||||
|
expected_tool_calls = [
|
||||||
|
NamedExpectedToolCall(name="TestTool", args={"owner": "arcade", "repo": "tools"}),
|
||||||
|
]
|
||||||
|
actual_tool_calls = [
|
||||||
|
("TestTool", {"owner": "arcade", "repo": "wrong"}), # owner matches, repo doesn't
|
||||||
|
]
|
||||||
|
|
||||||
|
case = EvalCase(
|
||||||
|
name="Test",
|
||||||
|
system_message="",
|
||||||
|
user_message="",
|
||||||
|
expected_tool_calls=expected_tool_calls,
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="owner", weight=FuzzyWeight.HIGH),
|
||||||
|
BinaryCritic(critic_field="repo", weight=FuzzyWeight.LOW),
|
||||||
|
],
|
||||||
|
rubric=EvalRubric(tool_selection_weight=0.0, fail_threshold=0.5),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = case.evaluate(actual_tool_calls)
|
||||||
|
|
||||||
|
# HIGH=5/8=0.625, LOW=3/8=0.375 after normalization
|
||||||
|
# owner matches: 0.625 score
|
||||||
|
# repo doesn't match: 0.0 score
|
||||||
|
# Total score = 0.625 / (0.625 + 0.375) = 0.625
|
||||||
|
assert result.score == pytest.approx(5 / 8)
|
||||||
|
assert result.passed is True # 0.625 >= 0.5
|
||||||
|
|
||||||
|
def test_evaluation_all_match_fuzzy_weights(self) -> None:
|
||||||
|
"""Test evaluation where all critics match with FuzzyWeight."""
|
||||||
|
expected_tool_calls = [
|
||||||
|
NamedExpectedToolCall(name="TestTool", args={"a": "x", "b": "y"}),
|
||||||
|
]
|
||||||
|
actual_tool_calls = [
|
||||||
|
("TestTool", {"a": "x", "b": "y"}),
|
||||||
|
]
|
||||||
|
|
||||||
|
case = EvalCase(
|
||||||
|
name="Test",
|
||||||
|
system_message="",
|
||||||
|
user_message="",
|
||||||
|
expected_tool_calls=expected_tool_calls,
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="a", weight=FuzzyWeight.HIGH),
|
||||||
|
BinaryCritic(critic_field="b", weight=FuzzyWeight.MEDIUM),
|
||||||
|
],
|
||||||
|
rubric=EvalRubric(tool_selection_weight=0.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = case.evaluate(actual_tool_calls)
|
||||||
|
|
||||||
|
# All match, should be 1.0
|
||||||
|
assert result.score == pytest.approx(1.0)
|
||||||
|
assert result.passed is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeightTypeAlias:
|
||||||
|
"""Test Weight type alias works correctly."""
|
||||||
|
|
||||||
|
def test_weight_accepts_float(self) -> None:
|
||||||
|
"""Test Weight type accepts float."""
|
||||||
|
w: Weight = 0.5
|
||||||
|
assert w == 0.5
|
||||||
|
|
||||||
|
def test_weight_accepts_fuzzy_weight(self) -> None:
|
||||||
|
"""Test Weight type accepts FuzzyWeight."""
|
||||||
|
w: Weight = FuzzyWeight.HIGH
|
||||||
|
assert w == FuzzyWeight.HIGH
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackwardCompatibility:
|
||||||
|
"""Test backward compatibility with existing code."""
|
||||||
|
|
||||||
|
def test_existing_float_weights_work(self) -> None:
|
||||||
|
"""Test that existing float weight patterns continue to work."""
|
||||||
|
case = EvalCase(
|
||||||
|
name="Test",
|
||||||
|
system_message="",
|
||||||
|
user_message="",
|
||||||
|
expected_tool_calls=[NamedExpectedToolCall(name="test", args={})],
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="owner", weight=0.2),
|
||||||
|
BinaryCritic(critic_field="repo", weight=0.2),
|
||||||
|
BinaryCritic(critic_field="number", weight=0.2),
|
||||||
|
BinaryCritic(critic_field="entity_type", weight=0.2),
|
||||||
|
BinaryCritic(critic_field="add_labels", weight=0.2),
|
||||||
|
],
|
||||||
|
rubric=EvalRubric(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Float weights should remain unchanged
|
||||||
|
for critic in case.critics:
|
||||||
|
assert critic.weight == 0.2
|
||||||
|
|
||||||
|
# Sum should still be 1.0
|
||||||
|
assert sum(c.weight for c in case.critics) == pytest.approx(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdgeCases:
|
||||||
|
"""Test edge cases and error handling."""
|
||||||
|
|
||||||
|
def test_zero_weight_handling(self) -> None:
|
||||||
|
"""Test that zero weight is allowed and handled correctly."""
|
||||||
|
critics = [
|
||||||
|
BinaryCritic(critic_field="a", weight=FuzzyWeight.HIGH),
|
||||||
|
BinaryCritic(critic_field="b", weight=0), # Zero weight
|
||||||
|
]
|
||||||
|
normalized = normalize_fuzzy_weights(critics)
|
||||||
|
|
||||||
|
# HIGH=5, zero=0, total=5
|
||||||
|
assert normalized[0] == pytest.approx(1.0)
|
||||||
|
assert normalized[1] == pytest.approx(0.0)
|
||||||
|
assert sum(normalized) == pytest.approx(1.0)
|
||||||
|
|
||||||
|
def test_all_zero_weights(self) -> None:
|
||||||
|
"""Test that all zero weights are handled (equal distribution)."""
|
||||||
|
critics = [
|
||||||
|
BinaryCritic(critic_field="a", weight=0),
|
||||||
|
BinaryCritic(critic_field="b", weight=0),
|
||||||
|
]
|
||||||
|
normalized = normalize_fuzzy_weights(critics)
|
||||||
|
|
||||||
|
# All zero -> return zeros (no scoring should occur)
|
||||||
|
assert normalized[0] == 0.0
|
||||||
|
assert normalized[1] == 0.0
|
||||||
|
|
||||||
|
def test_large_float_weights(self) -> None:
|
||||||
|
"""Test that large float weights are handled correctly."""
|
||||||
|
critics = [
|
||||||
|
BinaryCritic(critic_field="a", weight=100.0),
|
||||||
|
BinaryCritic(critic_field="b", weight=50.0),
|
||||||
|
]
|
||||||
|
normalized = normalize_fuzzy_weights(critics)
|
||||||
|
|
||||||
|
# 100/150, 50/150
|
||||||
|
assert normalized[0] == pytest.approx(100 / 150)
|
||||||
|
assert normalized[1] == pytest.approx(50 / 150)
|
||||||
|
|
||||||
|
def test_negative_weight_raises_error(self) -> None:
|
||||||
|
"""Test that negative weights raise WeightError."""
|
||||||
|
from arcade_evals.errors import WeightError
|
||||||
|
|
||||||
|
with pytest.raises(WeightError):
|
||||||
|
BinaryCritic(critic_field="test", weight=-1.0)
|
||||||
|
|
||||||
|
def test_numeric_critic_with_fuzzy_weight(self) -> None:
|
||||||
|
"""Test NumericCritic works with FuzzyWeight."""
|
||||||
|
from arcade_evals import NumericCritic
|
||||||
|
|
||||||
|
critic = NumericCritic(
|
||||||
|
critic_field="score",
|
||||||
|
weight=FuzzyWeight.HIGH,
|
||||||
|
value_range=(0, 100),
|
||||||
|
)
|
||||||
|
assert critic.weight == FuzzyWeight.HIGH
|
||||||
|
assert critic.resolved_weight == 5
|
||||||
|
|
||||||
|
def test_similarity_critic_with_fuzzy_weight(self) -> None:
|
||||||
|
"""Test SimilarityCritic works with FuzzyWeight."""
|
||||||
|
from arcade_evals import SimilarityCritic
|
||||||
|
|
||||||
|
critic = SimilarityCritic(
|
||||||
|
critic_field="text",
|
||||||
|
weight=FuzzyWeight.MEDIUM,
|
||||||
|
)
|
||||||
|
assert critic.weight == FuzzyWeight.MEDIUM
|
||||||
|
assert critic.resolved_weight == 4
|
||||||
|
|
||||||
|
def test_datetime_critic_with_fuzzy_weight(self) -> None:
|
||||||
|
"""Test DatetimeCritic works with FuzzyWeight."""
|
||||||
|
from arcade_evals import DatetimeCritic
|
||||||
|
|
||||||
|
critic = DatetimeCritic(
|
||||||
|
critic_field="timestamp",
|
||||||
|
weight=FuzzyWeight.CRITICAL,
|
||||||
|
)
|
||||||
|
assert critic.weight == FuzzyWeight.CRITICAL
|
||||||
|
assert critic.resolved_weight == 7
|
||||||
63
libs/tests/sdk/test_loaders.py
Normal file
63
libs/tests/sdk/test_loaders.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""Unit tests for MCP tool loaders (no network / no external processes)."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import arcade_evals.loaders as loaders
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_imports_without_mcp_installed() -> None:
|
||||||
|
"""Importing the module must not require the optional MCP SDK."""
|
||||||
|
# If this import fails, the whole optional-dependency design breaks.
|
||||||
|
import arcade_evals.loaders # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_mcp_raises_helpful_error_when_missing() -> None:
|
||||||
|
"""Calling _require_mcp should raise a helpful ImportError if MCP isn't available."""
|
||||||
|
with patch.dict(sys.modules, {"mcp": None}):
|
||||||
|
with pytest.raises(ImportError) as exc:
|
||||||
|
loaders._require_mcp()
|
||||||
|
assert "MCP SDK is required" in str(exc.value)
|
||||||
|
assert "pip install" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_mcp_path_appends() -> None:
|
||||||
|
assert loaders._ensure_mcp_path("http://localhost:8000") == "http://localhost:8000/mcp"
|
||||||
|
assert loaders._ensure_mcp_path("http://localhost:8000/") == "http://localhost:8000/mcp"
|
||||||
|
assert (
|
||||||
|
loaders._ensure_mcp_path("http://localhost:8000?x=1")
|
||||||
|
== "http://localhost:8000/mcp?x=1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_mcp_path_does_not_duplicate() -> None:
|
||||||
|
assert loaders._ensure_mcp_path("http://localhost:8000/mcp") == "http://localhost:8000/mcp"
|
||||||
|
assert (
|
||||||
|
loaders._ensure_mcp_path("http://localhost:8000/mcp/?x=1")
|
||||||
|
== "http://localhost:8000/mcp?x=1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stdio_arcade_sets_env_and_calls_stdio_loader() -> None:
|
||||||
|
"""load_stdio_arcade_async should map auth into env vars and call load_from_stdio_async."""
|
||||||
|
with patch.object(loaders, "load_from_stdio_async", new_callable=AsyncMock) as mock_stdio:
|
||||||
|
mock_stdio.return_value = []
|
||||||
|
|
||||||
|
await loaders.load_stdio_arcade_async(
|
||||||
|
["python", "server.py"],
|
||||||
|
arcade_api_key="k",
|
||||||
|
arcade_user_id="u",
|
||||||
|
tool_secrets={"S": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
_, kwargs = mock_stdio.call_args
|
||||||
|
assert kwargs["env"]["ARCADE_API_KEY"] == "k"
|
||||||
|
assert kwargs["env"]["ARCADE_USER_ID"] == "u"
|
||||||
|
assert kwargs["env"]["S"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_arcade_api_base_url_constant() -> None:
|
||||||
|
"""Verify the default Arcade API base URL is set correctly."""
|
||||||
|
assert loaders.ARCADE_API_BASE_URL == "https://api.arcade.dev"
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import os
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "arcade-mcp"
|
name = "arcade-mcp"
|
||||||
version = "1.7.2"
|
version = "1.8.0"
|
||||||
description = "Arcade.dev - Tool Calling platform for Agents"
|
description = "Arcade.dev - Tool Calling platform for Agents"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
|
|
@ -20,35 +20,43 @@ requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
# CLI dependencies
|
# CLI dependencies
|
||||||
"arcade-mcp-server>=1.14.0,<2.0.0",
|
"arcade-mcp-server>=1.14.0,<2.0.0",
|
||||||
"arcade-core>=4.1.0,<5.0.0",
|
"arcade-core>=4.2.0,<5.0.0",
|
||||||
"typer==0.10.0",
|
"typer==0.10.0",
|
||||||
"rich>=14.0.0,<15.0.0",
|
"rich>=14.0.0,<15.0.0",
|
||||||
"Jinja2==3.1.6",
|
"Jinja2==3.1.6",
|
||||||
"authlib==1.6.5",
|
"authlib==1.6.5",
|
||||||
"arcadepy==1.8.0",
|
"arcadepy==1.8.0",
|
||||||
"tqdm==4.67.1",
|
"tqdm==4.67.1",
|
||||||
"openai==1.82.1",
|
|
||||||
"click==8.1.8",
|
"click==8.1.8",
|
||||||
"posthog==6.7.6",
|
"posthog==6.7.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
all = [
|
all = [
|
||||||
# evals
|
# LLM providers (needed for evals)
|
||||||
|
"openai==1.82.1",
|
||||||
|
"anthropic>=0.40.0",
|
||||||
|
"mcp>=1.9.0",
|
||||||
|
# Scientific computing (needed for evals)
|
||||||
"scipy>=1.14.0",
|
"scipy>=1.14.0",
|
||||||
"numpy>=2.0.0",
|
"numpy>=2.0.0",
|
||||||
"scikit-learn>=1.5.0",
|
"scikit-learn>=1.5.0",
|
||||||
"pytz>=2024.1",
|
"pytz>=2024.1",
|
||||||
"python-dateutil>=2.8.2",
|
"python-dateutil>=2.8.2",
|
||||||
# mcp
|
# mcp server
|
||||||
"arcade-mcp-server>=1.14.0,<2.0.0",
|
"arcade-mcp-server>=1.14.0,<2.0.0",
|
||||||
# serve
|
# serve
|
||||||
"arcade-serve>=3.2.0,<4.0.0",
|
"arcade-serve>=3.2.0,<4.0.0",
|
||||||
# tdk
|
# tdk
|
||||||
"arcade-tdk>=3.4.0,<4.0.0",
|
"arcade-tdk>=3.4.0,<4.0.0",
|
||||||
]
|
]
|
||||||
# Evals also depends on arcade-core and openai, but they are already required deps
|
|
||||||
evals = [
|
evals = [
|
||||||
|
# LLM providers
|
||||||
|
"openai==1.82.1",
|
||||||
|
"anthropic>=0.40.0",
|
||||||
|
"mcp>=1.9.0",
|
||||||
|
# Scientific computing
|
||||||
"scipy>=1.14.0",
|
"scipy>=1.14.0",
|
||||||
"numpy>=2.0.0",
|
"numpy>=2.0.0",
|
||||||
"scikit-learn>=1.5.0",
|
"scikit-learn>=1.5.0",
|
||||||
|
|
@ -58,12 +66,15 @@ evals = [
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
dev-dependencies = [
|
dev-dependencies = [
|
||||||
|
# Test framework
|
||||||
"pytest>=8.1.2",
|
"pytest>=8.1.2",
|
||||||
"pytest-cov>=4.0.0",
|
"pytest-cov>=4.0.0",
|
||||||
"pytest-asyncio>=0.23.7",
|
"pytest-asyncio>=0.23.7",
|
||||||
|
# Linting and type checking
|
||||||
"mypy>=1.5.1",
|
"mypy>=1.5.1",
|
||||||
"pre-commit>=3.4.0",
|
"pre-commit>=3.4.0",
|
||||||
"ruff>=0.4.0",
|
"ruff>=0.4.0",
|
||||||
|
# Type stubs
|
||||||
"types-Authlib>=1.3.0",
|
"types-Authlib>=1.3.0",
|
||||||
"types-PyYAML>=6.0.0",
|
"types-PyYAML>=6.0.0",
|
||||||
"types-python-dateutil>=2.8.2",
|
"types-python-dateutil>=2.8.2",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue